Compare commits

...
Author SHA1 Message Date
Nerdy-Technician fe07d2379a fix(integrations): update PatchMon API key settings link
Point the PatchMon “Get API key” link to /settings/integrations and add coverage for the generated URL.

Closes #6284
2026-07-11 21:45:46 +01:00
Thomas 576856d634 fix(beszel): add reliable live stats and filesystem UI (#6289) 2026-07-11 17:55:04 +02:00
Thomas def3ede3bc fix(db): restore widget_secret migration journal entries accidentally removed by #6206 (#6288) 2026-07-11 15:40:06 +02:00
homarr-releases[bot] d9c503d01b chore: update bug report template (#6281)
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-07-10 20:17:43 +00:00
Releases Homarr 82aa905214 chore(release): 1.70.0 [skip ci] 2026-07-10 20:03:12 +00:00
Thomas 4b351bb209 chore(release): automatic release v1.70.0 2026-07-10 22:01:50 +02:00
Thomas 13c9e5e181 fix(release): repair v1.70.0 quality failures (#6279)
* fix(release): repair v1.70.0 quality failures

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

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

Closes #6194
2026-07-06 01:50:13 +02:00
92 changed files with 4345 additions and 768 deletions

No files matched your search

+1
View File
@@ -33,6 +33,7 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 1.70.0
- 1.69.2
- 1.69.0
- 1.68.0
+2
View File
@@ -1,3 +1,5 @@
## [1.70.0](https://github.com/homarr-labs/homarr/compare/v1.69.2...v1.70.0) (2026-07-10)
## [1.69.2](https://github.com/homarr-labs/homarr/compare/v1.69.1...v1.69.2) (2026-07-06)
### Bug Fixes
+7
View File
@@ -135,6 +135,13 @@ packages/integrations/src/overseerr @manuel-rw
apps/docs/docs/integrations/paperless-ngx @ajnart
packages/integrations/src/paperless-ngx @ajnart
# PatchMon - introduced in https://github.com/homarr-labs/homarr/pull/6119 by @mrjoshuap
apps/docs/docs/integrations/patchmon @mrjoshuap
packages/integrations/src/patchmon @mrjoshuap
packages/widgets/src/patchmon @mrjoshuap
packages/api/src/router/widgets/patchmon.ts @mrjoshuap
packages/request-handler/src/patchmon.ts @mrjoshuap
# PeaNUT - introduced in https://github.com/homarr-labs/homarr/pull/5862 by @afonsojramos
apps/docs/docs/integrations/peanut @afonsojramos
packages/integrations/src/peanut @afonsojramos
@@ -2,9 +2,9 @@
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
![Version: 8.20.0](https://img.shields.io/badge/Version-8.20.0-informational?style=flat)
![Version: 8.21.0](https://img.shields.io/badge/Version-8.21.0-informational?style=flat)
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat)
![AppVersion: v1.68.0](https://img.shields.io/badge/AppVersion-v1.68.0-informational?style=flat)
![AppVersion: v1.69.2](https://img.shields.io/badge/AppVersion-v1.69.2-informational?style=flat)
A Helm chart to deploy homarr for Kubernetes
@@ -429,7 +429,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
| image.tag | string | `"v1.60.0"` | Overrides the image tag whose default is the chart appVersion |
| image.tag | string | `"v1.69.2"` | Overrides the image tag whose default is the chart appVersion |
| imagePullSecrets | list | `[]` | Secrets for Docker registry |
| ingress.annotations | object | `{}` | Ingress annotations |
| ingress.enabled | bool | `false` | Enable ingress |
@@ -0,0 +1,43 @@
---
title: 'PatchMon'
description: "PatchMon is a patch management and monitoring solution for Linux hosts."
hide_title: true
---
import { IntegrationHeader } from '@site/src/components/integrations/header';
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
import { AddingIntegration } from '@site/src/components/integrations/adding';
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
import { patchmonIntegration } from '.';
import { patchmonWidget } from '@site/docs/widgets/patchmon';
<IntegrationHeader
integration={patchmonIntegration}
categories={['Health Monitoring']}
/>
PatchMon provides host and package update statistics including total hosts, hosts needing updates, and security update counts.
The PatchMon widget requires users to be **signed in to Homarr**; see the [PatchMon widget documentation](/docs/widgets/patchmon#authentication) for details on authentication and public boards.
### Widgets & Capabilities
<IntegrationCapabilites
items={[
{ widget: patchmonWidget },
]}
/>
### Adding the integration
<AddingIntegration />
### Secrets
<IntegrationSecrets secrets={[{
credentials: ['patchmonApiKey', 'patchmonApiSecret'],
steps: [
'Open your PatchMon instance',
'Navigate to Settings and create a new GetHomepage API key',
'Copy the API key and secret from the success dialog',
'Paste them into Homarr — Basic authentication is handled automatically',
]
}]} />
@@ -0,0 +1,9 @@
import { IntegrationDefinition } from "@site/src/types";
export const patchmonIntegration: IntegrationDefinition = {
name: "PatchMon",
description:
"PatchMon is a patch management and monitoring solution for Linux hosts, tracking package updates and security patches across your infrastructure.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/patchmon.svg",
path: "../../integrations/patchmon",
};
+1
View File
@@ -141,6 +141,7 @@ Homarr exposes 50+ tools organized by namespace. Each tool is either a **query**
| `dnsHole` | DNS hole summary, toggle blocking | GET + POST |
| `downloads` | Active downloads, pause/resume/delete | GET + POST |
| `healthMonitoring` | Resource usage stats | GET |
| `patchmon` | Host patch and security update statistics | GET |
| `mediaRequests` | Approve/decline media requests | GET + POST |
| `mediaServer` | Media server sessions and stats | GET |
| `smartHome` | Execute automations, device states | GET + POST |
@@ -18,7 +18,9 @@ import beszelSystemGrid from './img/beszel-system-grid.png';
categories={['System Monitoring']}
/>
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Cards adapt their layout based on available space.
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Cards adapt their layout based on available space. If Beszel reports multiple filesystems, the disk bar exposes a hover breakdown for the root disk and each extra mount.
Select a system card to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
### Screenshots
@@ -1,24 +1,21 @@
---
title: 'Beszel System Stats'
description: 'Time-series charts for CPU, memory, disk, network, and Docker container metrics from Beszel.'
title: "Beszel System Stats"
description: "Time-series charts for CPU, memory, disk, network, and Docker container metrics from Beszel."
hide_title: true
---
import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { beszelSystemStatsWidget } from '.';
import { beszelIntegration } from '@site/docs/integrations/beszel';
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { beszelSystemStatsWidget } from ".";
import { beszelIntegration } from "@site/docs/integrations/beszel";
import beszelSystemStats from './img/beszel-system-stats.png';
import beszelSystemStats from "./img/beszel-system-stats.png";
<WidgetHeader
widget={beszelSystemStatsWidget}
categories={['System Monitoring', 'Charts']}
/>
<WidgetHeader widget={beszelSystemStatsWidget} categories={["System Monitoring", "Charts"]} />
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. Data is cached and refreshed periodically. You can select a time period ranging from 1 hour to 30 days.
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage chart combines the root disk and every extra filesystem reported by Beszel. You can use the realtime Live view or select a historical period ranging from 1 hour to 30 days. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data.
### Screenshots
@@ -26,14 +23,19 @@ This widget displays time-series charts for a selected Beszel system, including
### Supported Integrations
<WidgetIntegrations items={[{
integration: beszelIntegration,
note: "Displays time-series charts for a selected system"
}]} />
<WidgetIntegrations
items={[
{
integration: beszelIntegration,
note: "Displays time-series charts for a selected system",
},
]}
/>
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={beszelSystemStatsWidget.configuration} />
@@ -17,7 +17,9 @@ import { beszelIntegration } from '@site/docs/integrations/beszel';
categories={['System Monitoring']}
/>
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, and the table supports sorting by any column.
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, and the table supports sorting by any column. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk.
Select a system row to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
### Supported Integrations
+89
View File
@@ -0,0 +1,89 @@
---
title: 'PatchMon'
description: 'Displays host patch statistics from PatchMon including update and security counts.'
hide_title: true
---
import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { patchmonWidget } from '.';
import { patchmonIntegration } from '@site/docs/integrations/patchmon';
<WidgetHeader
widget={patchmonWidget}
categories={['Health Monitoring']}
/>
This widget displays patch statistics from your PatchMon instance, including total hosts, hosts needing updates, and security update counts. Each stat is shown with a dedicated icon and severity-tinted card background for quick visual scanning.
PatchMon does not render a built-in widget title. To add a label on your board, open **Edit item → Advanced options → Title** — the same optional overlay badge used by other Homarr widgets.
### Authentication
:::note
This widget requires a **signed-in Homarr session**. Anonymous visitors cannot load PatchMon data, even when the board is [public](/docs/management/boards#access-control). Placing this widget on a public board does not expose patch or security statistics to guests.
Stat visibility toggles in the widget settings control **display only**. Authenticated users with integration query access still receive the full API payload regardless of which stats are hidden in the UI.
:::
### Compliance hero
When **Show compliance hero** is enabled (default), the widget displays a ring showing the percentage of hosts that are fully up to date. The ring color follows the same threshold rules as the "Up-to-Date Hosts" stat. When the widget is **2 or more columns wide**, the hero also shows the "Patch Compliance" label and exact host count (e.g. "27 / 42") beside the ring.
In **1-column-wide** layouts (such as 1×1), the compliance hero switches to a ring-only view so useful compliance data remains visible in tight spaces. If the compliance hero is disabled, compact widgets fall back to the next selected content: OS distribution when enabled, then the first enabled stat.
### Threshold colors
When **Enable threshold colors** is on (default), stat values and card backgrounds are colored for at-a-glance severity:
| Stat | Green | Yellow | Red |
|------|-------|--------|-----|
| Needs Updates | 0 | 1–4 | 5+ |
| Security Updates | 0 | 1–9 | 10+ |
| Hosts with Security Updates | 0 | — | 1+ |
| Up-to-Date Hosts | 100% of total | 90–99% | below 90% |
| Outdated Packages | 0 | 1–49 | 50+ |
Enable **Advanced thresholds** to override warning and critical values per stat, using either absolute counts or percentages of total hosts.
### OS distribution
When **Show OS distribution** is enabled, the widget shows a breakdown of monitored hosts by operating system. Two display modes are available:
- **Horizontal bars** (default) — a scrollable list of colored progress bars showing each OS's share
- **Donut chart** — a compact donut chart visualization that scales down in narrow layouts
The OS distribution limit defaults to **All**. A widget size of **2×3** or larger is recommended when using this section alongside the default stats, but compact 1×1 and 2×1 layouts can still show multiple OS rows when OS distribution is the primary selected content. Wider donut layouts can also show a compact legend beside the chart.
### Responsive sizing
The widget adapts its layout based on the available space:
| Widget size | Layout |
|-------------|--------|
| **1×1** | Primary selected content: compliance ring (no label), compact OS distribution, or up to two enabled stats |
| **2×1** / **1×2** | Compact primary content; **2×1** shows compliance label and host ratio beside the ring; **1×2** uses ring-only hero |
| **2×2** (default) | Compliance hero, stat grid with icons, and tinted cards |
| **2×3** / **3×2** | Hero, stats, OS distribution, and "last updated" footer |
| **3×3** or larger | Full layout with up to 4-column stat grid, donut chart, and optional OS legend |
### Last updated
When the widget has enough space, a "last updated" timestamp appears at the bottom showing how recently PatchMon refreshed its data. Narrow layouts use a shortened timestamp such as `5m ago`; wider short layouts can show the full translated text.
### Supported Integrations
<WidgetIntegrations items={[{
integration: patchmonIntegration,
note: "Displays host patch and security update statistics"
}]} />
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={patchmonWidget.configuration} />
+200
View File
@@ -0,0 +1,200 @@
import { WidgetDefinition } from "@site/src/types";
import { IconShieldCheck } from "@tabler/icons-react";
export const patchmonWidget: WidgetDefinition = {
icon: IconShieldCheck,
name: "PatchMon",
description:
"Displays host patch statistics including total hosts, hosts needing updates, and security update counts.",
path: "../../widgets/patchmon",
configuration: {
items: [
{
name: "Show compliance hero",
description: "Display a compliance ring showing the percentage of up-to-date hosts",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show total hosts",
description: "Displays the total number of monitored hosts",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show hosts needing updates",
description: "Displays hosts with at least one outdated package",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show security updates",
description: "Displays the total number of security updates available",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show up-to-date hosts",
description: "Displays hosts with zero outdated packages",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Show hosts with security updates",
description: "Displays hosts requiring security patches",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Show recent updates (24h)",
description: "Displays the number of updates applied in the last 24 hours",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Show outdated packages",
description: "Displays the total number of outdated packages across all hosts",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Show repositories",
description: "Displays the number of active repositories being monitored",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Enable threshold colors",
description: "Colors stat values green, yellow, or red based on built-in severity presets",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Advanced thresholds",
description: "Customize warning and critical thresholds per stat (absolute count or % of total hosts)",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Needs updates threshold mode",
description:
"Compare the needs-updates count as an absolute value or percentage of total hosts. Shown when threshold colors and advanced thresholds are enabled.",
values: { type: "select", options: ["Absolute count", "Percent of total hosts"] },
defaultValue: "Absolute count",
},
{
name: "Needs updates warning threshold",
description: "Enter yellow when the value reaches this threshold",
values: "Integer ≥ 0",
defaultValue: "1",
},
{
name: "Needs updates critical threshold",
description: "Enter red when the value reaches this threshold",
values: "Integer ≥ 0",
defaultValue: "5",
},
{
name: "Security updates threshold mode",
description:
"Compare the security-updates count as an absolute value or percentage of total hosts. Shown when threshold colors and advanced thresholds are enabled.",
values: { type: "select", options: ["Absolute count", "Percent of total hosts"] },
defaultValue: "Absolute count",
},
{
name: "Security updates warning threshold",
description: "Enter yellow when the value reaches this threshold",
values: "Integer ≥ 0",
defaultValue: "1",
},
{
name: "Security updates critical threshold",
description: "Enter red when the value reaches this threshold",
values: "Integer ≥ 0",
defaultValue: "10",
},
{
name: "Hosts with security updates threshold mode",
description:
"Compare the host count as an absolute value or percentage of total hosts. Shown when threshold colors and advanced thresholds are enabled.",
values: { type: "select", options: ["Absolute count", "Percent of total hosts"] },
defaultValue: "Absolute count",
},
{
name: "Hosts with security updates warning threshold",
description: "Enter yellow when the value reaches this threshold",
values: "Integer ≥ 0",
defaultValue: "1",
},
{
name: "Hosts with security updates critical threshold",
description: "Enter red when the value reaches this threshold",
values: "Integer ≥ 0",
defaultValue: "1",
},
{
name: "Up-to-date hosts threshold mode",
description:
"Compare the up-to-date host count as an absolute value or percentage of total hosts. Shown when threshold colors and advanced thresholds are enabled.",
values: { type: "select", options: ["Absolute count", "Percent of total hosts"] },
defaultValue: "Percent of total hosts",
},
{
name: "Up-to-date hosts warning threshold",
description: "Enter yellow when the value is at or above this threshold but below full coverage",
values: "Integer ≥ 0",
defaultValue: "90",
},
{
name: "Up-to-date hosts critical threshold",
description: "Enter red when the value falls below this threshold",
values: "Integer ≥ 0",
defaultValue: "90",
},
{
name: "Outdated packages threshold mode",
description:
"Compare the outdated-packages count as an absolute value or percentage of total hosts. Shown when threshold colors, advanced thresholds, and Show outdated packages are enabled.",
values: { type: "select", options: ["Absolute count", "Percent of total hosts"] },
defaultValue: "Absolute count",
},
{
name: "Outdated packages warning threshold",
description: "Enter yellow when the value reaches this threshold",
values: "Integer ≥ 0",
defaultValue: "1",
},
{
name: "Outdated packages critical threshold",
description: "Enter red when the value reaches this threshold",
values: "Integer ≥ 0",
defaultValue: "50",
},
{
name: "Show OS distribution",
description: "Displays a horizontal bar breakdown of monitored hosts by operating system",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "OS display mode",
description:
"Choose how to display the operating system distribution. Shown when Show OS distribution is enabled.",
values: { type: "select", options: ["Horizontal bars", "Donut chart"] },
defaultValue: "Horizontal bars",
},
{
name: "OS distribution limit",
description: "Maximum number of operating systems to display in the distribution section",
values: { type: "select", options: ["Top 3", "Top 5", "Top 10", "All"] },
defaultValue: "All",
},
{
name: "Show OS version",
description: "Append the OS version to each distribution label when available",
values: { type: "boolean" },
defaultValue: "yes",
},
],
},
};
@@ -47,6 +47,16 @@ const secretKinds = {
description: "The Secret part of the API Key for authentication.",
icon: IconPassword,
},
patchmonApiKey: {
name: "API Key",
description: "The API key from PatchMon's GetHomepage integration.",
icon: IconKey,
},
patchmonApiSecret: {
name: "API Secret",
description: "The API secret from PatchMon's GetHomepage integration.",
icon: IconPassword,
},
githubAppId: { name: "App ID", description: "The ID of the GitHub App", icon: IconCode },
githubInstallationId: {
name: "Installation ID",
@@ -10,6 +10,7 @@ import {
createWSClient,
httpBatchStreamLink,
httpLink,
httpSubscriptionLink,
isNonJsonSerializable,
loggerLink,
splitLink,
@@ -101,9 +102,17 @@ export function TRPCReactProvider(props: PropsWithChildren) {
}),
splitLink({
condition: ({ type }) => type === "subscription",
true: wsLink<AppRouter>({
client: wsClient,
transformer: superjson,
true: splitLink({
condition: ({ path }) => path === "widget.beszel.subscribeSystemStats",
true: httpSubscriptionLink({
url: getTrpcUrl(),
transformer: superjson,
eventSourceOptions: { withCredentials: true },
}),
false: wsLink<AppRouter>({
client: wsClient,
transformer: superjson,
}),
}),
false: splitLink({
condition: ({ input }) => isNonJsonSerializable(input),
@@ -22,6 +22,7 @@ import { getI18n } from "@homarr/translation/server";
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
import { createMetaTitle } from "~/metadata";
import { env } from "~/env";
import { createBoardLayout } from "../_layout-creator";
import type { Board, Item } from "../_types";
import { DynamicClientBoard } from "./_dynamic-client";
@@ -40,7 +41,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
}: Props<TParams>) => {
return {
layout: createBoardLayout({
headerActions: <BoardContentHeaderActions />,
headerActions: <BoardContentHeaderActions demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
getInitialBoardAsync: getInitialBoard,
withTour: true,
}),
@@ -41,15 +41,12 @@ import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dy
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { HeaderButton } from "~/components/layout/header/button";
export const BoardContentHeaderActions = () => {
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
// Fall back to read-only if the query has no data (e.g. errored) so we never expose
// edit/save/settings UI that would then fail server-side.
const { data: demoReadOnly = true, isLoading } = clientApi.info.isDemoReadOnly.useQuery();
if (!hasChangeAccess || isLoading) {
if (!hasChangeAccess) {
return <SelectBoardsMenu />;
}
@@ -26,6 +26,8 @@ export const integrationSecretIcons = {
url: IconLink,
opnsenseApiKey: IconKey,
opnsenseApiSecret: IconPassword,
patchmonApiKey: IconKey,
patchmonApiSecret: IconPassword,
githubAppId: IconCode,
githubInstallationId: IconPlug,
privateKey: IconKey,
@@ -4,7 +4,7 @@ import type { MutableRefObject, ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Group, Loader, Menu, Switch, Text } from "@mantine/core";
import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react";
import type { QueryClient } from "@tanstack/react-query";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
import { clientApi } from "@homarr/api/client";
@@ -49,7 +49,10 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
const { gridstack } = useSectionContext().refs;
const queryClient = useQueryClient();
const widgetQueryKey = useMemo(() => [["widget", item.kind]], [item.kind]);
const widgetQueryKey = useMemo(
() => ("queryKey" in currentDefinition ? (currentDefinition.queryKey as QueryKey) : [["widget", item.kind]]),
[currentDefinition, item.kind],
);
const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0;
const handleRefetch = useCallback(() => {
void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" });
@@ -276,7 +279,7 @@ const WidgetCacheAge = ({
isFetching,
}: {
queryClient: QueryClient;
queryKey: unknown[];
queryKey: QueryKey;
isFetching: boolean;
}) => {
// 1s tick for "just now" → "1s ago" label transition; isFetching prop handles refetch reactivity
@@ -295,8 +298,8 @@ const WidgetCacheAge = ({
.filter(Boolean);
if (timestamps.length === 0) return null;
const oldest = Math.min(...timestamps);
const seconds = Math.floor((Date.now() - oldest) / 1000);
const latest = Math.max(...timestamps);
const seconds = Math.floor((Date.now() - latest) / 1000);
if (seconds < 5) return "just now";
if (seconds < 60) return `${seconds}s ago`;
return `${Math.floor(seconds / 60)}m ago`;
@@ -105,7 +105,7 @@ export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProp
content: (
<TourStepContent
description={t("welcome.description")}
documentationHref={createDocumentationLink("/docs/category/management")}
documentationHref={createDocumentationLink("/docs/management/boards")}
/>
),
},
+1
View File
@@ -4,6 +4,7 @@ export const env = createEnv({
server: {
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
DEMO_MODE: createBooleanSchema(false),
DEMO_READ_ONLY: createBooleanSchema(true),
},
experimental__runtimeEnv: process.env,
});
+40 -26
View File
@@ -95,20 +95,27 @@
</a>
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/bazarr" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/bazarr.svg" alt="Bazarr" width="90" height="90" />
<br/>
<p align="center">Bazarr</p>
</a>
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/beszel" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg" alt="Beszel" width="90" height="90" />
<br/>
<p align="center">Beszel</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/coolify" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/coolify.svg" alt="Coolify" width="90" height="90" />
<br/>
<p align="center">Coolify</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/dash-dot" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/dashdot.png" alt="Dash." width="90" height="90" />
<br/>
@@ -149,15 +156,15 @@
<br/>
<p align="center">Gotify</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/home-assistant" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/home-assistant.svg" alt="Home Assistant" width="90" height="90" />
<br/>
<p align="center">Home<br/>Assistant</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/ical" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ical.svg" alt="iCal" width="90" height="90" />
<br/>
@@ -198,15 +205,15 @@
<br/>
<p align="center">Navidrome</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/nextcloud" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nextcloud.svg" alt="Nextcloud" width="90" height="90" />
<br/>
<p align="center">Nextcloud</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/ntfy" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ntfy.svg" alt="ntfy" width="90" height="90" />
<br/>
@@ -247,6 +254,13 @@
<br/>
<p align="center">Paperless-ngx</p>
</a>
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/patchmon" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/patchmon.svg" alt="PatchMon" width="90" height="90" />
<br/>
<p align="center">PatchMon</p>
</a>
</td>
<td align="center">
<a href="null" target="_blank" rel="noreferrer noopener">
@@ -254,8 +268,8 @@
<br/>
<p align="center">PeaNUT</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/pi-hole" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg" alt="Pi-hole" width="90" height="90" />
<br/>
@@ -289,8 +303,8 @@
<br/>
<p align="center">qBittorrent</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/radarr" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/radarr.svg" alt="Radarr" width="90" height="90" />
<br/>
@@ -303,8 +317,8 @@
<br/>
<p align="center">Readarr</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/sabnzbd" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg" alt="SABnzbd" width="90" height="90" />
<br/>
@@ -338,8 +352,8 @@
<br/>
<p align="center">Speedtest<br/>Tracker</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/synology" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/synology.svg" alt="Synology DiskStation" width="90" height="90" />
<br/>
@@ -352,8 +366,8 @@
<br/>
<p align="center">Tdarr</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/technitium-dns" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/selfhst/icons/svg/technitium.svg" alt="Technitium DNS" width="90" height="90" />
<br/>
@@ -387,8 +401,8 @@
<br/>
<p align="center">TrueNAS</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/umami" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/umami.svg" alt="Umami" width="90" height="90" />
<br/>
@@ -401,8 +415,8 @@
<br/>
<p align="center">Unifi<br/>Controller</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/unraid" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/unraid.svg" alt="Unraid" width="90" height="90" />
<br/>
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.69.2",
"version": "1.70.0",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -43,7 +43,6 @@
"@semantic-release/release-notes-generator": "catalog:",
"@testcontainers/redis": "catalog:",
"@turbo/gen": "catalog:",
"@vitejs/plugin-react": "catalog:",
"@vitest/coverage-v8": "catalog:",
"@vitest/ui": "catalog:",
+2
View File
@@ -18,6 +18,7 @@ import { downloadsRouter } from "./router/widgets/downloads";
import { healthMonitoringRouter } from "./router/widgets/health-monitoring";
import { mediaRequestsRouter } from "./router/widgets/media-requests";
import { mediaServerRouter } from "./router/widgets/media-server";
import { patchmonRouter } from "./router/widgets/patchmon";
import { smartHomeRouter } from "./router/widgets/smart-home";
import { widgetSecretsRouter } from "./router/widgets/widget-secrets";
@@ -39,6 +40,7 @@ export const mcpRouter = createTRPCRouter({
healthMonitoring: healthMonitoringRouter,
mediaRequests: mediaRequestsRouter,
mediaServer: mediaServerRouter,
patchmon: patchmonRouter,
smartHome: smartHomeRouter,
widgetSecrets: widgetSecretsRouter,
});
+1 -1
View File
@@ -16,7 +16,7 @@ export const setActiveQueryCacheBoardId = (boardId: string | null) => {
export const getActiveQueryCacheBoardId = () => activeQueryCacheBoardId;
const persistableQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
const excludedWidgetPaths = new Set(["widget.app.ping"]);
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
const getTrpcPathFromQueryKey = (queryKey: QueryKey) => {
const first = queryKey[0];
@@ -0,0 +1,200 @@
import { TRPCError } from "@trpc/server";
import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database } from "@homarr/db";
import { boards, integrationItems, integrations, integrationUserPermissions, items, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import type { PatchMonStats } from "@homarr/integrations";
import { patchmonRouter } from "../../widgets/patchmon";
const samplePatchMonStats = {
totalHosts: 42,
hostsNeedingUpdates: 15,
securityUpdates: 23,
upToDateHosts: 27,
hostsWithSecurityUpdates: 8,
recentUpdates24h: 34,
totalOutdatedPackages: 156,
totalRepos: 12,
lastUpdated: "2025-10-11T12:34:56.789Z",
osDistribution: [],
} satisfies PatchMonStats;
vi.mock("@homarr/request-handler/patchmon", () => ({
patchmonStatsRequestHandler: {
handler: () => ({
getDataAsync: async () => ({
data: samplePatchMonStats,
timestamp: new Date(),
}),
}),
},
}));
const createSession = (userId: string): Session => ({
user: {
id: userId,
permissions: [],
colorScheme: "light",
},
expires: new Date().toISOString(),
});
const createCaller = (db: Database, session: Session | null) =>
patchmonRouter.createCaller({
db,
deviceType: undefined,
session,
});
const createPatchMonIntegrationOnBoardAsync = async (
db: Database,
options: {
isPublic: boolean;
boardCreatorId: string;
},
) => {
const integrationId = createId();
const boardId = createId();
const itemId = createId();
await db.insert(integrations).values({
id: integrationId,
kind: "patchmon",
name: "PatchMon",
url: "https://patchmon.example.com",
});
await db.insert(boards).values({
id: boardId,
name: "patchmon-board",
creatorId: options.boardCreatorId,
isPublic: options.isPublic,
});
await db.insert(items).values({
id: itemId,
kind: "patchmon",
boardId,
options: "{}",
});
await db.insert(integrationItems).values({
integrationId,
itemId,
});
return { integrationId, boardId, itemId };
};
describe("patchmonRouter.getStats access control", () => {
test("should throw UNAUTHORIZED for unauthenticated users even when integration is on a public board", async () => {
const db = createDb();
const otherUserId = createId();
await db.insert(users).values({ id: otherUserId });
const { integrationId } = await createPatchMonIntegrationOnBoardAsync(db, {
isPublic: true,
boardCreatorId: otherUserId,
});
const caller = createCaller(db, null);
await expect(caller.getStats({ integrationId })).rejects.toThrow(
new TRPCError({ code: "UNAUTHORIZED" }),
);
});
test("should throw FORBIDDEN for authenticated users without board or integration access", async () => {
const db = createDb();
const boardOwnerId = createId();
const sessionUserId = createId();
await db.insert(users).values([{ id: boardOwnerId }, { id: sessionUserId }]);
const { integrationId } = await createPatchMonIntegrationOnBoardAsync(db, {
isPublic: false,
boardCreatorId: boardOwnerId,
});
const caller = createCaller(db, createSession(sessionUserId));
await expect(caller.getStats({ integrationId })).rejects.toThrow(
new TRPCError({
code: "FORBIDDEN",
message: "User does not have permission to query at least one of the specified integration",
}),
);
});
test("should return stats for authenticated users when integration is on a public board", async () => {
const db = createDb();
const boardOwnerId = createId();
const sessionUserId = createId();
await db.insert(users).values([{ id: boardOwnerId }, { id: sessionUserId }]);
const { integrationId } = await createPatchMonIntegrationOnBoardAsync(db, {
isPublic: true,
boardCreatorId: boardOwnerId,
});
const caller = createCaller(db, createSession(sessionUserId));
const result = await caller.getStats({ integrationId });
expect(result).toStrictEqual(samplePatchMonStats);
});
test("should return stats for authenticated users with integration use permission and no board placement", async () => {
const db = createDb();
const sessionUserId = createId();
const integrationId = createId();
await db.insert(users).values({ id: sessionUserId });
await db.insert(integrations).values({
id: integrationId,
kind: "patchmon",
name: "PatchMon",
url: "https://patchmon.example.com",
});
await db.insert(integrationUserPermissions).values({
integrationId,
userId: sessionUserId,
permission: "use",
});
const caller = createCaller(db, createSession(sessionUserId));
const result = await caller.getStats({ integrationId });
expect(result).toStrictEqual(samplePatchMonStats);
});
test("should throw NOT_FOUND when integration id does not exist", async () => {
const db = createDb();
const sessionUserId = createId();
await db.insert(users).values({ id: sessionUserId });
const caller = createCaller(db, createSession(sessionUserId));
await expect(caller.getStats({ integrationId: createId() })).rejects.toMatchObject({
code: "NOT_FOUND",
});
});
test("should throw NOT_FOUND when integration is not a PatchMon integration", async () => {
const db = createDb();
const sessionUserId = createId();
const integrationId = createId();
await db.insert(users).values({ id: sessionUserId });
await db.insert(integrations).values({
id: integrationId,
kind: "sonarr",
name: "Sonarr",
url: "https://sonarr.example.com",
});
await db.insert(integrationUserPermissions).values({
integrationId,
userId: sessionUserId,
permission: "use",
});
const caller = createCaller(db, createSession(sessionUserId));
await expect(caller.getStats({ integrationId })).rejects.toMatchObject({
code: "NOT_FOUND",
});
});
});
+39 -1
View File
@@ -207,6 +207,13 @@ export const beszelRouter = createTRPCRouter({
return observable<LiveStatsEvent>((emit) => {
const controller = new AbortController();
let isActive = true;
let emittedEventCount = 0;
logger.debug("Beszel realtime subscription started", {
userId: ctx.session?.user?.id,
integrationIds: ctx.integrations.map((integration) => integration.id),
systemId: input.systemId,
});
void (async () => {
const integration = ctx.integrations[0];
@@ -223,7 +230,19 @@ export const beszelRouter = createTRPCRouter({
await instance.subscribeRealtimeMetrics(
input.systemId,
(event) => {
if (isActive) emit.next(event);
if (!isActive) return;
emittedEventCount += 1;
if (emittedEventCount <= 2 || emittedEventCount % 60 === 0) {
logger.debug("Forwarding Beszel realtime events", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
eventType: event.type,
emittedEventCount,
statsCount: Array.isArray(event.record.stats) ? event.record.stats.length : undefined,
});
}
emit.next(event);
},
controller.signal,
);
@@ -237,6 +256,13 @@ export const beszelRouter = createTRPCRouter({
}
} catch (error) {
if (isActive) {
logger.warn("Beszel realtime subscription failed", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
emittedEventCount,
error: errorMessage(error),
});
emit.error(
error instanceof TRPCError
? error
@@ -249,12 +275,24 @@ export const beszelRouter = createTRPCRouter({
}
if (isActive) {
logger.debug("Beszel realtime subscription completed", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
emittedEventCount,
});
emit.complete();
}
})();
return () => {
isActive = false;
logger.debug("Beszel realtime subscription cancelled", {
userId: ctx.session?.user?.id,
integrationIds: ctx.integrations.map((integration) => integration.id),
systemId: input.systemId,
emittedEventCount,
});
controller.abort();
};
});
+1
View File
@@ -9,6 +9,7 @@ export const widgetRouter = createTRPCRouter({
coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)),
immich: lazy(() => import("./immich").then((mod) => mod.immichRouter)),
paperlessNgx: lazy(() => import("./paperless-ngx").then((mod) => mod.paperlessNgxRouter)),
patchmon: lazy(() => import("./patchmon").then((mod) => mod.patchmonRouter)),
bazarr: lazy(() => import("./bazarr").then((mod) => mod.bazarrRouter)),
notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)),
weather: lazy(() => import("./weather").then((mod) => mod.weatherRouter)),
@@ -21,7 +21,7 @@ export const mediaOrganizerRouter = createTRPCRouter({
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = mediaOrganizerRequestHandler.handler(integration, { pageSize: input.pageSize });
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
const { data } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
...data,
@@ -0,0 +1,21 @@
import { patchmonStatsRequestHandler } from "@homarr/request-handler/patchmon";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure } from "../../trpc";
export const patchmonRouter = createTRPCRouter({
getStats: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Get PatchMon host patch statistics including total hosts, hosts needing updates, security update counts, up-to-date hosts, outdated packages, repositories, and OS distribution. REQUIRED: integrationId (single PatchMon integration ID from integration_all). Requires authenticated session (API key or browser login) and integration query access.",
},
})
.concat(createOneIntegrationMiddleware("query", "patchmon"))
.query(async ({ ctx }) => {
const innerHandler = patchmonStatsRequestHandler.handler(ctx.integration, {});
const { data } = await innerHandler.getDataAsync();
return data;
}),
});
+12 -6
View File
@@ -45,7 +45,7 @@ export const releasesRouter = createTRPCRouter({
)
.query(async ({ ctx, input }) => {
const tokensByProvider = new Map<string, string>();
let allowedRepoIds: Set<string> | null = null;
const savedRepos = new Map<string, { providerUrl?: string }>();
if (input.itemId) {
try {
@@ -54,9 +54,15 @@ export const releasesRouter = createTRPCRouter({
await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "view");
const options = SuperJSON.parse<Record<string, unknown>>(item.options);
const repos = options.repositories as Array<{ id?: string }> | undefined;
const repos = options.repositories as
| Array<{ provider?: string; identifier: string; providerUrl?: string }>
| undefined;
if (repos) {
allowedRepoIds = new Set(repos.map((r) => r.id).filter((id): id is string => Boolean(id)));
for (const repo of repos) {
if (repo.provider && repo.identifier) {
savedRepos.set(`${repo.provider}:${repo.identifier}`, { providerUrl: repo.providerUrl });
}
}
}
const secrets = await ctx.db.query.widgetSecrets.findMany({
@@ -78,16 +84,16 @@ export const releasesRouter = createTRPCRouter({
return await Promise.all(
input.repositories.map(async (repository) => {
const repositoryId = repository.id ?? repository.identifier;
const savedRepo = savedRepos.get(`${repository.provider}:${repository.identifier}`);
try {
const useToken = allowedRepoIds === null || allowedRepoIds.has(repositoryId);
const response = await releasesRequestHandler
.handler({
id: repositoryId,
provider: repository.provider,
identifier: repository.identifier,
versionRegex: formatVersionFilterRegex(repository.versionFilter),
providerUrl: repository.providerUrl,
token: useToken ? tokensByProvider.get(repository.provider) : undefined,
providerUrl: savedRepo?.providerUrl ?? repository.providerUrl,
token: savedRepo ? tokensByProvider.get(repository.provider) : undefined,
})
.getDataAsync();
@@ -67,6 +67,8 @@ describe("isPersistableWidgetQueryKey", () => {
[[["docker", "getContainers"], { type: "query" }], true],
[[["integration", "byIds"], { type: "query" }], true],
[[["widget", "app", "ping"], { type: "query", input: { id: "abc" } }], false],
[[["widget", "beszel", "getSystemStats"], { type: "query" }], false],
[[["widget", "beszel", "getSystems"], { type: "query" }], true],
[[["app", "selectable"], { type: "query" }], false],
[[["board", "getBoardByName"], { type: "query" }], false],
[["widget"], false],
@@ -2,7 +2,7 @@ import type { AgentOptions } from "node:https";
import { Agent as HttpsAgent } from "node:https";
import { checkServerIdentity } from "node:tls";
import axios from "axios";
import type { RequestInfo, RequestInit, Response } from "undici";
import type { Agent as UndiciAgent, RequestInfo, RequestInit, Response } from "undici";
import { fetch } from "undici";
import {
@@ -29,11 +29,15 @@ export const createCustomCheckServerIdentity = (
};
};
export const createCertificateAgentAsync = async (override?: {
ca: string | string[];
checkServerIdentity: typeof checkServerIdentity;
}) => {
export const createCertificateAgentAsync = async (
override?: {
ca: string | string[];
checkServerIdentity: typeof checkServerIdentity;
},
agentOptions?: Pick<UndiciAgent.Options, "bodyTimeout">,
) => {
return new UndiciHttpAgent({
...agentOptions,
connect: override ?? {
ca: await getAllTrustedCertificatesAsync(),
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
@@ -61,14 +65,18 @@ export const createAxiosCertificateInstanceAsync = async (
export const fetchWithTrustedCertificatesAsync = async (
url: RequestInfo,
options?: RequestInit & { timeout?: number },
options?: RequestInit & { timeout?: number; bodyTimeout?: number },
): Promise<Response> => {
const agent = await createCertificateAgentAsync(undefined);
const agent = await createCertificateAgentAsync(
undefined,
options?.bodyTimeout !== undefined ? { bodyTimeout: options.bodyTimeout } : undefined,
);
if (options?.timeout) {
const { bodyTimeout: _bodyTimeout, ...fetchOptions } = options;
return await withTimeoutAsync(
async (signal) =>
fetch(url, {
...options,
...fetchOptions,
signal,
dispatcher: agent,
}),
@@ -76,8 +84,9 @@ export const fetchWithTrustedCertificatesAsync = async (
);
}
const { bodyTimeout: _bodyTimeout, ...fetchOptions } = options ?? {};
return fetch(url, {
...options,
...fetchOptions,
dispatcher: agent,
});
};
@@ -295,6 +295,13 @@
"when": 1780764500000,
"tag": "0041_merge_custom_widget_url",
"breakpoints": true
},
{
"idx": 42,
"version": "5",
"when": 1783176100870,
"tag": "0042_add_widget_secret",
"breakpoints": true
}
]
}
@@ -57,6 +57,13 @@
"when": 1780764500000,
"tag": "0007_merge_custom_widget_url",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1783176100870,
"tag": "0008_add_widget_secret",
"breakpoints": true
}
]
}
@@ -281,6 +281,13 @@
"when": 1780764500000,
"tag": "0039_merge_custom_widget_url",
"breakpoints": true
},
{
"idx": 40,
"version": "6",
"when": 1783176100870,
"tag": "0040_add_widget_secret",
"breakpoints": true
}
]
}
@@ -31,6 +31,7 @@ const integrationAliases: Partial<Record<IntegrationKind, readonly string[]>> =
audiobookshelf: ["audiobookshelf"],
navidrome: ["navidrome"],
paperlessNgx: ["paperless-ngx", "paperless"],
patchmon: ["patchmon", "patch-mon"],
coolify: ["coolify"],
truenas: ["truenas"],
bazarr: ["bazarr"],
@@ -159,6 +159,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/archiveteam-warrior"
| "/docs/integrations/aria2"
| "/docs/integrations/audiobookshelf"
| "/docs/integrations/bazarr"
| "/docs/integrations/beszel"
| "/docs/integrations/coolify"
| "/docs/integrations/dash-dot"
@@ -183,6 +184,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/opnsense"
| "/docs/integrations/overseerr"
| "/docs/integrations/paperless-ngx"
| "/docs/integrations/patchmon"
| "/docs/integrations/peanut"
| "/docs/integrations/pi-hole"
| "/docs/integrations/plex"
@@ -223,6 +225,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/anchor-note"
| "/docs/widgets/app"
| "/docs/widgets/audio-stats"
| "/docs/widgets/bazarr"
| "/docs/widgets/beszel-alerts"
| "/docs/widgets/beszel-system-grid"
| "/docs/widgets/beszel-system-stats"
@@ -242,6 +245,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/immich-album-carousel"
| "/docs/widgets/immich-server-stats"
| "/docs/widgets/indexer-manager"
| "/docs/widgets/media-missing"
| "/docs/widgets/media-releases"
| "/docs/widgets/media-request-list"
| "/docs/widgets/media-request-stats"
@@ -253,6 +257,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/notebook"
| "/docs/widgets/notifications"
| "/docs/widgets/paperless-ngx"
| "/docs/widgets/patchmon"
| "/docs/widgets/releases"
| "/docs/widgets/rss-feed"
| "/docs/widgets/smart-home-entity-state"
@@ -313,6 +318,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/coolify"
| "/docs/integrations/immich"
| "/docs/integrations/paperless-ngx"
| "/docs/integrations/patchmon"
| "/docs/integrations/tracearr"
| "/docs/integrations/speedtest-tracker"
| "/docs/integrations/uptime-kuma"
@@ -341,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"
@@ -360,6 +367,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/immich-server-stats"
| "/docs/widgets/immich-album-carousel"
| "/docs/widgets/paperless-ngx"
| "/docs/widgets/patchmon"
| "/docs/widgets/bazarr"
| "/docs/widgets/tracearr"
| "/docs/widgets/speedtest-tracker"
@@ -42,6 +42,7 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
coolify: "coolify",
immich: "immich",
paperlessNgx: "paperless-ngx",
patchmon: "patchmon",
tracearr: "tracearr",
speedtestTracker: "speedtest-tracker",
uptimeKuma: "uptime-kuma",
@@ -39,6 +39,7 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
"immich-serverStats": "immich-server-stats",
"immich-albumCarousel": "immich-album-carousel",
paperlessNgx: "paperless-ngx",
patchmon: "patchmon",
bazarr: "bazarr",
tracearr: "tracearr",
speedtestTracker: "speedtest-tracker",
+11
View File
@@ -13,6 +13,8 @@ export const integrationSecretKindObject = {
topic: { isPublic: true, multiline: false },
opnsenseApiKey: { isPublic: false, multiline: false },
opnsenseApiSecret: { isPublic: false, multiline: false },
patchmonApiKey: { isPublic: true, multiline: false },
patchmonApiSecret: { isPublic: false, multiline: false },
url: { isPublic: false, multiline: false },
privateKey: { isPublic: false, multiline: true },
githubAppId: { isPublic: true, multiline: false },
@@ -370,6 +372,15 @@ export const integrationDefs = {
documentationUrl: null,
defaultPort: 8000,
},
patchmon: {
name: "PatchMon",
secretKinds: [["patchmonApiKey", "patchmonApiSecret"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/patchmon.svg",
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/patchmon"),
defaultPort: 3413,
apiKeySettingsPath: "/settings/integrations",
},
tracearr: {
name: "Tracearr",
secretKinds: [["apiKey"]],
@@ -15,6 +15,12 @@ describe("getIntegrationApiKeyUrl", () => {
);
});
it("builds URL for patchmon", () => {
expect(getIntegrationApiKeyUrl("http://patchmon.local:3413", "patchmon")).toBe(
"http://patchmon.local:3413/settings/integrations",
);
});
it("returns null for integrations without apiKeySettingsPath", () => {
expect(getIntegrationApiKeyUrl("http://localhost", "plex")).toBeNull();
});
@@ -24,7 +24,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
networkControllerSummary: getIntegrationKindsByCategory("networkController"),
networkControllerStatus: getIntegrationKindsByCategory("networkController"),
indexerManager: getIntegrationKindsByCategory("indexerManager"),
healthMonitoring: getIntegrationKindsByCategory("healthMonitoring"),
healthMonitoring: getIntegrationKindsByCategory("healthMonitoring").filter((kind) => kind !== "patchmon"),
firewall: getIntegrationKindsByCategory("firewall"),
notifications: getIntegrationKindsByCategory("notifications"),
mediaReleases: ["emby", "jellyfin", "plex"],
@@ -34,6 +34,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
"immich-serverStats": ["immich"],
"immich-albumCarousel": ["immich"],
paperlessNgx: ["paperlessNgx"],
patchmon: ["patchmon"],
bazarr: ["bazarr"],
tracearr: ["tracearr"],
speedtestTracker: ["speedtestTracker"],
+2
View File
@@ -37,6 +37,7 @@ export const widgetKinds = [
"immich-serverStats",
"immich-albumCarousel",
"paperlessNgx",
"patchmon",
"bazarr",
"tracearr",
"speedtestTracker",
@@ -59,6 +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 },
};
@@ -37,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";
@@ -120,6 +121,7 @@ export const integrationCreators = {
glances: GlancesIntegration,
immich: ImmichIntegration,
paperlessNgx: PaperlessNgxIntegration,
patchmon: PatchMonIntegration,
speedtestTracker: SpeedtestTrackerIntegration,
audiobookshelf: AudiobookshelfIntegration,
navidrome: NavidromeIntegration,
@@ -19,15 +19,66 @@ import type {
BeszelSystemDetails,
BeszelSystemdService,
BeszelSystemStatsRecord,
BeszelSystemStats,
BeszelContainerStats,
CreateAlertInput,
LiveStatsEvent,
PocketBaseListResponse,
UpdateAlertInput,
} from "./beszel-types";
import { createRealtimeMetricsTopic, PocketBaseSseParser } from "./pocketbase-realtime";
const logger = createLogger({ module: "beszel-integration" });
const escapeFilterValue = (value: string) => value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
const realtimeRetryDelaysMs = [200, 300, 500, 1_000, 1_500, 2_000] as const;
const supportsRealtimeMetrics = (version: string) => {
const [major = 0, minor = 0] = version.replace(/^v/, "").split(".").map(Number);
return major > 0 || minor >= 13;
};
const waitForRetryAsync = async (delayMs: number, signal: AbortSignal) =>
await new Promise<void>((resolve) => {
if (signal.aborted) return resolve();
const timeout = setTimeout(done, delayMs);
signal.addEventListener("abort", done, { once: true });
function done() {
clearTimeout(timeout);
signal.removeEventListener("abort", done);
resolve();
}
});
interface BeszelRealtimeSnapshot {
stats?: BeszelSystemStats;
container?: BeszelContainerStats[];
}
export const normalizeRealtimeSnapshot = (
payload: BeszelRealtimeSnapshot,
systemId: string,
receivedAt = new Date(),
): LiveStatsEvent[] => {
const created = receivedAt.toISOString();
const id = `realtime-${receivedAt.getTime()}`;
const events: LiveStatsEvent[] = [];
if (payload.stats && typeof payload.stats === "object" && !Array.isArray(payload.stats)) {
events.push({
type: "system_stats",
record: { id, system: systemId, stats: payload.stats, type: "1m", created, updated: created },
});
}
if (Array.isArray(payload.container)) {
events.push({
type: "container_stats",
record: { id, system: systemId, stats: payload.container, type: "1m", created, updated: created },
});
}
return events;
};
interface BeszelSession {
token: string;
@@ -285,123 +336,163 @@ export class BeszelIntegration extends Integration {
});
}
/**
* Subscribe to real-time system and container stats via PocketBase SSE.
* Connects to the PocketBase Realtime API and parses incoming SSE events,
* filtering by systemId. This provides live-updating metrics (≤1s latency
* from agent) instead of polling the REST API every 5s.
*/
/** Subscribe to Beszel's custom one-second `rt_metrics` PocketBase topic. */
public async subscribeRealtimeMetrics(
systemId: string,
onMessage: (event: LiveStatsEvent) => void,
signal: AbortSignal,
): Promise<void> {
const session = await this.authenticateAsync();
const realtimeUrl = this.url("/api/realtime");
logger.debug("Opening Beszel SSE connection for realtime metrics", {
integrationId: this.integration.id,
systemId,
url: realtimeUrl.pathname,
});
const response = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
headers: { Authorization: session.token },
signal,
});
if (!response.ok) {
logger.warn("Beszel SSE connection failed", {
integrationId: this.integration.id,
systemId,
status: response.status,
});
throw new ResponseError(response);
const systems = await this.getSystemsAsync();
const system = systems.find((candidate) => candidate.id === systemId);
if (!system) throw new Error("Selected Beszel system was not found");
if (system.status !== "up")
throw new Error(`Beszel system is ${system.status}; Live mode requires an online system`);
if (!supportsRealtimeMetrics(system.info.v)) {
throw new Error(`Beszel agent ${system.info.v} does not support Live mode; version 0.13.0 or newer is required`);
}
const reader = response.body?.getReader();
if (!reader) throw new Error("No response body in SSE stream");
const decoder = new TextDecoder();
let buffer = "";
let subscribed = false;
const processLine = async (line: string) => {
if (!line.startsWith("data: ")) return;
const realtimeUrl = this.url("/api/realtime");
const topic = createRealtimeMetricsTopic(systemId);
let retryAttempt = 0;
let emittedSnapshotCount = 0;
while (!signal.aborted) {
try {
const parsed = JSON.parse(line.slice(6)) as Record<string, unknown>;
// PB_CONNECT delivers the clientId — use it to POST our subscriptions
if (!subscribed && typeof parsed.clientId === "string") {
subscribed = true;
const subscribeResponse = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
method: "POST",
let session = await this.authenticateAsync();
const openStream = async () =>
await fetchWithTrustedCertificatesAsync(realtimeUrl, {
headers: {
Accept: "text/event-stream",
Authorization: session.token,
"Content-Type": "application/json",
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
body: JSON.stringify({
clientId: parsed.clientId,
subscriptions: ["system_stats", "container_stats"],
}),
signal,
bodyTimeout: 0,
});
if (!subscribeResponse.ok) {
logger.warn("Beszel SSE subscription POST failed", {
logger.debug("Opening Beszel rt_metrics SSE connection", {
integrationId: this.integration.id,
systemId,
retryAttempt,
});
let response = await openStream();
if (response.status === 401) {
await this.sessionStore.clearAsync();
session = await this.authenticateAsync();
response = await openStream();
}
if (!response.ok) throw new ResponseError(response);
const reader = response.body?.getReader();
if (!reader) throw new Error("Beszel realtime response has no body");
const decoder = new TextDecoder();
const parser = new PocketBaseSseParser();
let clientId: string | undefined;
const processFrameAsync = async (frame: ReturnType<PocketBaseSseParser["push"]>[number]) => {
if (frame.event === "PB_CONNECT") {
let fallbackClientId: string | undefined;
try {
const connectData = JSON.parse(frame.data) as { clientId?: string };
fallbackClientId = connectData.clientId;
} catch {
// PocketBase normally provides the client ID in the SSE id field.
}
clientId = frame.id || fallbackClientId;
if (!clientId) throw new Error("PocketBase PB_CONNECT event did not include a client ID");
const subscribeResponse = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
method: "POST",
headers: { Authorization: session.token, "Content-Type": "application/json" },
body: JSON.stringify({ clientId, subscriptions: [topic] }),
signal,
});
if (subscribeResponse.status === 401) await this.sessionStore.clearAsync();
if (!subscribeResponse.ok) throw new ResponseError(subscribeResponse);
logger.debug("Subscribed to Beszel rt_metrics topic", {
integrationId: this.integration.id,
systemId,
status: subscribeResponse.status,
clientId,
});
throw new ResponseError(subscribeResponse);
return;
}
logger.debug("Beszel SSE subscribed to collections", {
if (!frame.event.startsWith("rt_metrics")) return;
try {
const snapshot = JSON.parse(frame.data) as BeszelRealtimeSnapshot;
const events = normalizeRealtimeSnapshot(snapshot, systemId);
if (events.length === 0) return;
emittedSnapshotCount += 1;
retryAttempt = 0;
for (const event of events) onMessage(event);
if (emittedSnapshotCount === 1 || emittedSnapshotCount % 30 === 0) {
logger.debug("Forwarded Beszel rt_metrics snapshots", {
integrationId: this.integration.id,
systemId,
emittedSnapshotCount,
containerCount: snapshot.container?.length ?? 0,
});
}
} catch (error) {
logger.warn("Failed to parse Beszel rt_metrics payload", {
integrationId: this.integration.id,
systemId,
error: error instanceof Error ? error.message : String(error),
});
}
};
logger.debug("Beszel rt_metrics SSE connection established", {
integrationId: this.integration.id,
systemId,
contentType: response.headers.get("content-type"),
});
try {
while (!signal.aborted) {
const { done, value } = await reader.read();
if (done) break;
for (const frame of parser.push(decoder.decode(value, { stream: true }))) {
await processFrameAsync(frame);
}
}
for (const frame of parser.push(decoder.decode())) await processFrameAsync(frame);
for (const frame of parser.finish()) await processFrameAsync(frame);
} finally {
await reader.cancel().catch(() => {});
reader.releaseLock();
}
if (!signal.aborted) throw new Error("Beszel rt_metrics SSE stream ended unexpectedly");
} catch (error) {
if (signal.aborted) break;
if (error instanceof ResponseError && error.statusCode === 401 && retryAttempt === 0) {
retryAttempt += 1;
logger.debug("Retrying Beszel rt_metrics handshake after reauthentication", {
integrationId: this.integration.id,
systemId,
clientId: parsed.clientId,
});
return;
continue;
}
const record = parsed.record as Record<string, unknown> | undefined;
if (!record || record.system !== systemId) return;
if (Array.isArray(record.stats)) {
onMessage({
type: "container_stats",
record: record as unknown as BeszelContainerStatsRecord,
});
} else if (record.stats && typeof record.stats === "object") {
onMessage({
type: "system_stats",
record: record as unknown as BeszelSystemStatsRecord,
});
}
} catch (error) {
if (error instanceof ResponseError) throw error;
// skip malformed SSE data lines
if (error instanceof ResponseError && error.statusCode < 500 && error.statusCode !== 429) throw error;
const delayMs = realtimeRetryDelaysMs[Math.min(retryAttempt, realtimeRetryDelaysMs.length - 1)] ?? 2_000;
retryAttempt += 1;
logger.warn("Beszel rt_metrics connection lost; retrying", {
integrationId: this.integration.id,
systemId,
retryAttempt,
delayMs,
error: error instanceof Error ? error.message : String(error),
});
await waitForRetryAsync(delayMs, signal);
}
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
await processLine(line);
}
}
} finally {
reader.releaseLock();
logger.debug("Beszel SSE connection closed", { integrationId: this.integration.id, systemId });
}
logger.debug("Beszel rt_metrics subscription stopped", {
integrationId: this.integration.id,
systemId,
emittedSnapshotCount,
});
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
@@ -133,9 +133,9 @@ export interface BeszelSystemStats {
/** disk usage (%) */
dp: number;
/** disk read (MB/s) */
dr: number;
dr?: number;
/** disk write (MB/s) */
dw: number;
dw?: number;
/** disk read max (MB/s) */
drm?: number;
/** disk write max (MB/s) */
@@ -145,9 +145,9 @@ export interface BeszelSystemStats {
/** disk IOPS max [read, write] */
diom?: [number, number];
/** network sent — all interfaces (bytes/s) */
ns: number;
ns?: number;
/** network received — all interfaces (bytes/s) */
nr: number;
nr?: number;
/** bandwidth — public interfaces only (bytes/s) [sent, recv]. Prefer over ns/nr */
b?: [number, number];
/** network sent max (bytes/s) */
@@ -169,26 +169,26 @@ export interface BeszelSystemStats {
}
export interface BeszelExtraFsStats {
/** total disk (bytes) */
/** total disk (GiB) */
d: number;
/** disk used (bytes) */
/** disk used (GiB) */
du: number;
/** read (bytes/s) */
r: number;
/** write (bytes/s) */
w: number;
/** read max (bytes/s) */
rm: number;
rm?: number;
/** write max (bytes/s) */
wm: number;
wm?: number;
/** read (IOPS) */
rb: number;
rb?: number;
/** write (IOPS) */
wb: number;
wb?: number;
/** read max (IOPS) */
rbm: number;
rbm?: number;
/** write max (IOPS) */
wbm: number;
wbm?: number;
}
export interface BeszelGPUData {
@@ -382,6 +382,8 @@ export interface BeszelSystemRow {
memory: number;
/** disk usage (%) */
disk: number;
/** extra filesystem usage (%) keyed by mount path */
extraFilesystems: Record<string, number>;
/** GPU usage (%) */
gpu: number;
/** load average [1m, 5m, 15m] */
@@ -0,0 +1,65 @@
export interface PocketBaseSseFrame {
event: string;
id?: string;
data: string;
}
export const createRealtimeMetricsTopic = (systemId: string) =>
`rt_metrics?options=${encodeURIComponent(JSON.stringify({ query: { system: systemId } }))}`;
export class PocketBaseSseParser {
private buffer = "";
private event = "message";
private id: string | undefined;
private dataLines: string[] = [];
public push(chunk: string): PocketBaseSseFrame[] {
this.buffer += chunk;
const frames: PocketBaseSseFrame[] = [];
const lines = this.buffer.split(/\r?\n/);
this.buffer = lines.pop() ?? "";
for (const line of lines) {
const frame = this.processLine(line);
if (frame) frames.push(frame);
}
return frames;
}
public finish(): PocketBaseSseFrame[] {
const frames: PocketBaseSseFrame[] = [];
if (this.buffer !== "") {
const frame = this.processLine(this.buffer);
if (frame) frames.push(frame);
this.buffer = "";
}
const trailingFrame = this.dispatch();
if (trailingFrame) frames.push(trailingFrame);
return frames;
}
private processLine(line: string): PocketBaseSseFrame | null {
if (line === "") return this.dispatch();
if (line.startsWith(":")) return null;
const colonIndex = line.indexOf(":");
const field = colonIndex === -1 ? line : line.slice(0, colonIndex);
let value = colonIndex === -1 ? "" : line.slice(colonIndex + 1);
if (value.startsWith(" ")) value = value.slice(1);
if (field === "event") this.event = value;
else if (field === "id") this.id = value;
else if (field === "data") this.dataLines.push(value);
return null;
}
private dispatch(): PocketBaseSseFrame | null {
if (this.dataLines.length === 0 && this.event === "message" && this.id === undefined) return null;
const frame = { event: this.event, id: this.id, data: this.dataLines.join("\n") };
this.event = "message";
this.id = undefined;
this.dataLines = [];
return frame;
}
}
@@ -40,7 +40,124 @@ vi.mock("@homarr/core/infrastructure/certificates", () => ({
getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]),
}));
import { BeszelIntegration } from "../beszel-integration";
import { BeszelIntegration, normalizeRealtimeSnapshot } from "../beszel-integration";
import type { LiveStatsEvent } from "../beszel-types";
import { createRealtimeMetricsTopic } from "../pocketbase-realtime";
describe("normalizeRealtimeSnapshot", () => {
test("normalizes current Beszel host and container snapshots", () => {
const receivedAt = new Date("2026-07-11T13:51:30.217Z");
const events = normalizeRealtimeSnapshot(
{
stats: {
cpu: 18.07,
m: 31.09,
mu: 16.53,
mp: 53.18,
mb: 14.32,
s: 4,
su: 3.9,
d: 938.8,
du: 455.81,
dp: 51.15,
efs: { sda: { d: 1906.8, du: 1656.45, r: 25.63, w: 0 } },
},
container: [{ n: "homarr", c: 1.65, m: 650.56, b: [356066, 19702867] }],
},
"system-1",
receivedAt,
);
expect(events).toHaveLength(2);
expect(events[0]).toMatchObject({
type: "system_stats",
record: { system: "system-1", created: receivedAt.toISOString(), stats: { cpu: 18.07 } },
});
expect(events[1]).toMatchObject({
type: "container_stats",
record: { system: "system-1", stats: [{ n: "homarr", m: 650.56 }] },
});
});
test("ignores snapshots without metric data", () => {
expect(normalizeRealtimeSnapshot({}, "system-1")).toEqual([]);
});
});
describe("subscribeRealtimeMetrics", () => {
test("subscribes to Beszel rt_metrics and forwards one-second snapshots", async () => {
const originalFetch = globalThis.fetch;
const topic = createRealtimeMetricsTopic("system-1");
const subscriptionBodies: unknown[] = [];
const encoder = new TextEncoder();
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/api/collections/users/auth-with-password")) {
return Response.json({ token: "test-token", record: { id: "user-1" } });
}
if (url.includes("/api/collections/systems/records")) {
return Response.json({
page: 1,
perPage: 500,
totalItems: 1,
totalPages: 1,
items: [
{
id: "system-1",
name: "Server",
host: "localhost",
port: "45876",
status: "up",
info: { cpu: 1, mp: 2, dp: 3, u: 4, v: "0.18.7" },
created: "2026-07-11T00:00:00.000Z",
updated: "2026-07-11T00:00:00.000Z",
},
],
});
}
if (url.endsWith("/api/realtime") && init?.method === "POST") {
subscriptionBodies.push(JSON.parse(String(init.body)));
expect(new Headers(init.headers).get("Authorization")).toBe("test-token");
return new Response(null, { status: 204 });
}
if (url.endsWith("/api/realtime")) {
const snapshot = JSON.stringify({
stats: { cpu: 5, m: 10, mu: 4, mp: 40, mb: 1, s: 0, su: 0, d: 20, du: 8, dp: 40 },
container: [{ n: "homarr", c: 1, m: 256 }],
});
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode("event: PB_CONNECT\nid: client-1\ndata: {}\n\n"));
controller.enqueue(encoder.encode(`event: ${topic}\ndata: ${snapshot}\n\n`));
controller.close();
},
});
return new Response(stream, { headers: { "Content-Type": "text/event-stream" } });
}
throw new Error(`Unexpected request: ${init?.method ?? "GET"} ${url}`);
}) as typeof fetch;
try {
const integration = createBeszelIntegration();
const controller = new AbortController();
const events: LiveStatsEvent[] = [];
await integration.subscribeRealtimeMetrics(
"system-1",
(event) => {
events.push(event);
if (events.length === 2) controller.abort();
},
controller.signal,
);
expect(subscriptionBodies).toEqual([{ clientId: "client-1", subscriptions: [topic] }]);
expect(events.map((event) => event.type)).toEqual(["system_stats", "container_stats"]);
} finally {
globalThis.fetch = originalFetch;
}
});
});
function createBeszelIntegration() {
return new BeszelIntegration({
@@ -0,0 +1,31 @@
import { describe, expect, test } from "vitest";
import { createRealtimeMetricsTopic, PocketBaseSseParser } from "../pocketbase-realtime";
describe("PocketBaseSseParser", () => {
test("parses PB_CONNECT client IDs and CRLF-delimited frames split across chunks", () => {
const parser = new PocketBaseSseParser();
expect(parser.push("event: PB_CONNECT\r\nid: client-")).toEqual([]);
expect(parser.push("123\r\ndata: {}\r\n\r\n")).toEqual([{ event: "PB_CONNECT", id: "client-123", data: "{}" }]);
});
test("joins multiline data and ignores keepalive comments", () => {
const parser = new PocketBaseSseParser();
expect(parser.push(': keepalive\n\nevent: rt_metrics\ndata: {"stats":\ndata: {}}\n\n')).toEqual([
{ event: "rt_metrics", id: undefined, data: '{"stats":\n{}}' },
]);
});
test("flushes a trailing frame when the stream ends without a blank line", () => {
const parser = new PocketBaseSseParser();
expect(parser.push("event: rt_metrics\ndata: {}")).toEqual([]);
expect(parser.finish()).toEqual([{ event: "rt_metrics", id: undefined, data: "{}" }]);
});
});
describe("createRealtimeMetricsTopic", () => {
test("matches the PocketBase custom-topic options encoding used by Beszel", () => {
const topic = createRealtimeMetricsTopic("system-1");
expect(topic).toBe(`rt_metrics?options=${encodeURIComponent(JSON.stringify({ query: { system: "system-1" } }))}`);
});
});
+1
View File
@@ -70,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,
@@ -226,6 +226,7 @@ export class BeszelMockService {
u: randInt(86400, 2_000_000),
mp: clamp(sys.baseMem + rand(-5, 5), 0, 100),
dp: clamp(sys.baseDisk + rand(-2, 2), 0, 100),
efs: i === 0 ? { "/mnt/sda": 86.9, "/mnt/sdb": 70.5, "/mnt/sdc": 42.7 } : undefined,
bb: rand(200, 3000) * KB,
v: "0.8.2",
g: i === 3 ? rand(15, 45) : 0,
@@ -0,0 +1,58 @@
import { ParseError, ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { PatchMonStats } from "./patchmon-types";
import { mapPatchMonStats, parsePatchMonStatsResponseAsync } from "./patchmon-types";
const STATS_REQUEST_TIMEOUT_MS = 10_000;
export class PatchMonIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/v1/gethomepage/stats"), {
headers: this.getAuthHeaders(),
});
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
try {
await parsePatchMonStatsResponseAsync(response);
} catch (error) {
if (error instanceof ParseError) {
return TestConnectionError.ParseResult(error);
}
throw error;
}
return { success: true };
}
public async getStatsAsync(): Promise<PatchMonStats> {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/gethomepage/stats"), {
headers: this.getAuthHeaders(),
timeout: STATS_REQUEST_TIMEOUT_MS,
});
if (!response.ok) {
throw new ResponseError(response);
}
const data = await parsePatchMonStatsResponseAsync(response);
return mapPatchMonStats(data);
}
private getAuthHeaders(): Record<string, string> {
const credentials = Buffer.from(
`${this.getSecretValue("patchmonApiKey")}:${this.getSecretValue("patchmonApiSecret")}`,
).toString("base64");
return { Authorization: `Basic ${credentials}` };
}
}
@@ -0,0 +1,88 @@
import { ParseError } from "@homarr/common/server";
import { z } from "zod/v4";
const PATCHMON_STATS_PARSE_ERROR_MESSAGE = "Invalid PatchMon stats response";
export const patchmonOsDistributionEntrySchema = z.object({
name: z.string(),
count: z.number(),
os_type: z.string().optional(),
os_version: z.string().optional(),
});
export const patchmonStatsResponseSchema = z.object({
total_hosts: z.number(),
hosts_needing_updates: z.number(),
security_updates: z.number(),
up_to_date_hosts: z.number(),
hosts_with_security_updates: z.number(),
recent_updates_24h: z.number(),
total_outdated_packages: z.number(),
total_repos: z.number(),
last_updated: z.string(),
os_distribution: z.array(patchmonOsDistributionEntrySchema).default([]),
});
export interface PatchMonOsDistributionEntry {
name: string;
count: number;
osType?: string;
osVersion?: string;
}
export interface PatchMonStats {
totalHosts: number;
hostsNeedingUpdates: number;
securityUpdates: number;
upToDateHosts: number;
hostsWithSecurityUpdates: number;
recentUpdates24h: number;
totalOutdatedPackages: number;
totalRepos: number;
lastUpdated: string;
osDistribution: PatchMonOsDistributionEntry[];
}
const mapOsDistributionEntry = (
entry: z.infer<typeof patchmonOsDistributionEntrySchema>,
): PatchMonOsDistributionEntry => ({
name: entry.name,
count: entry.count,
osType: entry.os_type,
osVersion: entry.os_version,
});
export const parsePatchMonStatsResponseAsync = async (
response: { json: () => Promise<unknown> },
): Promise<z.infer<typeof patchmonStatsResponseSchema>> => {
let json: unknown;
try {
json = await response.json();
} catch (error) {
throw new ParseError(PATCHMON_STATS_PARSE_ERROR_MESSAGE, {
cause: error instanceof Error ? error : new Error(String(error)),
});
}
const parseResult = await patchmonStatsResponseSchema.safeParseAsync(json);
if (!parseResult.success) {
throw new ParseError(PATCHMON_STATS_PARSE_ERROR_MESSAGE, { cause: parseResult.error });
}
return parseResult.data;
};
export const mapPatchMonStats = (data: z.infer<typeof patchmonStatsResponseSchema>): PatchMonStats => ({
totalHosts: data.total_hosts,
hostsNeedingUpdates: data.hosts_needing_updates,
securityUpdates: data.security_updates,
upToDateHosts: data.up_to_date_hosts,
hostsWithSecurityUpdates: data.hosts_with_security_updates,
recentUpdates24h: data.recent_updates_24h,
totalOutdatedPackages: data.total_outdated_packages,
totalRepos: data.total_repos,
lastUpdated: data.last_updated,
osDistribution: [...data.os_distribution]
.map(mapOsDistributionEntry)
.sort((a, b) => b.count - a.count),
});
@@ -0,0 +1,167 @@
// @vitest-environment node
import { ParseError } from "@homarr/common/server";
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.hoisted(() => {
process.env.SKIP_ENV_VALIDATION = "true";
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
});
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { IntegrationParseError } from "../../base/errors/parse/integration-parse-error";
import type { IntegrationSecret } from "../../base/types";
import { PatchMonIntegration } from "../patchmon-integration";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const TEST_URL = "https://patchmon.example.com";
const TEST_API_KEY = "test-api-key";
const TEST_API_SECRET = "test-api-secret";
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const sampleStatsResponse = {
total_hosts: 42,
hosts_needing_updates: 15,
security_updates: 23,
up_to_date_hosts: 27,
hosts_with_security_updates: 8,
recent_updates_24h: 34,
total_outdated_packages: 156,
total_repos: 12,
last_updated: "2025-10-11T12:34:56.789Z",
os_distribution: [
{ name: "Debian", count: 12, os_type: "linux", os_version: "12" },
{ name: "Ubuntu", count: 20, os_type: "linux", os_version: "22.04" },
{ name: "Rocky Linux", count: 10, os_type: "linux", os_version: "9" },
],
};
const createIntegration = (decryptedSecrets: IntegrationSecret[] = []) =>
new PatchMonIntegration({
id: "test-patchmon",
name: "Test PatchMon",
url: TEST_URL,
externalUrl: null,
decryptedSecrets:
decryptedSecrets.length > 0
? decryptedSecrets
: [
{ kind: "patchmonApiKey", value: TEST_API_KEY },
{ kind: "patchmonApiSecret", value: TEST_API_SECRET },
],
});
beforeEach(() => {
mockFetch.mockReset();
});
describe("PatchMonIntegration getStatsAsync", () => {
test("maps API response fields to camelCase stats", async () => {
mockFetch.mockResolvedValue(
new Response(JSON.stringify(sampleStatsResponse), {
status: 200,
headers: { "content-type": "application/json" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
);
const stats = await createIntegration().getStatsAsync();
expect(stats).toStrictEqual({
totalHosts: 42,
hostsNeedingUpdates: 15,
securityUpdates: 23,
upToDateHosts: 27,
hostsWithSecurityUpdates: 8,
recentUpdates24h: 34,
totalOutdatedPackages: 156,
totalRepos: 12,
lastUpdated: "2025-10-11T12:34:56.789Z",
osDistribution: [
{ name: "Ubuntu", count: 20, osType: "linux", osVersion: "22.04" },
{ name: "Debian", count: 12, osType: "linux", osVersion: "12" },
{ name: "Rocky Linux", count: 10, osType: "linux", osVersion: "9" },
],
});
});
test("defaults os distribution to empty array when absent", async () => {
const { os_distribution: _osDistribution, ...responseWithoutOs } = sampleStatsResponse;
mockFetch.mockResolvedValue(
new Response(JSON.stringify(responseWithoutOs), {
status: 200,
headers: { "content-type": "application/json" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
);
const stats = await createIntegration().getStatsAsync();
expect(stats.osDistribution).toStrictEqual([]);
});
test("throws ParseError when API response is not valid JSON", async () => {
mockFetch.mockResolvedValue(
new Response("not-json", {
status: 200,
headers: { "content-type": "application/json" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
);
await expect(createIntegration().getStatsAsync()).rejects.toSatisfy((error) => {
if (!(error instanceof IntegrationParseError)) return false;
const cause = error.cause;
return cause instanceof ParseError && cause.message.includes("Invalid PatchMon stats response");
});
});
test("throws ParseError when API response does not match schema", async () => {
mockFetch.mockResolvedValue(
new Response(JSON.stringify({ invalid: true }), {
status: 200,
headers: { "content-type": "application/json" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
);
await expect(createIntegration().getStatsAsync()).rejects.toSatisfy((error) => {
if (!(error instanceof IntegrationParseError)) return false;
const cause = error.cause;
return cause instanceof ParseError && cause.message.includes("Invalid PatchMon stats response");
});
});
test("throws when API returns an error", async () => {
mockFetch.mockResolvedValue(
new Response("Unauthorized", {
status: 401,
headers: { "content-type": "text/plain" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
);
await expect(createIntegration().getStatsAsync()).rejects.toThrow();
});
});
describe("PatchMonIntegration authentication", () => {
test("sends a Basic auth header with api key and secret", async () => {
mockFetch.mockResolvedValue(
new Response(JSON.stringify(sampleStatsResponse), {
status: 200,
headers: { "content-type": "application/json" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
);
await createIntegration().getStatsAsync();
const expected = `Basic ${Buffer.from(`${TEST_API_KEY}:${TEST_API_SECRET}`).toString("base64")}`;
const [url, requestInit] = mockFetch.mock.calls[0] ?? [];
expect(String(url)).toContain("/api/v1/gethomepage/stats");
expect(requestInit?.headers).toMatchObject({ Authorization: expected });
expect(requestInit?.timeout).toBe(10_000);
});
});
@@ -6,6 +6,7 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/h
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { SessionStore } from "../base/session-store";
import type { SynologyDiskRecord, SynologyVolumeRecord } from "./synology-types";
import {
AUTH_API_NAME,
INFO_API_NAME,
@@ -23,8 +24,6 @@ import {
synologyUpgradeCheckDataSchema,
synologyUpgradeStatusDataSchema,
synologyUtilizationDataSchema,
type SynologyDiskRecord,
type SynologyVolumeRecord,
} from "./synology-types";
const logger = createLogger({ module: "synologyClient" });
@@ -158,13 +157,7 @@ export class SynologyClient {
if (!name) {
return null;
}
return this.mapVolumeRecord(
name,
volume.size?.used,
volume.size?.total,
volume.status,
volume.display_name,
);
return this.mapVolumeRecord(name, volume.size?.used, volume.size?.total, volume.status, volume.display_name);
})
.filter((volume): volume is SynologyVolumeRecord => volume !== null);
}
@@ -2,6 +2,7 @@
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { z } from "zod";
vi.hoisted(() => {
process.env.CI = "true";
@@ -37,6 +38,7 @@ import { IntegrationResponseError } from "../../base/errors/http/integration-res
import type { IntegrationSecret } from "../../base/types";
import { SynologyIntegration } from "../synology-integration";
import type { synologyStorageV2DataSchema, synologyUtilizationDataSchema } from "../synology-types";
const TEST_URL = "https://synology.example.com:5001";
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
@@ -202,8 +204,8 @@ const mockSuccessfulFlow = (options?: {
success: boolean;
data: Record<string, unknown>;
};
utilization?: typeof utilizationResponse;
storageV2?: typeof storageV2Response;
utilization?: { success: boolean; data: z.input<typeof synologyUtilizationDataSchema> };
storageV2?: { success: boolean; data: z.input<typeof synologyStorageV2DataSchema> };
storageLoadInfo?: typeof storageLoadInfoResponse;
}) => {
mockFetch.mockImplementation((url) => {
@@ -241,9 +243,9 @@ const mockSuccessfulFlow = (options?: {
new Response(JSON.stringify({ success: false, error: { code: 103 } }), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
return Promise.resolve(new Response(JSON.stringify(options?.storageV2 ?? storageV2Response), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
return Promise.resolve(
new Response(JSON.stringify(options?.storageV2 ?? storageV2Response), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (apiName === "SYNO.Core.System" && method === "info" && type === "storage") {
+1
View File
@@ -34,6 +34,7 @@ function mapToSystemRow(system: BeszelSystem, details: BeszelSystemDetails | nul
cpu: info.cpu,
memory: info.mp,
disk: info.dp,
extraFilesystems: info.efs ?? {},
gpu: info.g ?? 0,
loadAvg: info.la ?? null,
// bb = bytes/s (newer), b = Mbps (legacy, multiply to get bytes/s)
@@ -6,7 +6,7 @@ import { createIntegrationRequestHandler } from "./lib/integration-request-handl
export const systemInfoRequestHandler = createIntegrationRequestHandler<
SystemHealthMonitoring,
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify" | "beszel">,
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify" | "beszel" | "patchmon">,
Record<string, never>
>({
async requestAsync(integration, _input) {
@@ -1,10 +1,8 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { IMediaOrganizerIntegration, MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
interface MediaOrganizerData {
missing: MissingMediaItem[];
@@ -13,7 +11,7 @@ interface MediaOrganizerData {
queuedCount: number;
}
export const mediaOrganizerRequestHandler = createCachedIntegrationRequestHandler<
export const mediaOrganizerRequestHandler = createIntegrationRequestHandler<
MediaOrganizerData,
IntegrationKindByCategory<"mediaOrganizer">,
{ pageSize: number }
@@ -31,6 +29,5 @@ export const mediaOrganizerRequestHandler = createCachedIntegrationRequestHandle
queuedCount: queueResult.totalCount,
};
},
cacheDuration: dayjs.duration(1, "minute"),
queryKey: "mediaOrganizer",
cacheTtlMs: 60_000,
});
+15
View File
@@ -0,0 +1,15 @@
import { createIntegrationAsync } from "@homarr/integrations";
import type { PatchMonStats } from "@homarr/integrations";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const patchmonStatsRequestHandler = createIntegrationRequestHandler<
PatchMonStats,
"patchmon",
Record<string, never>
>({
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getStatsAsync();
},
});
@@ -134,7 +134,7 @@ const getGithubApi = (baseUrl: string, userAgent: string, token?: string) =>
baseUrl,
auth: token,
request: { fetch: fetchWithTrustedCertificatesAsync },
throttle: { enabled: false },
throttle: { enabled: true },
userAgent,
});
+172 -5
View File
@@ -1285,6 +1285,14 @@
"label": "API Key (Secret)",
"newLabel": "New API Key (Secret)"
},
"patchmonApiKey": {
"label": "API Key",
"newLabel": "New API Key"
},
"patchmonApiSecret": {
"label": "API Secret",
"newLabel": "New API Secret"
},
"githubAppId": {
"label": "App Id",
"newLabel": "New App Id"
@@ -1336,6 +1344,10 @@
"name": "Paperless-ngx",
"description": "Document management system for archiving and organizing scanned documents"
},
"patchmon": {
"name": "PatchMon",
"description": "Patch management and monitoring for Linux hosts"
},
"bazarr": {
"name": "Bazarr",
"description": "Companion application to Sonarr and Radarr that manages and downloads subtitles"
@@ -2762,6 +2774,164 @@
"internalServerError": "Failed to fetch Paperless-ngx data"
}
},
"patchmon": {
"name": "PatchMon",
"description": "Overview of host patch status from PatchMon",
"title": "PatchMon Statistics",
"totalHosts": "Total Hosts",
"hostsNeedingUpdates": "Needs Updates",
"securityUpdates": "Security Updates",
"upToDateHosts": "Up-to-Date Hosts",
"hostsWithSecurityUpdates": "Hosts with Security Updates",
"recentUpdates24h": "Updates (24h)",
"totalOutdatedPackages": "Outdated Packages",
"totalRepos": "Repositories",
"osDistribution": "OS Distribution",
"compliance": "Patch Compliance",
"complianceRatio": "{upToDate} / {total}",
"lastUpdated": "Updated {time}",
"option": {
"showTotalHosts": {
"label": "Show total hosts",
"description": "Display the total number of monitored hosts"
},
"showHostsNeedingUpdates": {
"label": "Show hosts needing updates",
"description": "Display hosts with at least one outdated package"
},
"showSecurityUpdates": {
"label": "Show security updates",
"description": "Display the total number of security updates available"
},
"showUpToDateHosts": {
"label": "Show up-to-date hosts",
"description": "Display hosts with zero outdated packages"
},
"showHostsWithSecurityUpdates": {
"label": "Show hosts with security updates",
"description": "Display hosts requiring security patches"
},
"showRecentUpdates24h": {
"label": "Show recent updates (24h)",
"description": "Display the number of updates applied in the last 24 hours"
},
"showTotalOutdatedPackages": {
"label": "Show outdated packages",
"description": "Display the total number of outdated packages across all hosts"
},
"showTotalRepos": {
"label": "Show repositories",
"description": "Display the number of active repositories being monitored"
},
"enableThresholdColors": {
"label": "Enable threshold colors",
"description": "Color stat values green, yellow, or red based on severity presets"
},
"useCustomThresholds": {
"label": "Advanced thresholds",
"description": "Customize warning and critical thresholds per stat using absolute counts or percentages of total hosts"
},
"thresholdMode": {
"option": {
"absolute": "Absolute count",
"percent": "Percent of total hosts"
}
},
"hostsNeedingUpdatesThresholdMode": {
"label": "Needs updates threshold mode",
"description": "Compare the needs-updates count as an absolute value or percentage of total hosts"
},
"hostsNeedingUpdatesWarningAt": {
"label": "Needs updates warning threshold",
"description": "Enter yellow when the value reaches this threshold"
},
"hostsNeedingUpdatesCriticalAt": {
"label": "Needs updates critical threshold",
"description": "Enter red when the value reaches this threshold"
},
"securityUpdatesThresholdMode": {
"label": "Security updates threshold mode",
"description": "Compare the security-updates count as an absolute value or percentage of total hosts"
},
"securityUpdatesWarningAt": {
"label": "Security updates warning threshold",
"description": "Enter yellow when the value reaches this threshold"
},
"securityUpdatesCriticalAt": {
"label": "Security updates critical threshold",
"description": "Enter red when the value reaches this threshold"
},
"hostsWithSecurityUpdatesThresholdMode": {
"label": "Hosts with security updates threshold mode",
"description": "Compare the host count as an absolute value or percentage of total hosts"
},
"hostsWithSecurityUpdatesWarningAt": {
"label": "Hosts with security updates warning threshold",
"description": "Enter yellow when the value reaches this threshold"
},
"hostsWithSecurityUpdatesCriticalAt": {
"label": "Hosts with security updates critical threshold",
"description": "Enter red when the value reaches this threshold"
},
"upToDateHostsThresholdMode": {
"label": "Up-to-date hosts threshold mode",
"description": "Compare the up-to-date host count as an absolute value or percentage of total hosts"
},
"upToDateHostsWarningAt": {
"label": "Up-to-date hosts warning threshold",
"description": "Enter yellow when the value is at or above this threshold but below full coverage"
},
"upToDateHostsCriticalAt": {
"label": "Up-to-date hosts critical threshold",
"description": "Enter red when the value falls below this threshold"
},
"totalOutdatedPackagesThresholdMode": {
"label": "Outdated packages threshold mode",
"description": "Compare the outdated-packages count as an absolute value or percentage of total hosts"
},
"totalOutdatedPackagesWarningAt": {
"label": "Outdated packages warning threshold",
"description": "Enter yellow when the value reaches this threshold"
},
"totalOutdatedPackagesCriticalAt": {
"label": "Outdated packages critical threshold",
"description": "Enter red when the value reaches this threshold"
},
"showOsDistribution": {
"label": "Show OS distribution",
"description": "Display a breakdown of monitored hosts by operating system"
},
"osDistributionLimit": {
"label": "OS distribution limit",
"description": "Maximum number of operating systems to display",
"option": {
"3": "Top 3",
"5": "Top 5",
"10": "Top 10",
"all": "All"
}
},
"showOsVersion": {
"label": "Show OS version",
"description": "Append the OS version to each distribution label when available"
},
"showComplianceHero": {
"label": "Show compliance hero",
"description": "Display a compliance ring showing the percentage of up-to-date hosts"
},
"osDisplayMode": {
"label": "OS display mode",
"description": "Choose how to display the operating system distribution",
"option": {
"bars": "Horizontal bars",
"donut": "Donut chart"
}
}
},
"error": {
"internalServerError": "Failed to fetch PatchMon data"
}
},
"bazarr": {
"name": "Bazarr",
"description": "Overview of missing subtitles and health status from your Bazarr instance",
@@ -3861,9 +4031,6 @@
},
"showDockerNetwork": {
"label": "Show Docker network chart"
},
"showStorage": {
"label": "Show storage chart"
}
},
"selectSystem": "Select system",
@@ -3890,8 +4057,8 @@
},
"disk": {
"title": "Disk Usage",
"subtitle": "Usage of root partition",
"series": "Used"
"subtitle": "Usage by filesystem",
"series": "Root"
},
"diskIO": {
"title": "Disk I/O",
@@ -27,10 +27,18 @@ import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition";
import type { BeszelSystemRow } from "../beszel/_shared/types";
import { statusColorMap, thresholdColor } from "../beszel/_shared/colors";
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
import {
formatByteRate,
formatLoadAvg,
formatPercent,
formatTemp,
formatUptime,
getProgressTrackSize,
} from "../beszel/_shared/format";
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
import { DiskUsage } from "../beszel/_shared/disk-usage";
interface SizeConfig {
iconSize: number;
@@ -106,22 +114,14 @@ const getMaxVisibleMetrics = (cellHeight: number, size: SizeConfig): number => {
return Math.max(1, Math.floor(available / size.rowHeight));
};
const MIN_CELL_WIDTH = 120;
const MIN_CELL_WIDTH = 180;
const MIN_CELL_HEIGHT = 80;
const getColCount = (width: number, itemCount: number): number => {
const getColCount = (width: number, height: number, itemCount: number): number => {
if (itemCount <= 1) return 1;
const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / MIN_CELL_WIDTH)));
let best = 1;
for (let c = 1; c <= maxCols; c++) {
const emptyCells = (c - (itemCount % c)) % c;
const bestEmpty = (best - (itemCount % best)) % best;
if (emptyCells < bestEmpty || (emptyCells === bestEmpty && c > best)) {
best = c;
}
}
return best;
const aspectRatio = width / Math.max(height, MIN_CELL_HEIGHT);
return Math.min(maxCols, Math.max(1, Math.ceil(Math.sqrt(itemCount * aspectRatio))));
};
interface MetricRowProps {
@@ -133,20 +133,25 @@ interface MetricRowProps {
}
const MetricValue = ({ value, progress, size }: Pick<MetricRowProps, "value" | "progress" | "size">) => (
<Group gap={6} wrap="nowrap" style={{ flex: 1 }}>
<Text size={size.fontSize} fw={500} miw={size.valueMiw} ta="right">
<Group gap={6} wrap="nowrap" style={{ flex: 1, minWidth: 0, marginLeft: "auto" }}>
<Text size={size.fontSize} fw={500} w={size.valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{value}
</Text>
{progress && (
<Progress value={progress.value} color={progress.color} size={size.progressSize} style={{ flex: 1 }} />
<Progress
value={progress.value}
color={progress.color}
size={getProgressTrackSize(size.progressSize)}
style={{ flex: 1 }}
/>
)}
</Group>
);
const MetricRow = ({ icon, label, value, progress, size }: MetricRowProps) => (
<Group gap="xs" wrap="nowrap" style={{ minHeight: size.rowHeight }}>
<Group gap="xs" wrap="nowrap" justify="space-between" style={{ minHeight: size.rowHeight }}>
{icon}
<Text size={size.fontSize} c="dimmed" miw={size.labelMiw}>
<Text size={size.fontSize} c="dimmed" w={size.labelMiw} style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{label}
</Text>
<MetricValue value={value} progress={progress} size={size} />
@@ -195,14 +200,13 @@ const metricRenderers = [
{
key: "showDisk",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow
key="disk"
icon={<HardDrive size={sz.iconSize} />}
label={t("metric.disk")}
value={formatPercent(s.disk)}
progress={{ value: s.disk, color: thresholdColor(s.disk) }}
size={sz}
/>
<Group key="disk" gap="xs" wrap="nowrap" justify="space-between" style={{ minHeight: sz.rowHeight }}>
<HardDrive size={sz.iconSize} />
<Text size={sz.fontSize} c="dimmed" w={sz.labelMiw} style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{t("metric.disk")}
</Text>
<DiskUsage system={s} fontSize={sz.fontSize} progressSize={sz.progressSize} valueMiw={sz.valueMiw} />
</Group>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showDisk,
},
@@ -399,7 +403,7 @@ export default function BeszelSystemGridWidget({
);
}
const cols = getColCount(width, filteredSystems.length);
const cols = getColCount(width, height, filteredSystems.length);
const rows = Math.ceil(filteredSystems.length / cols) || 1;
const rawCellHeight = height / rows;
const scrollEnabled = rawCellHeight < MIN_CELL_HEIGHT;
@@ -6,6 +6,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", {
icon: IconLayoutGrid,
queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
@@ -1,54 +1,21 @@
"use client";
import { useCallback, useMemo } from "react";
import { Button, Box, Center, Loader, Menu, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconPlugConnectedX, IconServer, IconQuestionMark, IconServerOff } from "@tabler/icons-react";
import { Box, Button, Center, Loader, Menu, ScrollArea, Select, Stack, Text } from "@mantine/core";
import { IconQuestionMark, IconServer, IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { useOptionalBoard } from "@homarr/boards/context";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition";
import { containerColors } from "../beszel/_shared/colors";
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
import {
BeszelChartPanel,
CPU_Y_AXIS_DOMAIN,
useContainerNames,
useDockerChartData,
useEfsChartData,
useSystemChartData,
} from "../beszel/_shared/chart";
import {
chartAxisFormatters,
formatByteRate,
formatGB,
formatPercent,
formatStorageBytes,
} from "../beszel/_shared/format";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { makeTooltipProps } from "../beszel/_shared/tooltip";
import { useLiveStats } from "../beszel/_shared/use-live-stats";
const tooltipPercent = makeTooltipProps(formatPercent);
const tooltipGB = makeTooltipProps(formatGB, true);
const tooltipRate = makeTooltipProps(formatByteRate, true);
const tooltipPercentTotal = makeTooltipProps(formatPercent, true);
const tooltipBytesTotal = makeTooltipProps(formatStorageBytes, true);
const CHART_HEIGHT = 180;
const MB = 1024 * 1024;
const gridColumns = [1, 2] as const;
function statsWhenShown<T>(show: boolean | undefined, data: T | undefined): T | undefined {
if (!show || !data) return undefined;
return data;
}
import { BeszelStatsView } from "../beszel/_shared/stats-view";
export default function BeszelSystemStatsWidget({
options,
@@ -71,14 +38,12 @@ export default function BeszelSystemStatsWidget({
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const systems = useMemo(
() => systemsResult.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name }))),
() => systemsResult.flatMap((result) => result.systems.map((system) => ({ value: system.id, label: system.name }))),
[systemsResult],
);
const selectedSystem = options.systemId || systems[0]?.value || "";
const selectedLabel = systems.find((s) => s.value === selectedSystem)?.label;
const systemExists = systems.some((s) => s.value === selectedSystem);
const systemReady = !systemsPending && (selectedSystem === "" || systemExists);
const selectedLabel = systems.find((system) => system.value === selectedSystem)?.label;
const systemExists = systems.some((system) => system.value === selectedSystem);
const handleSelectSystem = useCallback(
(value: string) => {
@@ -90,139 +55,13 @@ export default function BeszelSystemStatsWidget({
[setOptions, hasChangeAccess, boardId, itemId, saveItemOptions],
);
const showDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork;
const isLive = options.timePeriod === "1m";
const { data: statsResult, error: statsError } = clientApi.widget.beszel.getSystemStats.useQuery(
{
integrationIds,
systemId: selectedSystem,
timePeriod: options.timePeriod as "1m" | "1h" | "12h" | "24h" | "1w" | "30d",
includeDocker: showDocker,
},
{
refetchInterval: isLive ? false : 5_000,
enabled: !isLive && systemReady && selectedSystem !== "",
},
);
const { data: liveData, error: liveError } = useLiveStats(integrationIds, selectedSystem, isLive && systemReady);
let activeStats:
| { systemStats: BeszelSystemStatsRecord[]; containerStats: BeszelContainerStatsRecord[]; error?: string }
| null
| undefined = statsResult;
if (isLive) {
activeStats = liveData;
}
const mappers = useMemo(
() => ({
cpu: (s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }),
memory: (s: { mu: number; mb: number }) => ({
[t("chart.memory.series")]: s.mu,
[t("chart.memory.cache")]: s.mb ?? 0,
}),
disk: (s: { du: number }) => ({ [t("chart.disk.series")]: s.du }),
diskIO: (s: { dr?: number; dw?: number }) => ({
[t("chart.diskIO.read")]: (s.dr ?? 0) * MB,
[t("chart.diskIO.write")]: (s.dw ?? 0) * MB,
}),
network: (s: { ns: number; nr: number; b?: [number, number] }) => ({
[t("chart.network.sent")]: s.b?.[0] ?? s.ns,
[t("chart.network.recv")]: s.b?.[1] ?? s.nr,
}),
}),
[t],
);
const series = useMemo(
() => ({
cpu: [{ name: t("chart.cpu.series"), color: "teal.6" }],
memory: [
{ name: t("chart.memory.series"), color: "teal.5" },
{ name: t("chart.memory.cache"), color: "teal.8" },
],
disk: [{ name: t("chart.disk.series"), color: "grape.6" }],
diskIO: [
{ name: t("chart.diskIO.write"), color: "orange.6" },
{ name: t("chart.diskIO.read"), color: "blue.6" },
],
network: [
{ name: t("chart.network.sent"), color: "blue.6" },
{ name: t("chart.network.recv"), color: "teal.6" },
],
}),
[t],
);
const systemStats = activeStats?.systemStats;
const containerStatsRaw = activeStats?.containerStats;
const timePeriod = options.timePeriod as BeszelTimePeriod;
const cpuData = useSystemChartData(statsWhenShown(options.showCpu, systemStats), mappers.cpu, timePeriod);
const memoryData = useSystemChartData(statsWhenShown(options.showMemory, systemStats), mappers.memory, timePeriod);
const diskData = useSystemChartData(statsWhenShown(options.showDisk, systemStats), mappers.disk, timePeriod);
const diskIOData = useSystemChartData(statsWhenShown(options.showDiskIO, systemStats), mappers.diskIO, timePeriod);
const networkData = useSystemChartData(statsWhenShown(options.showNetwork, systemStats), mappers.network, timePeriod);
const containerNames = useContainerNames(statsWhenShown(showDocker, containerStatsRaw));
const dockerCpuData = useDockerChartData(
statsWhenShown(options.showDockerCpu, containerStatsRaw),
containerNames,
"cpu",
timePeriod,
);
const dockerMemData = useDockerChartData(
statsWhenShown(options.showDockerMemory, containerStatsRaw),
containerNames,
"memory",
timePeriod,
);
const dockerNetData = useDockerChartData(
statsWhenShown(options.showDockerNetwork, containerStatsRaw),
containerNames,
"network",
timePeriod,
);
const efsPaths = useMemo(() => {
if (!systemStats) return [];
const paths = new Set<string>();
for (const record of systemStats) {
if (record.stats.efs) {
for (const path of Object.keys(record.stats.efs)) {
paths.add(path);
}
}
}
return [...paths];
}, [systemStats]);
const storageData = useEfsChartData(statsWhenShown(options.showStorage, systemStats), efsPaths, timePeriod);
const containerSeries = useMemo(
() => containerNames.map((name, i) => ({ name, color: containerColors[i % containerColors.length] as string })),
[containerNames],
);
const storageSeries = useMemo(
() => efsPaths.map((path, i) => ({ name: path, color: containerColors[i % containerColors.length] as string })),
[efsPaths],
);
const cols = gridColumns[Number(width > 600)];
if (systemsError) throw systemsError;
if (!isLive && statsError) throw statsError;
if (systemsPending) {
if (systemsPending)
return (
<Center h="100%">
<Loader size="sm" />
</Center>
);
}
if (systems.length === 0) {
return (
@@ -240,7 +79,7 @@ export default function BeszelSystemStatsWidget({
);
}
if (!systemsPending && !systemExists && systems.length > 0) {
if (!systemExists) {
return (
<Center h="100%">
<Stack align="center" gap="sm" p="md">
@@ -260,22 +99,6 @@ export default function BeszelSystemStatsWidget({
);
}
if (isLive && liveError && !liveData) {
return (
<Center h="100%">
<Stack align="center" gap="sm" p="md">
<IconPlugConnectedX size={40} />
<Text size="sm" c="dimmed" ta="center">
{t("error.liveConnectionFailed")}
</Text>
<Button size="xs" variant="light" onClick={() => setOptions({ newOptions: { timePeriod: "1h" } })}>
{t("error.switchToHistorical")}
</Button>
</Stack>
</Center>
);
}
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
@@ -284,7 +107,7 @@ export default function BeszelSystemStatsWidget({
<ScrollArea
h="100%"
className={classes.beszelStatsContainer}
style={{ pointerEvents: (isEditMode && "none") || undefined }}
style={{ pointerEvents: isEditMode ? "none" : undefined }}
>
<Stack gap="md" p="sm">
{!isEditMode && systems.length > 1 && (
@@ -300,176 +123,37 @@ export default function BeszelSystemStatsWidget({
</Button>
</Menu.Target>
<Menu.Dropdown>
{systems.map((s) => (
{systems.map((system) => (
<Menu.Item
key={s.value}
key={system.value}
fz="xs"
fw={s.value === selectedSystem ? 600 : 400}
c={s.value !== selectedSystem ? "dimmed" : undefined}
onClick={() => handleSelectSystem(s.value)}
fw={system.value === selectedSystem ? 600 : 400}
c={system.value !== selectedSystem ? "dimmed" : undefined}
onClick={() => handleSelectSystem(system.value)}
>
{s.label}
{system.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
{!isLive && !activeStats && systemReady && selectedSystem !== "" && (
<Center h={CHART_HEIGHT}>
<Loader size="sm" />
</Center>
)}
{!isLive && activeStats && "error" in activeStats && activeStats.error && (
<Center h={CHART_HEIGHT}>
<Stack align="center" gap="xs">
<IconServerOff size={24} opacity={0.5} />
<Text size="sm" c="dimmed">
{activeStats.error}
</Text>
</Stack>
</Center>
)}
{activeStats && (!("error" in activeStats) || !activeStats.error) && (
<SimpleGrid cols={cols} spacing="md">
{options.showCpu && cpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.cpu.title")}
subtitle={t("chart.cpu.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: cpuData,
series: series.cpu,
yAxisFormatter: chartAxisFormatters.percent,
yAxisDomain: CPU_Y_AXIS_DOMAIN,
tooltipProps: tooltipPercent,
}}
/>
)}
{options.showMemory && memoryData.length > 0 && (
<BeszelChartPanel
title={t("chart.memory.title")}
subtitle={t("chart.memory.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: memoryData,
type: "stacked",
series: series.memory,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{options.showDisk && diskData.length > 0 && (
<BeszelChartPanel
title={t("chart.disk.title")}
subtitle={t("chart.disk.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: diskData,
series: series.disk,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{options.showDiskIO && diskIOData.length > 0 && (
<BeszelChartPanel
title={t("chart.diskIO.title")}
subtitle={t("chart.diskIO.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: diskIOData,
series: series.diskIO,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
{options.showNetwork && networkData.length > 0 && (
<BeszelChartPanel
title={t("chart.network.title")}
subtitle={t("chart.network.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: networkData,
series: series.network,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
{options.showStorage && storageData.length > 0 && storageSeries.length > 0 && (
<BeszelChartPanel
title={t("chart.storage.title")}
subtitle={t("chart.storage.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: storageData,
type: "stacked",
series: storageSeries,
yAxisFormatter: chartAxisFormatters.bytes,
tooltipProps: tooltipBytesTotal,
}}
/>
)}
{showDocker && containerSeries.length > 0 && (
<>
{options.showDockerCpu && dockerCpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerCpu.title")}
subtitle={t("chart.dockerCpu.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerCpuData,
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.percent,
tooltipProps: tooltipPercentTotal,
}}
/>
)}
{options.showDockerMemory && dockerMemData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerMemory.title")}
subtitle={t("chart.dockerMemory.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerMemData,
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.bytes,
tooltipProps: tooltipBytesTotal,
}}
/>
)}
{options.showDockerNetwork && dockerNetData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerNetwork.title")}
subtitle={t("chart.dockerNetwork.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerNetData,
series: containerSeries,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
</>
)}
</SimpleGrid>
)}
<BeszelStatsView
integrationIds={integrationIds}
systemId={selectedSystem}
timePeriod={options.timePeriod as BeszelTimePeriod}
columns={width > 600 ? 2 : 1}
visibility={{
cpu: options.showCpu,
memory: options.showMemory,
disk: options.showDisk,
diskIO: options.showDiskIO,
network: options.showNetwork,
dockerCpu: options.showDockerCpu,
dockerMemory: options.showDockerMemory,
dockerNetwork: options.showDockerNetwork,
}}
onSwitchToHistorical={() => setOptions({ newOptions: { timePeriod: "1h" } })}
/>
</Stack>
</ScrollArea>
</Box>
@@ -16,6 +16,7 @@ const timePeriodOptions = [
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemStats", {
icon: IconChartAreaLine,
queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
@@ -45,7 +46,6 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
showDockerCpu: factory.switch({ defaultValue: true }),
showDockerMemory: factory.switch({ defaultValue: true }),
showDockerNetwork: factory.switch({ defaultValue: true }),
showStorage: factory.switch({ defaultValue: true }),
}));
},
errors: {
@@ -26,10 +26,18 @@ import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import type { BeszelSystemRow } from "../beszel/_shared/types";
import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors";
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
import {
formatByteRate,
formatLoadAvg,
formatPercent,
formatTemp,
formatUptime,
getProgressTrackSize,
} from "../beszel/_shared/format";
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
import { DiskUsage } from "../beszel/_shared/disk-usage";
const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
@@ -97,11 +105,16 @@ export default function BeszelSystemTableWidget({
}, [filteredSystems, sortStatus]);
const PercentCell = ({ value }: { value: number }) => (
<Group gap={4} wrap="nowrap" style={{ flex: 1 }}>
<Text size={size.fontSize} fw={500} miw={size.valueMiw} ta="right" style={{ whiteSpace: "nowrap" }}>
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
<Text size={size.fontSize} fw={500} w={size.valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{formatPercent(value)}
</Text>
<Progress value={value} color={thresholdColor(value)} size={size.progressSize} style={{ flex: 1 }} />
<Progress
value={value}
color={thresholdColor(value)}
size={getProgressTrackSize(size.progressSize)}
style={{ flex: 1 }}
/>
</Group>
);
@@ -156,7 +169,15 @@ export default function BeszelSystemTableWidget({
</Group>
),
sortable: true,
render: (record) => <PercentCell value={record.disk} />,
render: (record) => (
<DiskUsage
system={record}
fontSize={size.fontSize}
progressSize={size.progressSize}
valueMiw={size.valueMiw}
valueGap={8}
/>
),
},
options.showGpu && {
accessor: "gpu",
@@ -25,6 +25,7 @@ const sortDirectionOptions = [
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemTable", {
icon: IconTable,
queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
@@ -0,0 +1,87 @@
import { describe, expect, test } from "vitest";
import type { BeszelSystemStatsRecord } from "@homarr/integrations/types";
import { buildDiskChartData, padLiveTimeGrid } from "./chart";
const record = (
created: string,
du: number,
efs: BeszelSystemStatsRecord["stats"]["efs"],
): BeszelSystemStatsRecord => ({
id: created,
system: "system-1",
type: "1m",
created,
updated: created,
stats: {
cpu: 0,
m: 0,
mu: 0,
mp: 0,
mb: 0,
s: 0,
su: 0,
d: 938.8,
du,
dp: 51.15,
efs,
},
});
describe("buildDiskChartData", () => {
test("keeps root and extra filesystem usage in GiB without stacking or byte conversion", () => {
const data = buildDiskChartData(
[
record("2026-07-11T13:46:00.000Z", 455.81, {
sda: { d: 1906.8, du: 1656.45, r: 0, w: 0 },
sdb: { d: 5587.04, du: 3936.86, r: 0, w: 0 },
sdc: { d: 3724.18, du: 1589.84, r: 0, w: 0 },
}),
],
["sda", "sdb", "sdc"],
"Root",
"1h",
);
expect(data).toHaveLength(1);
expect(data[0]).toMatchObject({ Root: 455.81, sda: 1656.45, sdb: 3936.86, sdc: 1589.84 });
});
test("uses zero for a filesystem missing from an individual sample and orders historical records oldest first", () => {
const data = buildDiskChartData(
[
record("2026-07-11T13:47:00.000Z", 2, { sda: { d: 10, du: 4, r: 0, w: 0 } }),
record("2026-07-11T13:46:00.000Z", 1, undefined),
],
["sda"],
"Root",
"1h",
);
expect(data.map((point) => ({ Root: point.Root, sda: point.sda }))).toEqual([
{ Root: 1, sda: 0 },
{ Root: 2, sda: 4 },
]);
});
});
describe("padLiveTimeGrid", () => {
test("keeps a fixed 60-second window with the newest sample at the right edge", () => {
const data = padLiveTimeGrid([{ time: "13:51:30", rawTime: "2026-07-11T13:51:30.217Z", CPU: 18.07 }]);
expect(data).toHaveLength(60);
expect(data[0]).not.toHaveProperty("CPU");
expect(data[58]).not.toHaveProperty("CPU");
expect(data[59]).toMatchObject({ CPU: 18.07 });
});
test("leaves null gaps for missing seconds instead of stretching existing points", () => {
const data = padLiveTimeGrid([
{ time: "13:51:28", rawTime: "2026-07-11T13:51:28.000Z", CPU: 10 },
{ time: "13:51:30", rawTime: "2026-07-11T13:51:30.000Z", CPU: 20 },
]);
expect(data.slice(-3).map((point) => point.CPU)).toEqual([10, undefined, 20]);
});
});
+62 -14
View File
@@ -30,6 +30,7 @@ function prepareRecords<T>(records: T[], timePeriod: BeszelTimePeriod) {
}
function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeriod) {
if (timePeriod === "1m") return padLiveTimeGrid(data);
const days = periodDays[timePeriod];
if (!days) return data;
@@ -50,6 +51,38 @@ function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeri
return result.toSorted((a, b) => new Date(a.rawTime as string).getTime() - new Date(b.rawTime as string).getTime());
}
export function padLiveTimeGrid(data: Record<string, unknown>[], pointCount = 60) {
if (data.length === 0) return data;
const end = dayjs(
data.reduce((latest, point) => {
const timestamp = new Date(point.rawTime as string).getTime();
return Number.isFinite(timestamp) ? Math.max(latest, timestamp) : latest;
}, 0),
).startOf("second");
const pointsBySecond = new Map(
data.map(
(point) =>
[
dayjs(point.rawTime as string)
.startOf("second")
.valueOf(),
point,
] as const,
),
);
return Array.from({ length: pointCount }, (_, index) => {
const timestamp = end.subtract(pointCount - index - 1, "second");
return (
pointsBySecond.get(timestamp.valueOf()) ?? {
time: timestamp.format(timeFormats["1m"]),
rawTime: timestamp.toISOString(),
}
);
});
}
const yAxisBase = { tickMargin: 0, tick: { fontSize: 10 } } as const;
const chartStyle = { minWidth: 0, minHeight: 1 } as const;
const panelStyle = { minWidth: 0, overflow: "hidden" } as const;
@@ -174,24 +207,39 @@ const defaultContainerExtractors: Record<string, ContainerExtractor> = {
network: (c) => (c?.b ? c.b[0] + c.b[1] : (c?.ns ?? 0) + (c?.nr ?? 0)),
};
export const useEfsChartData = (
export const useDiskChartData = (
systemStats: BeszelSystemStatsRecord[] | undefined,
efsPaths: string[],
rootSeriesName: string,
timePeriod: BeszelTimePeriod = "1h",
) =>
useMemo(() => {
if (!systemStats?.length) return [];
const { fmt, ordered } = prepareRecords(systemStats, timePeriod);
const mapped = ordered.map((record) => {
const point: Record<string, unknown> = { time: fmt(record.created), rawTime: record.created };
const efs = record.stats.efs ?? {};
for (const path of efsPaths) {
point[path] = efs[path]?.du ?? 0;
}
return point;
});
return padTimeGrid(mapped, timePeriod);
}, [systemStats, efsPaths, timePeriod]);
useMemo(
() => buildDiskChartData(systemStats, efsPaths, rootSeriesName, timePeriod),
[systemStats, efsPaths, rootSeriesName, timePeriod],
);
export const buildDiskChartData = (
systemStats: BeszelSystemStatsRecord[] | undefined,
efsPaths: string[],
rootSeriesName: string,
timePeriod: BeszelTimePeriod = "1h",
) => {
if (!systemStats?.length) return [];
const { fmt, ordered } = prepareRecords(systemStats, timePeriod);
const mapped = ordered.map((record) => {
const point: Record<string, unknown> = {
time: fmt(record.created),
rawTime: record.created,
[rootSeriesName]: record.stats.du,
};
const efs = record.stats.efs ?? {};
for (const path of efsPaths) {
point[path] = efs[path]?.du ?? 0;
}
return point;
});
return padTimeGrid(mapped, timePeriod);
};
export const useDockerChartData = (
containerStats: BeszelContainerStatsRecord[] | undefined,
@@ -0,0 +1,105 @@
import { Box, Group, HoverCard, Progress, Stack, Text, UnstyledButton } from "@mantine/core";
import type { BeszelSystemRow } from "./types";
import { thresholdColor } from "./colors";
import { formatPercent, getProgressTrackSize } from "./format";
interface DiskUsageProps {
system: BeszelSystemRow;
fontSize: string;
progressSize: "xs" | "sm";
valueMiw: number;
valueGap?: number;
}
const mountLabel = (path: string) =>
path === "/" ? "ROOT" : (path.split("/").filter(Boolean).at(-1) ?? path).toUpperCase();
const severityColor = (value: number) => `var(--mantine-color-${thresholdColor(value)}-6)`;
export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap = 6 }: DiskUsageProps) => {
const filesystems = Object.entries(system.extraFilesystems).filter(([path]) => path !== "/");
const trackSize = getProgressTrackSize(progressSize);
const dotSize = progressSize === "xs" ? 2 : 3;
const dotGap = 2;
return (
<Group gap={valueGap} wrap="nowrap" style={{ flex: 1, minWidth: 0, marginLeft: "auto" }}>
<Text size={fontSize} fw={500} w={valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{formatPercent(system.disk)}
</Text>
<HoverCard
position="right"
withArrow
shadow="md"
openDelay={200}
closeDelay={100}
disabled={filesystems.length === 0}
>
<HoverCard.Target>
{filesystems.length > 0 ? (
<UnstyledButton
aria-label={`Show usage for ${filesystems.length + 1} filesystems`}
style={{
display: "flex",
alignItems: "center",
flex: 1,
minWidth: 24,
cursor: "pointer",
}}
>
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
<Progress value={system.disk} color={thresholdColor(system.disk)} size={trackSize} />
<Group
gap={dotGap}
wrap="nowrap"
aria-hidden
pos="absolute"
top="50%"
right={Math.max(2, dotGap)}
style={{ transform: "translateY(-50%)" }}
>
{filesystems.map(([path, value]) => (
<Box
key={path}
w={dotSize}
h={dotSize}
style={{
borderRadius: "50%",
backgroundColor: severityColor(value),
boxShadow: "0 0 0 0.5px var(--mantine-color-body)",
flex: "0 0 auto",
}}
/>
))}
</Group>
</Box>
</UnstyledButton>
) : (
<Box style={{ display: "flex", alignItems: "center", flex: 1, minWidth: 24 }}>
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
<Progress value={system.disk} color={thresholdColor(system.disk)} size={trackSize} />
</Box>
</Box>
)}
</HoverCard.Target>
<HoverCard.Dropdown p={8}>
<Stack gap={6} miw={145}>
{[["/", system.disk] as const, ...filesystems].map(([path, value]) => (
<Stack key={path} gap={1}>
<Text size="10px" c="dimmed" fw={500} lh={1.2}>
{mountLabel(path)}
</Text>
<Group gap={8} wrap="nowrap">
<Text size="sm" fw={600} miw={48} lh={1.25}>
{formatPercent(value)}
</Text>
<Progress value={value} color={thresholdColor(value)} size="xs" style={{ flex: 1 }} />
</Group>
</Stack>
))}
</Stack>
</HoverCard.Dropdown>
</HoverCard>
</Group>
);
};
@@ -0,0 +1,21 @@
import { describe, expect, test } from "vitest";
import { chartAxisFormatters, formatGB, getProgressTrackSize } from "./format";
describe("Beszel storage formatting", () => {
test("keeps smaller values in GB", () => {
expect(formatGB(455.81)).toBe("455.81 GB");
expect(chartAxisFormatters.gb(455.81)).toBe("456G");
});
test("promotes large GiB values to TB", () => {
expect(formatGB(3323)).toBe("3.25 TB");
expect(formatGB(3936.86)).toBe("3.84 TB");
expect(chartAxisFormatters.gb(3323)).toBe("3.2T");
});
test("maps progress sizes consistently", () => {
expect(getProgressTrackSize("xs")).toBe(6);
expect(getProgressTrackSize("sm")).toBe(9);
});
});
@@ -29,14 +29,22 @@ export const formatStorageBytes = (bytes: number): string => formatScaled(bytes,
export const formatGB = (value: number): string => {
if (!value || !Number.isFinite(value)) return "0 GB";
if (Math.abs(value) >= 1024) return `${(value / 1024).toFixed(2)} TB`;
return `${value.toFixed(2)} GB`;
};
export const formatPercent = (value: number): string => `${value.toFixed(1)}%`;
export const getProgressTrackSize = (size: "xs" | "sm"): number => (size === "xs" ? 6 : 9);
export const chartAxisFormatters = {
percent: formatPercent,
gb: (value: number) => (!value ? "0" : `${Number(value).toFixed(0)}G`),
gb: (value: number) => {
const numericValue = Number(value);
if (!numericValue) return "0";
if (Math.abs(numericValue) >= 1024) return `${(numericValue / 1024).toFixed(1)}T`;
return `${numericValue.toFixed(0)}G`;
},
mb: (value: number) => (!value ? "0" : `${Number(value).toFixed(0)}M`),
bytes: (value: number) => formatScaledCompact(Number(value), byteUnits, "0"),
rate: (value: number) => formatScaledCompact(Number(value), rateUnits, "0"),
@@ -0,0 +1,339 @@
"use client";
import { useMemo } from "react";
import { Button, Center, Group, SimpleGrid, Skeleton, Stack, Text } from "@mantine/core";
import { IconPlugConnectedX, IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { containerColors } from "./colors";
import type { BeszelTimePeriod } from "./chart";
import {
BeszelChartPanel,
CPU_Y_AXIS_DOMAIN,
useContainerNames,
useDiskChartData,
useDockerChartData,
useSystemChartData,
} from "./chart";
import { chartAxisFormatters, formatByteRate, formatGB, formatPercent, formatStorageBytes } from "./format";
import { makeTooltipProps } from "./tooltip";
import { useLiveStats } from "./use-live-stats";
const CHART_HEIGHT = 180;
const MB = 1024 * 1024;
const tooltipPercent = makeTooltipProps(formatPercent);
const tooltipGB = makeTooltipProps(formatGB, true);
const tooltipRate = makeTooltipProps(formatByteRate, true);
const tooltipPercentTotal = makeTooltipProps(formatPercent, true);
const tooltipBytesTotal = makeTooltipProps(formatStorageBytes, true);
const ChartSkeleton = () => (
<Stack gap={4} style={{ minWidth: 0 }}>
<Group gap="xs">
<Skeleton h={14} w={72} radius="sm" />
<Skeleton h={10} w={110} radius="sm" />
</Group>
<Skeleton h={CHART_HEIGHT} radius="sm" />
</Stack>
);
export interface BeszelStatsVisibility {
cpu: boolean;
memory: boolean;
disk: boolean;
diskIO: boolean;
network: boolean;
dockerCpu: boolean;
dockerMemory: boolean;
dockerNetwork: boolean;
}
interface BeszelStatsViewProps {
integrationIds: string[];
systemId: string;
timePeriod: BeszelTimePeriod;
visibility: BeszelStatsVisibility;
columns: 1 | 2;
onSwitchToHistorical?: () => void;
}
const whenVisible = <T,>(visible: boolean, data: T | undefined) => (visible ? data : undefined);
export function BeszelStatsView({
integrationIds,
systemId,
timePeriod,
visibility,
columns,
onSwitchToHistorical,
}: BeszelStatsViewProps) {
const t = useScopedI18n("widget.beszelSystemStats");
const showDocker = visibility.dockerCpu || visibility.dockerMemory || visibility.dockerNetwork;
const isLive = timePeriod === "1m";
const { data: historicalData, error: historicalError } = clientApi.widget.beszel.getSystemStats.useQuery(
{ integrationIds, systemId, timePeriod, includeDocker: showDocker },
{ refetchInterval: isLive ? false : 5_000, enabled: !isLive && systemId !== "" },
);
const { data: liveData, error: liveError } = useLiveStats(integrationIds, systemId, isLive && systemId !== "");
const activeStats:
| { systemStats: BeszelSystemStatsRecord[]; containerStats: BeszelContainerStatsRecord[]; error?: string }
| null
| undefined = isLive ? liveData : historicalData;
const mappers = useMemo(
() => ({
cpu: (s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }),
memory: (s: { mu: number; mb: number }) => ({
[t("chart.memory.series")]: s.mu,
[t("chart.memory.cache")]: s.mb ?? 0,
}),
diskIO: (s: { dr?: number; dw?: number }) => ({
[t("chart.diskIO.read")]: (s.dr ?? 0) * MB,
[t("chart.diskIO.write")]: (s.dw ?? 0) * MB,
}),
network: (s: { ns?: number; nr?: number; b?: [number, number] }) => ({
[t("chart.network.sent")]: s.b?.[0] ?? s.ns ?? 0,
[t("chart.network.recv")]: s.b?.[1] ?? s.nr ?? 0,
}),
}),
[t],
);
const series = useMemo(
() => ({
cpu: [{ name: t("chart.cpu.series"), color: "teal.6" }],
memory: [
{ name: t("chart.memory.series"), color: "teal.5" },
{ name: t("chart.memory.cache"), color: "teal.8" },
],
diskIO: [
{ name: t("chart.diskIO.write"), color: "orange.6" },
{ name: t("chart.diskIO.read"), color: "blue.6" },
],
network: [
{ name: t("chart.network.sent"), color: "blue.6" },
{ name: t("chart.network.recv"), color: "teal.6" },
],
}),
[t],
);
const systemStats = activeStats?.systemStats;
const containerStats = activeStats?.containerStats;
const cpuData = useSystemChartData(whenVisible(visibility.cpu, systemStats), mappers.cpu, timePeriod);
const memoryData = useSystemChartData(whenVisible(visibility.memory, systemStats), mappers.memory, timePeriod);
const diskIOData = useSystemChartData(whenVisible(visibility.diskIO, systemStats), mappers.diskIO, timePeriod);
const networkData = useSystemChartData(whenVisible(visibility.network, systemStats), mappers.network, timePeriod);
const efsPaths = useMemo(() => {
const paths = new Set<string>();
for (const record of systemStats ?? []) {
for (const path of Object.keys(record.stats.efs ?? {})) paths.add(path);
}
return [...paths];
}, [systemStats]);
const rootSeriesName = t("chart.disk.series");
const diskData = useDiskChartData(whenVisible(visibility.disk, systemStats), efsPaths, rootSeriesName, timePeriod);
const diskSeries = useMemo(
() => [
{ name: rootSeriesName, color: "grape.6" },
...efsPaths.map((path, index) => ({
name: path,
color: containerColors[(index + 1) % containerColors.length] as string,
})),
],
[efsPaths, rootSeriesName],
);
const containerNames = useContainerNames(whenVisible(showDocker, containerStats));
const containerSeries = useMemo(
() =>
containerNames.map((name, index) => ({ name, color: containerColors[index % containerColors.length] as string })),
[containerNames],
);
const dockerCpuData = useDockerChartData(
whenVisible(visibility.dockerCpu, containerStats),
containerNames,
"cpu",
timePeriod,
);
const dockerMemoryData = useDockerChartData(
whenVisible(visibility.dockerMemory, containerStats),
containerNames,
"memory",
timePeriod,
);
const dockerNetworkData = useDockerChartData(
whenVisible(visibility.dockerNetwork, containerStats),
containerNames,
"network",
timePeriod,
);
if (historicalError) throw historicalError;
if (isLive && liveError) {
return (
<Center h={CHART_HEIGHT}>
<Stack align="center" gap="sm" p="md">
<IconPlugConnectedX size={40} />
<Text size="sm" c="dimmed" ta="center">
{t("error.liveConnectionFailed")}
</Text>
{onSwitchToHistorical && (
<Button size="xs" variant="light" onClick={onSwitchToHistorical}>
{t("error.switchToHistorical")}
</Button>
)}
</Stack>
</Center>
);
}
if (!activeStats) {
const visibleChartCount = Object.values(visibility).filter(Boolean).length;
return (
<SimpleGrid cols={columns} spacing="md" aria-label="Loading system statistics">
{Array.from({ length: visibleChartCount }, (_, index) => (
<ChartSkeleton key={index} />
))}
</SimpleGrid>
);
}
if ("error" in activeStats && activeStats.error) {
return (
<Center h={CHART_HEIGHT}>
<Stack align="center" gap="xs">
<IconServerOff size={24} opacity={0.5} />
<Text size="sm" c="dimmed">
{activeStats.error}
</Text>
</Stack>
</Center>
);
}
return (
<SimpleGrid cols={columns} spacing="md">
{visibility.cpu && cpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.cpu.title")}
subtitle={t("chart.cpu.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: cpuData,
series: series.cpu,
yAxisFormatter: chartAxisFormatters.percent,
yAxisDomain: CPU_Y_AXIS_DOMAIN,
tooltipProps: tooltipPercent,
}}
/>
)}
{visibility.memory && memoryData.length > 0 && (
<BeszelChartPanel
title={t("chart.memory.title")}
subtitle={t("chart.memory.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: memoryData,
type: "stacked",
series: series.memory,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{visibility.disk && diskData.length > 0 && (
<BeszelChartPanel
title={t("chart.disk.title")}
subtitle={t("chart.disk.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: diskData,
type: "stacked",
series: diskSeries,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{visibility.diskIO && diskIOData.length > 0 && (
<BeszelChartPanel
title={t("chart.diskIO.title")}
subtitle={t("chart.diskIO.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: diskIOData,
series: series.diskIO,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
{visibility.network && networkData.length > 0 && (
<BeszelChartPanel
title={t("chart.network.title")}
subtitle={t("chart.network.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: networkData,
series: series.network,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
{showDocker && containerSeries.length > 0 && (
<>
{visibility.dockerCpu && dockerCpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerCpu.title")}
subtitle={t("chart.dockerCpu.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerCpuData,
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.percent,
tooltipProps: tooltipPercentTotal,
}}
/>
)}
{visibility.dockerMemory && dockerMemoryData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerMemory.title")}
subtitle={t("chart.dockerMemory.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerMemoryData,
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.bytes,
tooltipProps: tooltipBytesTotal,
}}
/>
)}
{visibility.dockerNetwork && dockerNetworkData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerNetwork.title")}
subtitle={t("chart.dockerNetwork.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerNetworkData,
series: containerSeries,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
</>
)}
</SimpleGrid>
);
}
@@ -1,25 +1,15 @@
"use client";
import { useMemo, useState } from "react";
import { SegmentedControl, SimpleGrid, Stack } from "@mantine/core";
import { useState } from "react";
import { SegmentedControl, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { createModal } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import type { BeszelTimePeriod } from "./chart";
import { BeszelChartPanel, CPU_Y_AXIS_DOMAIN, useSystemChartData } from "./chart";
import { chartAxisFormatters, formatByteRate, formatGB, formatPercent } from "./format";
import { makeTooltipProps } from "./tooltip";
const tooltipPercent = makeTooltipProps(formatPercent);
const tooltipGB = makeTooltipProps(formatGB, true);
const tooltipRate = makeTooltipProps(formatByteRate, true);
const CHART_HEIGHT = 180;
const MB = 1024 * 1024;
import { BeszelStatsView } from "./stats-view";
const timePeriodOptions: { value: BeszelTimePeriod; label: string }[] = [
{ value: "1m", label: "Live" },
{ value: "1h", label: "1H" },
{ value: "12h", label: "12H" },
{ value: "24h", label: "24H" },
@@ -27,141 +17,41 @@ const timePeriodOptions: { value: BeszelTimePeriod; label: string }[] = [
{ value: "30d", label: "30D" },
];
const allCharts = {
cpu: true,
memory: true,
disk: true,
diskIO: true,
network: true,
dockerCpu: true,
dockerMemory: true,
dockerNetwork: true,
} as const;
interface BeszelSystemStatsModalProps {
integrationId: string;
systemId: string;
}
export const BeszelSystemStatsModal = createModal<BeszelSystemStatsModalProps>(({ innerProps }) => {
const t = useScopedI18n("widget.beszelSystemStats");
const [timePeriod, setTimePeriod] = useState<BeszelTimePeriod>("1h");
const { data } = clientApi.widget.beszel.getSystemStats.useQuery({
integrationIds: [innerProps.integrationId],
systemId: innerProps.systemId,
timePeriod,
includeDocker: false,
});
const mappers = useMemo(
() => ({
cpu: (s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }),
memory: (s: { mu: number; mb: number }) => ({
[t("chart.memory.series")]: s.mu,
[t("chart.memory.cache")]: s.mb ?? 0,
}),
disk: (s: { du: number }) => ({ [t("chart.disk.series")]: s.du }),
diskIO: (s: { dr?: number; dw?: number }) => ({
[t("chart.diskIO.read")]: (s.dr ?? 0) * MB,
[t("chart.diskIO.write")]: (s.dw ?? 0) * MB,
}),
network: (s: { ns: number; nr: number; b?: [number, number] }) => ({
[t("chart.network.sent")]: s.b?.[0] ?? s.ns,
[t("chart.network.recv")]: s.b?.[1] ?? s.nr,
}),
}),
[t],
);
const series = useMemo(
() => ({
cpu: [{ name: t("chart.cpu.series"), color: "teal.6" }],
memory: [
{ name: t("chart.memory.series"), color: "teal.5" },
{ name: t("chart.memory.cache"), color: "teal.8" },
],
disk: [{ name: t("chart.disk.series"), color: "grape.6" }],
diskIO: [
{ name: t("chart.diskIO.write"), color: "orange.6" },
{ name: t("chart.diskIO.read"), color: "blue.6" },
],
network: [
{ name: t("chart.network.sent"), color: "blue.6" },
{ name: t("chart.network.recv"), color: "teal.6" },
],
}),
[t],
);
const systemStats = data?.systemStats;
const cpuData = useSystemChartData(systemStats, mappers.cpu, timePeriod);
const memoryData = useSystemChartData(systemStats, mappers.memory, timePeriod);
const diskData = useSystemChartData(systemStats, mappers.disk, timePeriod);
const diskIOData = useSystemChartData(systemStats, mappers.diskIO, timePeriod);
const networkData = useSystemChartData(systemStats, mappers.network, timePeriod);
return (
<Stack gap="md">
<SegmentedControl
size="xs"
value={timePeriod}
onChange={(v) => setTimePeriod(v as BeszelTimePeriod)}
onChange={(value) => setTimePeriod(value as BeszelTimePeriod)}
data={timePeriodOptions}
/>
<SimpleGrid cols={2} spacing="md">
{cpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.cpu.title")}
chartProps={{
h: CHART_HEIGHT,
data: cpuData,
series: series.cpu,
yAxisFormatter: chartAxisFormatters.percent,
yAxisDomain: CPU_Y_AXIS_DOMAIN,
tooltipProps: tooltipPercent,
}}
/>
)}
{memoryData.length > 0 && (
<BeszelChartPanel
title={t("chart.memory.title")}
chartProps={{
h: CHART_HEIGHT,
data: memoryData,
type: "stacked",
series: series.memory,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{diskData.length > 0 && (
<BeszelChartPanel
title={t("chart.disk.title")}
chartProps={{
h: CHART_HEIGHT,
data: diskData,
series: series.disk,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{diskIOData.length > 0 && (
<BeszelChartPanel
title={t("chart.diskIO.title")}
chartProps={{
h: CHART_HEIGHT,
data: diskIOData,
series: series.diskIO,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
{networkData.length > 0 && (
<BeszelChartPanel
title={t("chart.network.title")}
chartProps={{
h: CHART_HEIGHT,
data: networkData,
series: series.network,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
</SimpleGrid>
<BeszelStatsView
integrationIds={[innerProps.integrationId]}
systemId={innerProps.systemId}
timePeriod={timePeriod}
visibility={allCharts}
columns={2}
onSwitchToHistorical={() => setTimePeriod("1h")}
/>
</Stack>
);
}).withOptions({
@@ -1,12 +1,14 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { skipToken } from "@tanstack/react-query";
import { clientApi } from "@homarr/api/client";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
// 120 records at ~1 per second = 2 minutes of rolling data for charts
const MAX_BUFFER = 120;
// Beszel emits roughly one record per second. Keep a sliding one-minute window
// without synthesizing points for missed updates.
const MAX_BUFFER = 60;
export const useLiveStats = (integrationIds: string[], systemId: string, enabled: boolean) => {
const [systemStats, setSystemStats] = useState<BeszelSystemStatsRecord[]>([]);
@@ -29,9 +31,8 @@ export const useLiveStats = (integrationIds: string[], systemId: string, enabled
}, []);
clientApi.widget.beszel.subscribeSystemStats.useSubscription(
{ integrationIds, systemId },
enabled && systemId !== "" ? { integrationIds, systemId } : skipToken,
{
enabled: enabled && systemId !== "",
onData(event) {
setError(null);
if (event.type === "system_stats") {
@@ -48,7 +49,7 @@ export const useLiveStats = (integrationIds: string[], systemId: string, enabled
// Reset buffers when system changes or integration changes
const prevKeyRef = useRef<string>("");
const currentKey = `${integrationIds.join(",")}:${systemId}`;
const currentKey = `${integrationIds.join(",")}:${systemId}:${enabled}`;
useEffect(() => {
if (prevKeyRef.current !== currentKey) {
prevKeyRef.current = currentKey;
+2 -1
View File
@@ -1,6 +1,6 @@
import type React from "react";
import type { LoaderComponent } from "next/dynamic";
import type { QueryClient } from "@tanstack/react-query";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
@@ -69,6 +69,7 @@ export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition ext
export interface WidgetDefinition {
icon: TablerIcon;
queryKey?: QueryKey;
supportedIntegrations?: IntegrationKind[];
integrationsRequired?: boolean;
maxIntegrations?: number;
@@ -111,7 +111,7 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon
},
);
},
supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring"),
supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring").filter((kind) => kind !== "patchmon"),
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
+2
View File
@@ -39,6 +39,7 @@ import * as networkControllerStatus from "./network-controller/network-status";
import * as networkControllerSummary from "./network-controller/summary";
import * as notebook from "./notebook";
import * as paperlessNgx from "./paperless-ngx";
import * as patchmon from "./patchmon";
import * as notifications from "./notifications";
import type { WidgetOptionDefinition } from "./options";
import * as releases from "./releases";
@@ -114,6 +115,7 @@ export const widgetImports = {
"immich-serverStats": immichServerStats,
"immich-albumCarousel": immichAlbumCarousel,
paperlessNgx,
patchmon,
tracearr,
speedtestTracker,
uptimeKuma,
@@ -19,6 +19,7 @@ import { getInputForType } from "../_inputs";
import { FormProvider, useForm } from "../_inputs/form";
import type { BoardItemAdvancedOptions } from "../../../validation/src/shared";
import type { OptionsBuilderResult } from "../options";
import { OPTIONS_SUPER_REFINE } from "../options";
import type { IntegrationSelectOption } from "../widget-integration-select";
import { WidgetIntegrationSelect } from "../widget-integration-select";
import { WidgetAdvancedOptionsModal } from "./widget-advanced-options-modal";
@@ -54,24 +55,29 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
});
const { definition } = widgetImports[innerProps.kind];
const options = definition.createOptions(innerProps.settings) as Record<string, OptionsBuilderResult[string]>;
const optionsSuperRefine = (options as Record<symbol, unknown>)[OPTIONS_SUPER_REFINE] as
| ((data: Record<string, unknown>, ctx: z.RefinementCtx) => void)
| undefined;
const optionsFieldSchema = objectEntries(options).reduce(
(acc, [key, value]: [string, { type: string; validate?: z.ZodType<unknown> }]) => {
if (value.validate) {
acc[key] = value.type === "multiText" ? z.array(value.validate).optional() : value.validate;
}
return acc;
},
{} as Record<string, z.ZodType<unknown>>,
);
const optionsSchema = optionsSuperRefine
? z.object(optionsFieldSchema).superRefine(optionsSuperRefine)
: z.object(optionsFieldSchema);
const form = useForm({
mode: "controlled",
initialValues: innerProps.value,
validate: schemaResolver(
z.object({
options: z.object(
objectEntries(options).reduce(
(acc, [key, value]: [string, { type: string; validate?: z.ZodType<unknown> }]) => {
if (value.validate) {
acc[key] = value.type === "multiText" ? z.array(value.validate).optional() : value.validate;
}
return acc;
},
{} as Record<string, z.ZodType<unknown>>,
),
),
options: optionsSchema,
integrationIds: z.array(z.string()),
advancedOptions: z.object({
customCssClasses: z.array(z.string()),
+14 -2
View File
@@ -2,7 +2,7 @@ import type React from "react";
import type { DraggableAttributes, UniqueIdentifier } from "@dnd-kit/core";
import type { ActionIconProps } from "@mantine/core";
import { z } from "zod/v4";
import type { ZodType } from "zod/v4";
import type { RefinementCtx, ZodType } from "zod/v4";
import type { IntegrationKind } from "@homarr/definitions";
@@ -257,9 +257,12 @@ type ConfigurationInput<TOptions extends WidgetOptionsRecord> = Partial<
Record<keyof TOptions, FieldConfiguration<TOptions>>
>;
export const OPTIONS_SUPER_REFINE = Symbol("optionsSuperRefine");
const createOptions = <TOptions extends WidgetOptionsRecord>(
optionsCallback: (factory: WidgetOptionFactory) => TOptions,
configuration?: ConfigurationInput<TOptions>,
optionsSuperRefine?: (data: inferOptionsFromDefinition<TOptions>, ctx: RefinementCtx) => void,
) => {
const obj = {} as Record<keyof TOptions, unknown>;
const options = optionsCallback(optionsFactory);
@@ -271,9 +274,18 @@ const createOptions = <TOptions extends WidgetOptionsRecord>(
};
}
return obj as {
const result = obj as {
[key in keyof TOptions]: TOptions[key] & FieldConfiguration<TOptions>;
};
if (optionsSuperRefine) {
Object.defineProperty(result, OPTIONS_SUPER_REFINE, {
value: optionsSuperRefine,
enumerable: false,
});
}
return result;
};
type OptionsBuilder = typeof createOptions;
@@ -0,0 +1,371 @@
.root {
container-type: size;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
padding: clamp(6px, 3cqi, 14px);
gap: clamp(4px, 2cqi, 10px);
overflow: hidden;
}
.compactRoot {
padding: clamp(4px, 3cqi, 8px);
gap: 2px;
}
.content {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
gap: clamp(4px, 2cqi, 8px);
}
.hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: clamp(8px, 3cqi, 18px);
flex-shrink: 0;
padding-bottom: clamp(4px, 2cqi, 10px);
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
}
.heroExpanded {
flex: 1;
border-bottom: none;
justify-content: center;
padding-bottom: 0;
}
.heroTextOnly {
justify-content: center;
}
.heroRingOnly {
justify-content: center;
padding-bottom: 0;
border-bottom: none;
}
.heroWithText {
justify-content: center;
width: 100%;
}
.heroWithText .heroText {
flex: 0 1 auto;
}
.heroExpanded.heroRingOnly {
flex: 1;
}
.heroTextOnly .heroText {
align-items: center;
text-align: center;
}
.heroTextOnly .heroValue {
font-size: clamp(22px, 10cqi, 36px);
}
.heroText {
display: flex;
flex-direction: column;
gap: clamp(2px, 1cqi, 4px);
min-width: 0;
flex: 1;
}
.heroLabel {
font-size: clamp(9px, 2.8cqi, 13px);
color: var(--mantine-color-dimmed);
line-height: 1.2;
}
.heroValue {
font-size: clamp(18px, 8cqi, 32px);
font-weight: 700;
line-height: 1.1;
}
.ring {
flex-shrink: 0;
}
.compactPrimary {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
min-height: 0;
min-width: 0;
}
.statsSection {
flex: 1;
min-height: 0;
}
.statsSectionCompact {
flex: 0 1 auto;
}
.grid {
display: grid;
height: 100%;
min-height: 0;
gap: clamp(4px, 2cqi, 10px);
grid-template-columns: repeat(var(--stat-cols, 2), minmax(0, 1fr));
grid-auto-rows: 1fr;
align-content: stretch;
}
.statTile {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 0;
min-width: 0;
gap: clamp(2px, 1.5cqmin, 5px);
padding: clamp(4px, 2.5cqi, 10px);
border-radius: var(--mantine-radius-md);
background-color: var(--stat-bg, light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)));
overflow: hidden;
}
.statIcon {
flex-shrink: 0;
}
.statLabel {
font-size: clamp(8px, 2.5cqmin, 12px);
color: var(--mantine-color-dimmed);
line-height: 1.2;
text-align: center;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.statValue {
font-size: clamp(14px, 5.5cqmin, 24px);
font-weight: 700;
line-height: 1.1;
text-align: center;
}
.compactStatGrid {
display: grid;
flex: 1;
grid-template-columns: repeat(var(--compact-stat-cols, 1), minmax(0, 1fr));
grid-auto-rows: minmax(0, 1fr);
gap: clamp(3px, 2cqi, 6px);
min-width: 0;
min-height: 0;
width: 100%;
}
.compactStatTile {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 0;
min-height: 0;
padding: clamp(4px, 3cqi, 8px);
border-radius: var(--mantine-radius-md);
background-color: var(--stat-bg, light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)));
overflow: hidden;
}
.compactStatValue {
font-size: clamp(22px, 17cqmin, 42px);
font-weight: 700;
line-height: 1;
text-align: center;
}
.compactStatLabel {
max-width: 100%;
overflow: hidden;
color: var(--mantine-color-dimmed);
font-size: clamp(8px, 2.4cqmin, 11px);
line-height: 1.1;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.emptyState {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
min-height: 0;
color: var(--mantine-color-dimmed);
font-size: clamp(11px, 3cqi, 14px);
text-align: center;
}
.osSectionWrapper {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.osSectionWithStats {
padding-top: clamp(4px, 2cqi, 8px);
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
}
.osSection {
min-height: 0;
}
.osHeading {
flex-shrink: 0;
}
.osScroll {
flex: 1;
min-height: 0;
}
.osRow {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.osLabel {
font-size: clamp(9px, 2.5cqmin, 12px);
line-height: 1.2;
}
.osBarGroup {
width: 100%;
min-width: 0;
}
.osBar {
flex: 1;
min-width: 0;
}
.osCount {
flex: 0 0 auto;
min-width: 1.5rem;
text-align: right;
font-size: clamp(9px, 2.5cqmin, 12px);
}
.compactOsBars {
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
gap: clamp(3px, 2cqmin, 5px);
min-width: 0;
min-height: 0;
width: 100%;
}
.compactOsBar {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.compactOsLabel {
max-width: 100%;
overflow: hidden;
font-size: clamp(9px, 2.5cqmin, 12px);
line-height: 1.1;
text-overflow: ellipsis;
white-space: nowrap;
}
.osDonutContent {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
min-height: 0;
max-width: 100%;
overflow: visible;
}
.osDonutContentWithLegend {
justify-content: space-evenly;
gap: clamp(8px, 3cqi, 16px);
}
.osDonut {
display: flex;
align-items: center;
justify-content: center;
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
overflow: visible;
}
.osLegend {
display: flex;
flex: 1 1 0;
flex-direction: column;
gap: 4px;
min-width: 0;
max-width: 50%;
}
.osLegendItem {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
font-size: clamp(9px, 2.2cqmin, 12px);
line-height: 1.2;
}
.osLegendSwatch {
width: 8px;
height: 8px;
flex: 0 0 auto;
border-radius: var(--mantine-radius-xl);
background-color: var(--os-color);
}
.osLegendLabel {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.osLegendValue {
flex: 0 0 auto;
}
.footer {
flex-shrink: 0;
max-width: 100%;
overflow: hidden;
font-size: clamp(9px, 2.2cqmin, 12px);
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.compactFooter {
font-size: clamp(8px, 2.2cqmin, 10px);
}
+458
View File
@@ -0,0 +1,458 @@
"use client";
import type { CSSProperties } from "react";
import { RingProgress, ScrollArea, Text, Tooltip } from "@mantine/core";
import {
IconCircleCheck,
IconClockCheck,
IconDatabase,
IconPackages,
IconRefreshAlert,
IconServer,
IconShieldExclamation,
IconShieldX,
} from "@tabler/icons-react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css";
import { getGridCols, shouldShowComplianceHeroText } from "./layout-utils";
import { OsDistributionSection } from "./os-distribution-section";
import type { PatchMonStatKey } from "./stat-colors";
import { resolveStatColor, severityToIconColor, severityToMantineBgColor, severityToMantineColor } from "./stat-colors";
dayjs.extend(relativeTime);
const statVisibilityByOption = {
showTotalHosts: "totalHosts",
showHostsNeedingUpdates: "hostsNeedingUpdates",
showSecurityUpdates: "securityUpdates",
showUpToDateHosts: "upToDateHosts",
showHostsWithSecurityUpdates: "hostsWithSecurityUpdates",
showRecentUpdates24h: "recentUpdates24h",
showTotalOutdatedPackages: "totalOutdatedPackages",
showTotalRepos: "totalRepos",
} as const;
const statIcons: Record<PatchMonStatKey, TablerIcon> = {
totalHosts: IconServer,
hostsNeedingUpdates: IconRefreshAlert,
securityUpdates: IconShieldExclamation,
upToDateHosts: IconCircleCheck,
hostsWithSecurityUpdates: IconShieldX,
recentUpdates24h: IconClockCheck,
totalOutdatedPackages: IconPackages,
totalRepos: IconDatabase,
};
const iconSizeByWidth = [
{ minWidth: 320, size: 22 },
{ minWidth: 200, size: 18 },
{ minWidth: 0, size: 16 },
] as const;
const ringSizeByWidth = [
{ minWidth: 400, size: 88 },
{ minWidth: 320, size: 72 },
{ minWidth: 220, size: 56 },
{ minWidth: 0, size: 40 },
] as const;
type LayoutMode = "mini" | "compact" | "comfortable" | "spacious";
type CompactPrimaryContent = "hero" | "os" | "stats" | null;
const getLayoutMode = (width: number, height: number): LayoutMode => {
const minDim = Math.min(width, height);
if (minDim < 140) return "mini";
if (minDim < 220) return "compact";
if (width >= 380 && height >= 350) return "spacious";
return "comfortable";
};
export default function PatchMonWidget({ integrationIds, options, width, height }: WidgetComponentProps<"patchmon">) {
const t = useScopedI18n("widget.patchmon");
const integrationId = integrationIds[0] ?? "";
const { data: stats } = clientApi.widget.patchmon.getStats.useQuery({ integrationId }, { staleTime: 60 * 1000 });
if (!stats) return <WidgetEmptyState />;
const statValues: Record<PatchMonStatKey, number> = {
totalHosts: stats.totalHosts,
hostsNeedingUpdates: stats.hostsNeedingUpdates,
securityUpdates: stats.securityUpdates,
upToDateHosts: stats.upToDateHosts,
hostsWithSecurityUpdates: stats.hostsWithSecurityUpdates,
recentUpdates24h: stats.recentUpdates24h,
totalOutdatedPackages: stats.totalOutdatedPackages,
totalRepos: stats.totalRepos,
};
const visibleStatKeys = Object.entries(statVisibilityByOption)
.filter(([optionKey]) => options[optionKey as keyof typeof options])
.map(([, statKey]) => statKey);
const colorContext = { totalHosts: stats.totalHosts };
const layout = getLayoutMode(width, height);
const isLandscape = width / height > 2.5;
const isTinySquare = width < 160 && height < 160;
const isShortLandscape = width >= 160 && height < 160;
const isCompactSurface = isTinySquare || isShortLandscape;
const isNarrow = width < 180;
const osDisplayMode = options.osDisplayMode as "bars" | "donut";
const showOsData = options.showOsDistribution && stats.osDistribution.length > 0;
const showIcons = layout !== "mini";
const showLabels = layout !== "mini";
const showHero = options.showComplianceHero && (!isLandscape || isCompactSurface) && width >= 90 && height >= 90;
const showHeroText = showHero && shouldShowComplianceHeroText(width);
const showHeroRingOnly = showHero && !showHeroText;
const compactPrimaryContent = isCompactSurface
? getCompactPrimaryContent({
showHero,
showOsDistribution: showOsData,
visibleStatKeys,
})
: null;
const showStats = visibleStatKeys.length > 0 && !isCompactSurface;
const showOsSection = showOsData && !isCompactSurface && height > 200;
const showCompactFooter = isCompactSurface && height >= 110;
const showFooter = isCompactSurface ? showCompactFooter : !isLandscape && height > 220;
const showOsLegend = osDisplayMode === "donut" && width >= 380 && height >= 300;
const hasContent = compactPrimaryContent !== null || showStats || showOsSection || showHero;
const gridCols = isLandscape
? Math.min(visibleStatKeys.length, 4)
: Math.min(getGridCols(width), visibleStatKeys.length || 1);
const iconSize = getIconSize(width);
const ringSize = getRingSize(width, height, showHeroRingOnly);
const compliancePercent = stats.totalHosts > 0 ? Math.round((stats.upToDateHosts / stats.totalHosts) * 100) : 100;
const complianceSeverity = resolveStatColor("upToDateHosts", stats.upToDateHosts, colorContext, options);
const complianceColor = complianceSeverity === "neutral" ? "blue" : complianceSeverity;
const timeAgo = dayjs(stats.lastUpdated).fromNow();
const compactTimeAgo = getCompactTimeAgo(stats.lastUpdated);
const footerText = isNarrow ? compactTimeAgo : t("lastUpdated", { time: timeAgo });
if (isCompactSurface) {
const compactContentHeight = showCompactFooter ? height - 18 : height;
const compactStatCols = Math.max(1, Math.floor(width / 120));
const compactStatKeys = visibleStatKeys.slice(0, Math.min(visibleStatKeys.length, compactStatCols * 2));
const compactContent =
compactPrimaryContent === "hero" ? (
<ComplianceHero
compliancePercent={compliancePercent}
complianceColor={complianceColor}
upToDateHosts={stats.upToDateHosts}
totalHosts={stats.totalHosts}
ringSize={getRingSize(width, compactContentHeight, showHeroRingOnly)}
showText={showHeroText}
expanded
compact
getLabel={t}
/>
) : compactPrimaryContent === "os" ? (
<div className={classes.compactPrimary}>
<OsDistributionSection
entries={stats.osDistribution}
limit={Number(options.osDistributionLimit)}
showOsVersion={options.showOsVersion}
displayMode={osDisplayMode}
width={width}
height={compactContentHeight}
showLegend={false}
compact
/>
</div>
) : compactPrimaryContent === "stats" ? (
<CompactStatGrid
statKeys={compactStatKeys}
statValues={statValues}
colorContext={colorContext}
options={options}
columns={compactStatCols}
showIcon={width >= 180 || height >= 140}
getLabel={t}
/>
) : null;
if (!compactContent) {
return (
<div className={`${classes.root} ${classes.compactRoot}`}>
<div className={classes.emptyState}>
<Text size="sm" c="dimmed">
—
</Text>
</div>
</div>
);
}
return (
<div className={`${classes.root} ${classes.compactRoot}`}>
{compactContent}
{showCompactFooter && (
<Text size="xs" c="dimmed" ta="center" className={`${classes.footer} ${classes.compactFooter}`}>
{footerText}
</Text>
)}
</div>
);
}
if (!hasContent) {
return (
<div className={classes.root}>
<div className={classes.emptyState}>
<Text size="sm" c="dimmed">
—
</Text>
</div>
</div>
);
}
const statGridContent = showStats && (
<div className={`${classes.statsSection} ${showOsSection ? classes.statsSectionCompact : ""}`}>
<div className={classes.grid} style={{ "--stat-cols": gridCols } as CSSProperties}>
{visibleStatKeys.map((statKey) => {
const severity = resolveStatColor(statKey, statValues[statKey], colorContext, options);
const Icon = statIcons[statKey];
const tile = (
<div
key={statKey}
className={classes.statTile}
style={{ "--stat-bg": severityToMantineBgColor(severity) } as CSSProperties}
>
{showIcons && (
<Icon className={classes.statIcon} size={iconSize} stroke={1.5} color={severityToIconColor(severity)} />
)}
<span className={classes.statValue}>
<Text component="span" fw={700} size="inherit" c={severityToMantineColor(severity)}>
{statValues[statKey]}
</Text>
</span>
{showLabels && <span className={classes.statLabel}>{t(statKey)}</span>}
</div>
);
if (!showLabels) {
return (
<Tooltip key={statKey} label={t(statKey)} withArrow>
{tile}
</Tooltip>
);
}
return tile;
})}
</div>
</div>
);
const osContent = showOsSection && (
<div className={`${classes.osSectionWrapper} ${visibleStatKeys.length > 0 ? classes.osSectionWithStats : ""}`}>
<OsDistributionSection
entries={stats.osDistribution}
limit={Number(options.osDistributionLimit)}
showOsVersion={options.showOsVersion}
displayMode={osDisplayMode}
width={width}
height={height}
showLegend={showOsLegend}
/>
</div>
);
const needsScroll = layout === "mini" || layout === "compact";
return (
<div className={classes.root}>
{showHero && (
<ComplianceHero
compliancePercent={compliancePercent}
complianceColor={complianceColor}
upToDateHosts={stats.upToDateHosts}
totalHosts={stats.totalHosts}
ringSize={ringSize}
showText={showHeroText}
expanded={!showStats && !showOsSection}
getLabel={t}
/>
)}
{(statGridContent || osContent) &&
(needsScroll ? (
<ScrollArea style={{ flex: 1, minHeight: 0 }} scrollbars="y">
<div className={classes.content}>
{statGridContent}
{osContent}
</div>
</ScrollArea>
) : (
<div className={classes.content}>
{statGridContent}
{osContent}
</div>
))}
{showFooter && (
<Text size="xs" c="dimmed" ta={isNarrow ? "center" : "right"} className={classes.footer}>
{footerText}
</Text>
)}
</div>
);
}
function ComplianceHero({
compliancePercent,
complianceColor,
upToDateHosts,
totalHosts,
ringSize,
showText,
expanded = false,
compact = false,
getLabel,
}: {
compliancePercent: number;
complianceColor: string;
upToDateHosts: number;
totalHosts: number;
ringSize: number;
showText: boolean;
expanded?: boolean;
compact?: boolean;
getLabel: (key: "compliance" | "complianceRatio", params?: { upToDate: number; total: number }) => string;
}) {
const heroClasses = [
compact && classes.compactPrimary,
classes.hero,
expanded && classes.heroExpanded,
showText ? classes.heroWithText : classes.heroRingOnly,
]
.filter(Boolean)
.join(" ");
return (
<div className={heroClasses}>
{showText && (
<div className={classes.heroText}>
<span className={classes.heroLabel}>{getLabel("compliance")}</span>
<span className={classes.heroValue}>
{getLabel("complianceRatio", { upToDate: upToDateHosts, total: totalHosts })}
</span>
</div>
)}
<RingProgress
className={classes.ring}
size={ringSize}
thickness={Math.max(4, Math.round(ringSize / 10))}
roundCaps
sections={[{ value: compliancePercent, color: complianceColor }]}
label={
<Text ta="center" size="xs" fw={700}>
{compliancePercent}%
</Text>
}
/>
</div>
);
}
function getIconSize(width: number): number {
const match = iconSizeByWidth.find(({ minWidth }) => width >= minWidth);
return match?.size ?? 16;
}
function getRingSize(width: number, height: number, compact: boolean): number {
if (compact) {
return Math.max(36, Math.min(64, width - 28, height - 28));
}
const match = ringSizeByWidth.find(({ minWidth }) => width >= minWidth);
return match?.size ?? 44;
}
function getCompactPrimaryContent({
showHero,
showOsDistribution,
visibleStatKeys,
}: {
showHero: boolean;
showOsDistribution: boolean;
visibleStatKeys: PatchMonStatKey[];
}): CompactPrimaryContent {
if (showHero) return "hero";
if (showOsDistribution) return "os";
return visibleStatKeys.length > 0 ? "stats" : null;
}
function getCompactTimeAgo(lastUpdated: string): string {
const minutes = Math.max(0, dayjs().diff(dayjs(lastUpdated), "minute"));
if (minutes < 1) return "now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function CompactStatGrid({
statKeys,
statValues,
colorContext,
options,
columns,
showIcon,
getLabel,
}: {
statKeys: PatchMonStatKey[];
statValues: Record<PatchMonStatKey, number>;
colorContext: { totalHosts: number };
options: WidgetComponentProps<"patchmon">["options"];
columns: number;
showIcon: boolean;
getLabel: (key: PatchMonStatKey) => string;
}) {
return (
<div className={classes.compactStatGrid} style={{ "--compact-stat-cols": columns } as CSSProperties}>
{statKeys.map((statKey) => {
const severity = resolveStatColor(statKey, statValues[statKey], colorContext, options);
const Icon = statIcons[statKey];
const label = getLabel(statKey);
return (
<Tooltip key={statKey} label={label} withArrow>
<div
className={classes.compactStatTile}
style={{ "--stat-bg": severityToMantineBgColor(severity) } as CSSProperties}
>
{showIcon && (
<Icon className={classes.statIcon} size={18} stroke={1.5} color={severityToIconColor(severity)} />
)}
<span className={classes.compactStatValue}>
<Text component="span" fw={700} size="inherit" c={severityToMantineColor(severity)}>
{statValues[statKey]}
</Text>
</span>
<span className={classes.compactStatLabel}>{label}</span>
</div>
</Tooltip>
);
})}
</div>
);
}
+234
View File
@@ -0,0 +1,234 @@
import { IconServerOff, IconShieldCheck } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
import { PATCHMON_STAT_COLOR_PRESETS } from "./stat-colors";
import { patchmonOptionsSuperRefine } from "./threshold-validation";
export const { definition, componentLoader } = createWidgetDefinition("patchmon", {
icon: IconShieldCheck,
supportedIntegrations: ["patchmon"],
integrationsRequired: true,
createOptions() {
return optionsBuilder.from(
(factory) => ({
showComplianceHero: factory.switch({
defaultValue: true,
withDescription: true,
}),
showTotalHosts: factory.switch({
defaultValue: true,
withDescription: true,
}),
showHostsNeedingUpdates: factory.switch({
defaultValue: true,
withDescription: true,
}),
showSecurityUpdates: factory.switch({
defaultValue: true,
withDescription: true,
}),
showUpToDateHosts: factory.switch({
defaultValue: false,
withDescription: true,
}),
showHostsWithSecurityUpdates: factory.switch({
defaultValue: false,
withDescription: true,
}),
showRecentUpdates24h: factory.switch({
defaultValue: false,
withDescription: true,
}),
showTotalOutdatedPackages: factory.switch({
defaultValue: false,
withDescription: true,
}),
showTotalRepos: factory.switch({
defaultValue: false,
withDescription: true,
}),
enableThresholdColors: factory.switch({
defaultValue: true,
withDescription: true,
}),
useCustomThresholds: factory.switch({
defaultValue: false,
withDescription: true,
}),
hostsNeedingUpdatesThresholdMode: factory.select({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.hostsNeedingUpdates.mode,
withDescription: true,
options: [
{ value: "absolute", label: (t) => t("widget.patchmon.option.thresholdMode.option.absolute") },
{ value: "percent", label: (t) => t("widget.patchmon.option.thresholdMode.option.percent") },
] as const,
}),
hostsNeedingUpdatesWarningAt: factory.number({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.hostsNeedingUpdates.warningAt,
withDescription: true,
step: 1,
validate: z.number().int().min(0),
}),
hostsNeedingUpdatesCriticalAt: factory.number({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.hostsNeedingUpdates.criticalAt,
withDescription: true,
step: 1,
validate: z.number().int().min(0),
}),
securityUpdatesThresholdMode: factory.select({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.securityUpdates.mode,
withDescription: true,
options: [
{ value: "absolute", label: (t) => t("widget.patchmon.option.thresholdMode.option.absolute") },
{ value: "percent", label: (t) => t("widget.patchmon.option.thresholdMode.option.percent") },
] as const,
}),
securityUpdatesWarningAt: factory.number({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.securityUpdates.warningAt,
withDescription: true,
step: 1,
validate: z.number().int().min(0),
}),
securityUpdatesCriticalAt: factory.number({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.securityUpdates.criticalAt,
withDescription: true,
step: 1,
validate: z.number().int().min(0),
}),
hostsWithSecurityUpdatesThresholdMode: factory.select({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.hostsWithSecurityUpdates.mode,
withDescription: true,
options: [
{ value: "absolute", label: (t) => t("widget.patchmon.option.thresholdMode.option.absolute") },
{ value: "percent", label: (t) => t("widget.patchmon.option.thresholdMode.option.percent") },
] as const,
}),
hostsWithSecurityUpdatesWarningAt: factory.number({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.hostsWithSecurityUpdates.warningAt,
withDescription: true,
step: 1,
validate: z.number().int().min(0),
}),
hostsWithSecurityUpdatesCriticalAt: factory.number({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.hostsWithSecurityUpdates.criticalAt,
withDescription: true,
step: 1,
validate: z.number().int().min(0),
}),
upToDateHostsThresholdMode: factory.select({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.upToDateHosts.mode,
withDescription: true,
options: [
{ value: "absolute", label: (t) => t("widget.patchmon.option.thresholdMode.option.absolute") },
{ value: "percent", label: (t) => t("widget.patchmon.option.thresholdMode.option.percent") },
] as const,
}),
upToDateHostsWarningAt: factory.number({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.upToDateHosts.warningAt,
withDescription: true,
step: 1,
validate: z.number().int().min(0),
}),
upToDateHostsCriticalAt: factory.number({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.upToDateHosts.criticalAt,
withDescription: true,
step: 1,
validate: z.number().int().min(0),
}),
totalOutdatedPackagesThresholdMode: factory.select({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.totalOutdatedPackages.mode,
withDescription: true,
options: [
{ value: "absolute", label: (t) => t("widget.patchmon.option.thresholdMode.option.absolute") },
{ value: "percent", label: (t) => t("widget.patchmon.option.thresholdMode.option.percent") },
] as const,
}),
totalOutdatedPackagesWarningAt: factory.number({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.totalOutdatedPackages.warningAt,
withDescription: true,
step: 1,
validate: z.number().int().min(0),
}),
totalOutdatedPackagesCriticalAt: factory.number({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.totalOutdatedPackages.criticalAt,
withDescription: true,
step: 1,
validate: z.number().int().min(0),
}),
showOsDistribution: factory.switch({
defaultValue: false,
withDescription: true,
}),
osDisplayMode: factory.select({
defaultValue: "bars",
withDescription: true,
options: [
{ value: "bars", label: (t) => t("widget.patchmon.option.osDisplayMode.option.bars") },
{ value: "donut", label: (t) => t("widget.patchmon.option.osDisplayMode.option.donut") },
] as const,
}),
osDistributionLimit: factory.select({
defaultValue: "0",
withDescription: true,
options: [
{ value: "3", label: (t) => t("widget.patchmon.option.osDistributionLimit.option.3") },
{ value: "5", label: (t) => t("widget.patchmon.option.osDistributionLimit.option.5") },
{ value: "10", label: (t) => t("widget.patchmon.option.osDistributionLimit.option.10") },
{ value: "0", label: (t) => t("widget.patchmon.option.osDistributionLimit.option.all") },
] as const,
}),
showOsVersion: factory.switch({
defaultValue: true,
withDescription: true,
}),
}),
{
useCustomThresholds: {
shouldHide: (options) => !options.enableThresholdColors,
},
hostsNeedingUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
hostsNeedingUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
hostsNeedingUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
securityUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
securityUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
securityUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
hostsWithSecurityUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
hostsWithSecurityUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
hostsWithSecurityUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
upToDateHostsThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
upToDateHostsWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
upToDateHostsCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
totalOutdatedPackagesThresholdMode: {
shouldHide: (options) =>
!options.enableThresholdColors || !options.useCustomThresholds || !options.showTotalOutdatedPackages,
},
totalOutdatedPackagesWarningAt: {
shouldHide: (options) =>
!options.enableThresholdColors || !options.useCustomThresholds || !options.showTotalOutdatedPackages,
},
totalOutdatedPackagesCriticalAt: {
shouldHide: (options) =>
!options.enableThresholdColors || !options.useCustomThresholds || !options.showTotalOutdatedPackages,
},
osDisplayMode: {
shouldHide: (options) => !options.showOsDistribution,
},
osDistributionLimit: {
shouldHide: (options) => !options.showOsDistribution,
},
showOsVersion: {
shouldHide: (options) => !options.showOsDistribution,
},
},
patchmonOptionsSuperRefine,
);
},
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
message: (t) => t("widget.patchmon.error.internalServerError"),
},
},
}).withDynamicImport(() => import("./component"));
@@ -0,0 +1,15 @@
const gridColsByWidth = [
{ minWidth: 500, cols: 4 },
{ minWidth: 380, cols: 3 },
{ minWidth: 220, cols: 2 },
{ minWidth: 0, cols: 1 },
] as const;
export function getGridCols(width: number): number {
const match = gridColsByWidth.find(({ minWidth }) => width >= minWidth);
return match?.cols ?? 1;
}
export function shouldShowComplianceHeroText(width: number): boolean {
return getGridCols(width) >= 2;
}
@@ -0,0 +1,167 @@
"use client";
import type { CSSProperties } from "react";
import { DonutChart } from "@mantine/charts";
import { Group, Progress, ScrollArea, Stack, Text } from "@mantine/core";
import type { PatchMonOsDistributionEntry } from "@homarr/integrations";
import { useScopedI18n } from "@homarr/translation/client";
import classes from "./component.module.css";
const OS_COLORS = [
"blue.6",
"orange.6",
"green.6",
"grape.6",
"cyan.6",
"pink.6",
"yellow.6",
"teal.6",
] as const;
interface OsDistributionSectionProps {
entries: PatchMonOsDistributionEntry[];
limit: number;
showOsVersion: boolean;
displayMode: "bars" | "donut";
width: number;
height: number;
showLegend: boolean;
compact?: boolean;
}
export const formatOsLabel = (entry: PatchMonOsDistributionEntry, showVersion: boolean) =>
showVersion && entry.osVersion ? `${entry.name} ${entry.osVersion}` : entry.name;
const getMantineColorVariable = (color: string) => `var(--mantine-color-${color.replace(".", "-")})`;
export function OsDistributionSection({
entries,
limit,
showOsVersion,
displayMode,
width,
height,
showLegend,
compact = false,
}: OsDistributionSectionProps) {
const t = useScopedI18n("widget.patchmon");
if (entries.length === 0) {
return null;
}
const visibleEntries = limit > 0 ? entries.slice(0, limit) : entries;
if (displayMode === "donut") {
const chartData = visibleEntries.map((entry, i) => ({
name: formatOsLabel(entry, showOsVersion),
value: entry.count,
color: OS_COLORS[i % OS_COLORS.length] as string,
}));
const donutSize = compact
? Math.max(36, Math.min(width - 18, height - 10, 96))
: Math.max(48, Math.min(width - 24, height * 0.55, showLegend ? 140 : 180));
return (
<Stack gap={4} className={classes.osSection} h="100%">
{!compact && (
<Text size="xs" c="dimmed" fw={500} className={classes.osHeading}>
{t("osDistribution")}
</Text>
)}
<div className={`${classes.osDonutContent} ${showLegend ? classes.osDonutContentWithLegend : ""}`}>
<div className={classes.osDonut}>
<DonutChart
data={chartData}
size={donutSize}
withTooltip
tooltipDataSource="segment"
paddingAngle={2}
/>
</div>
{showLegend && (
<div className={classes.osLegend}>
{chartData.map((entry) => (
<div key={entry.name} className={classes.osLegendItem}>
<span
className={classes.osLegendSwatch}
style={{ "--os-color": getMantineColorVariable(entry.color) } as CSSProperties}
/>
<span className={classes.osLegendLabel} title={entry.name}>
{entry.name}
</span>
<Text component="span" size="xs" fw={600} className={classes.osLegendValue}>
{entry.value}
</Text>
</div>
))}
</div>
)}
</div>
</Stack>
);
}
const maxCount = Math.max(...visibleEntries.map((entry) => entry.count), 1);
if (compact) {
const maxRows = Math.max(2, Math.min(visibleEntries.length, Math.floor(height / 28), width < 160 ? 2 : 4));
const compactEntries = visibleEntries.slice(0, maxRows);
if (compactEntries.length === 0) return null;
return (
<div className={classes.compactOsBars}>
{compactEntries.map((entry, i) => (
<div key={`${entry.name}-${entry.osVersion ?? ""}`} className={classes.compactOsBar}>
<Text size="xs" className={classes.compactOsLabel} truncate="end" title={formatOsLabel(entry, showOsVersion)}>
{formatOsLabel(entry, showOsVersion)}
</Text>
<Group gap={6} wrap="nowrap" className={classes.osBarGroup}>
<Progress
value={(entry.count / maxCount) * 100}
size="sm"
radius="sm"
className={classes.osBar}
color={OS_COLORS[i % OS_COLORS.length]}
/>
<Text size="xs" fw={700} className={classes.osCount}>
{entry.count}
</Text>
</Group>
</div>
))}
</div>
);
}
return (
<Stack gap={4} className={classes.osSection} h="100%">
<Text size="xs" c="dimmed" fw={500} className={classes.osHeading}>
{t("osDistribution")}
</Text>
<ScrollArea className={classes.osScroll} scrollbars="y">
<Stack gap={6} pr={4}>
{visibleEntries.map((entry, i) => {
const barValue = (entry.count / maxCount) * 100;
const barColor = OS_COLORS[i % OS_COLORS.length] as string;
return (
<div key={`${entry.name}-${entry.osVersion ?? ""}`} className={classes.osRow}>
<Text size="xs" className={classes.osLabel} truncate="end" title={formatOsLabel(entry, showOsVersion)}>
{formatOsLabel(entry, showOsVersion)}
</Text>
<Group gap={6} wrap="nowrap" className={classes.osBarGroup}>
<Progress value={barValue} size="sm" radius="sm" className={classes.osBar} color={barColor} />
<Text size="xs" fw={600} className={classes.osCount}>
{entry.count}
</Text>
</Group>
</div>
);
})}
</Stack>
</ScrollArea>
</Stack>
);
}
@@ -0,0 +1,215 @@
import type { WidgetComponentProps } from "../definition";
import { clampPercentThreshold } from "./threshold-validation";
export type PatchMonStatKey =
| "totalHosts"
| "hostsNeedingUpdates"
| "securityUpdates"
| "upToDateHosts"
| "hostsWithSecurityUpdates"
| "recentUpdates24h"
| "totalOutdatedPackages"
| "totalRepos";
export type ColorablePatchMonStatKey = Extract<
PatchMonStatKey,
| "hostsNeedingUpdates"
| "securityUpdates"
| "hostsWithSecurityUpdates"
| "upToDateHosts"
| "totalOutdatedPackages"
>;
export type StatSeverity = "green" | "yellow" | "red" | "neutral";
export type ThresholdMode = "absolute" | "percent";
export interface StatColorContext {
totalHosts: number;
}
export interface StatThresholdRule {
direction: "lowerIsBetter" | "higherIsBetter";
mode: ThresholdMode;
warningAt: number;
criticalAt: number;
skipYellow?: boolean;
}
type PatchMonColorOptions = WidgetComponentProps<"patchmon">["options"];
export const COLORABLE_PATCHMON_STATS = [
"hostsNeedingUpdates",
"securityUpdates",
"hostsWithSecurityUpdates",
"upToDateHosts",
"totalOutdatedPackages",
] as const satisfies readonly ColorablePatchMonStatKey[];
export const NEUTRAL_PATCHMON_STATS = ["totalHosts", "totalRepos", "recentUpdates24h"] as const;
export const PATCHMON_STAT_COLOR_PRESETS: Record<ColorablePatchMonStatKey, StatThresholdRule> = {
hostsNeedingUpdates: {
direction: "lowerIsBetter",
mode: "absolute",
warningAt: 1,
criticalAt: 5,
},
securityUpdates: {
direction: "lowerIsBetter",
mode: "absolute",
warningAt: 1,
criticalAt: 10,
},
hostsWithSecurityUpdates: {
direction: "lowerIsBetter",
mode: "absolute",
warningAt: 1,
criticalAt: 1,
skipYellow: true,
},
upToDateHosts: {
direction: "higherIsBetter",
mode: "percent",
warningAt: 90,
criticalAt: 90,
},
totalOutdatedPackages: {
direction: "lowerIsBetter",
mode: "absolute",
warningAt: 1,
criticalAt: 50,
},
};
const thresholdModeOptionKey = {
hostsNeedingUpdates: "hostsNeedingUpdatesThresholdMode",
securityUpdates: "securityUpdatesThresholdMode",
hostsWithSecurityUpdates: "hostsWithSecurityUpdatesThresholdMode",
upToDateHosts: "upToDateHostsThresholdMode",
totalOutdatedPackages: "totalOutdatedPackagesThresholdMode",
} as const;
const warningAtOptionKey = {
hostsNeedingUpdates: "hostsNeedingUpdatesWarningAt",
securityUpdates: "securityUpdatesWarningAt",
hostsWithSecurityUpdates: "hostsWithSecurityUpdatesWarningAt",
upToDateHosts: "upToDateHostsWarningAt",
totalOutdatedPackages: "totalOutdatedPackagesWarningAt",
} as const;
const criticalAtOptionKey = {
hostsNeedingUpdates: "hostsNeedingUpdatesCriticalAt",
securityUpdates: "securityUpdatesCriticalAt",
hostsWithSecurityUpdates: "hostsWithSecurityUpdatesCriticalAt",
upToDateHosts: "upToDateHostsCriticalAt",
totalOutdatedPackages: "totalOutdatedPackagesCriticalAt",
} as const;
const isColorableStat = (statKey: PatchMonStatKey): statKey is ColorablePatchMonStatKey =>
COLORABLE_PATCHMON_STATS.includes(statKey as ColorablePatchMonStatKey);
const getComparableValue = (
value: number,
mode: ThresholdMode,
context: StatColorContext,
direction: StatThresholdRule["direction"],
): number => {
if (mode === "absolute") {
return value;
}
if (context.totalHosts <= 0) {
if (direction === "higherIsBetter") {
return 100;
}
return value > 0 ? 100 : 0;
}
return (value / context.totalHosts) * 100;
};
const resolveLowerIsBetter = (comparable: number, rule: StatThresholdRule): StatSeverity => {
if (comparable < rule.warningAt) {
return "green";
}
if (rule.skipYellow || comparable >= rule.criticalAt) {
return "red";
}
return "yellow";
};
const resolveHigherIsBetter = (
value: number,
comparable: number,
context: StatColorContext,
rule: StatThresholdRule,
): StatSeverity => {
if (context.totalHosts > 0 && value >= context.totalHosts) {
return "green";
}
if (comparable >= rule.warningAt) {
return "green";
}
if (rule.skipYellow || comparable < rule.criticalAt) {
return "red";
}
return "yellow";
};
const getThresholdRule = (statKey: ColorablePatchMonStatKey, options: PatchMonColorOptions): StatThresholdRule => {
if (!options.useCustomThresholds) {
return PATCHMON_STAT_COLOR_PRESETS[statKey];
}
const mode = options[thresholdModeOptionKey[statKey]] as ThresholdMode;
const warningAt = clampPercentThreshold(options[warningAtOptionKey[statKey]] as number, mode);
const criticalAt = clampPercentThreshold(options[criticalAtOptionKey[statKey]] as number, mode);
const preset = PATCHMON_STAT_COLOR_PRESETS[statKey];
return {
direction: preset.direction,
mode,
warningAt,
criticalAt,
skipYellow: preset.skipYellow && warningAt === criticalAt,
};
};
export const resolveStatColor = (
statKey: PatchMonStatKey,
value: number,
context: StatColorContext,
options: PatchMonColorOptions,
): StatSeverity => {
if (!options.enableThresholdColors || !isColorableStat(statKey)) {
return "neutral";
}
const rule = getThresholdRule(statKey, options);
const comparable = getComparableValue(value, rule.mode, context, rule.direction);
if (rule.direction === "higherIsBetter") {
return resolveHigherIsBetter(value, comparable, context, rule);
}
return resolveLowerIsBetter(comparable, rule);
};
export const severityToMantineColor = (severity: StatSeverity): string =>
severity === "neutral" ? "var(--mantine-primary-color-filled)" : severity;
export const severityToMantineBgColor = (severity: StatSeverity): string => {
if (severity === "neutral") return "light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6))";
return `var(--mantine-color-${severity}-light)`;
};
export const severityToIconColor = (severity: StatSeverity): string => {
if (severity === "neutral") return "var(--mantine-color-dimmed)";
return `var(--mantine-color-${severity}-6)`;
};
@@ -0,0 +1,36 @@
import { describe, expect, test } from "vitest";
import { getGridCols, shouldShowComplianceHeroText } from "../layout-utils";
describe("getGridCols", () => {
test("returns 1 column for narrow widths", () => {
expect(getGridCols(128)).toBe(1);
expect(getGridCols(219)).toBe(1);
});
test("returns 2 columns at 220px breakpoint", () => {
expect(getGridCols(220)).toBe(2);
expect(getGridCols(256)).toBe(2);
});
test("returns 3 columns at 380px breakpoint", () => {
expect(getGridCols(380)).toBe(3);
});
test("returns 4 columns at 500px breakpoint", () => {
expect(getGridCols(500)).toBe(4);
});
});
describe("shouldShowComplianceHeroText", () => {
test("returns false for 1-column-wide layouts", () => {
expect(shouldShowComplianceHeroText(128)).toBe(false);
expect(shouldShowComplianceHeroText(219)).toBe(false);
});
test("returns true for 2+-column-wide layouts", () => {
expect(shouldShowComplianceHeroText(220)).toBe(true);
expect(shouldShowComplianceHeroText(256)).toBe(true);
expect(shouldShowComplianceHeroText(384)).toBe(true);
});
});
@@ -0,0 +1,171 @@
import { describe, expect, test } from "vitest";
import type { WidgetComponentProps } from "../../definition";
import { resolveStatColor } from "../stat-colors";
const defaultOptions = {
enableThresholdColors: true,
useCustomThresholds: false,
showTotalHosts: true,
showHostsNeedingUpdates: true,
showSecurityUpdates: true,
showUpToDateHosts: false,
showHostsWithSecurityUpdates: false,
showRecentUpdates24h: false,
showTotalOutdatedPackages: false,
showTotalRepos: false,
showOsDistribution: false,
osDistributionLimit: "0",
showOsVersion: true,
hostsNeedingUpdatesThresholdMode: "absolute",
hostsNeedingUpdatesWarningAt: 1,
hostsNeedingUpdatesCriticalAt: 5,
securityUpdatesThresholdMode: "absolute",
securityUpdatesWarningAt: 1,
securityUpdatesCriticalAt: 10,
hostsWithSecurityUpdatesThresholdMode: "absolute",
hostsWithSecurityUpdatesWarningAt: 1,
hostsWithSecurityUpdatesCriticalAt: 1,
upToDateHostsThresholdMode: "percent",
upToDateHostsWarningAt: 90,
upToDateHostsCriticalAt: 90,
totalOutdatedPackagesThresholdMode: "absolute",
totalOutdatedPackagesWarningAt: 1,
totalOutdatedPackagesCriticalAt: 50,
showComplianceHero: true,
osDisplayMode: "bars",
} satisfies WidgetComponentProps<"patchmon">["options"];
const context = { totalHosts: 100 };
describe("resolveStatColor presets", () => {
test("hostsNeedingUpdates uses green, yellow, and red boundaries", () => {
expect(resolveStatColor("hostsNeedingUpdates", 0, context, defaultOptions)).toBe("green");
expect(resolveStatColor("hostsNeedingUpdates", 1, context, defaultOptions)).toBe("yellow");
expect(resolveStatColor("hostsNeedingUpdates", 4, context, defaultOptions)).toBe("yellow");
expect(resolveStatColor("hostsNeedingUpdates", 5, context, defaultOptions)).toBe("red");
});
test("securityUpdates uses green and yellow for low counts", () => {
expect(resolveStatColor("securityUpdates", 0, context, defaultOptions)).toBe("green");
expect(resolveStatColor("securityUpdates", 1, context, defaultOptions)).toBe("yellow");
expect(resolveStatColor("securityUpdates", 10, context, defaultOptions)).toBe("red");
});
test("hostsWithSecurityUpdates skips yellow", () => {
expect(resolveStatColor("hostsWithSecurityUpdates", 0, context, defaultOptions)).toBe("green");
expect(resolveStatColor("hostsWithSecurityUpdates", 1, context, defaultOptions)).toBe("red");
});
test("upToDateHosts uses percent of total hosts", () => {
expect(resolveStatColor("upToDateHosts", 100, context, defaultOptions)).toBe("green");
expect(resolveStatColor("upToDateHosts", 95, context, defaultOptions)).toBe("green");
expect(resolveStatColor("upToDateHosts", 90, context, defaultOptions)).toBe("green");
expect(resolveStatColor("upToDateHosts", 80, context, defaultOptions)).toBe("red");
});
test("neutral stats ignore threshold coloring", () => {
expect(resolveStatColor("totalHosts", 100, context, defaultOptions)).toBe("neutral");
expect(resolveStatColor("totalRepos", 12, context, defaultOptions)).toBe("neutral");
expect(resolveStatColor("recentUpdates24h", 34, context, defaultOptions)).toBe("neutral");
});
});
describe("resolveStatColor options", () => {
test("returns neutral when threshold colors are disabled", () => {
expect(
resolveStatColor("hostsNeedingUpdates", 10, context, {
...defaultOptions,
enableThresholdColors: false,
}),
).toBe("neutral");
});
test("supports custom absolute thresholds", () => {
expect(
resolveStatColor("hostsNeedingUpdates", 4, context, {
...defaultOptions,
useCustomThresholds: true,
hostsNeedingUpdatesWarningAt: 5,
hostsNeedingUpdatesCriticalAt: 10,
}),
).toBe("green");
});
test("supports custom percent thresholds for lower-is-better stats", () => {
expect(
resolveStatColor("hostsNeedingUpdates", 5, context, {
...defaultOptions,
useCustomThresholds: true,
hostsNeedingUpdatesThresholdMode: "percent",
hostsNeedingUpdatesWarningAt: 10,
hostsNeedingUpdatesCriticalAt: 20,
}),
).toBe("green");
expect(
resolveStatColor("hostsNeedingUpdates", 15, context, {
...defaultOptions,
useCustomThresholds: true,
hostsNeedingUpdatesThresholdMode: "percent",
hostsNeedingUpdatesWarningAt: 10,
hostsNeedingUpdatesCriticalAt: 20,
}),
).toBe("yellow");
});
test("handles zero total hosts for percent-based stats", () => {
expect(resolveStatColor("upToDateHosts", 0, { totalHosts: 0 }, defaultOptions)).toBe("green");
expect(
resolveStatColor("hostsNeedingUpdates", 1, { totalHosts: 0 }, {
...defaultOptions,
useCustomThresholds: true,
hostsNeedingUpdatesThresholdMode: "percent",
}),
).toBe("red");
});
test("clamps invalid percent thresholds when resolving colors", () => {
expect(
resolveStatColor("hostsNeedingUpdates", 50, context, {
...defaultOptions,
useCustomThresholds: true,
hostsNeedingUpdatesThresholdMode: "percent",
hostsNeedingUpdatesWarningAt: 40,
hostsNeedingUpdatesCriticalAt: 200,
}),
).toBe("yellow");
});
test("supports custom absolute thresholds for higher-is-better stats", () => {
expect(
resolveStatColor("upToDateHosts", 85, context, {
...defaultOptions,
useCustomThresholds: true,
upToDateHostsThresholdMode: "absolute",
upToDateHostsWarningAt: 80,
upToDateHostsCriticalAt: 60,
}),
).toBe("green");
expect(
resolveStatColor("upToDateHosts", 70, context, {
...defaultOptions,
useCustomThresholds: true,
upToDateHostsThresholdMode: "absolute",
upToDateHostsWarningAt: 80,
upToDateHostsCriticalAt: 60,
}),
).toBe("yellow");
expect(
resolveStatColor("upToDateHosts", 50, context, {
...defaultOptions,
useCustomThresholds: true,
upToDateHostsThresholdMode: "absolute",
upToDateHostsWarningAt: 80,
upToDateHostsCriticalAt: 60,
}),
).toBe("red");
});
});
@@ -0,0 +1,97 @@
import { describe, expect, test } from "vitest";
import { z } from "zod/v4";
import { clampPercentThreshold, patchmonOptionsSuperRefine } from "../threshold-validation";
const baseOptions = {
enableThresholdColors: true,
useCustomThresholds: true,
hostsNeedingUpdatesThresholdMode: "percent",
hostsNeedingUpdatesWarningAt: 10,
hostsNeedingUpdatesCriticalAt: 20,
securityUpdatesThresholdMode: "absolute",
securityUpdatesWarningAt: 1,
securityUpdatesCriticalAt: 10,
hostsWithSecurityUpdatesThresholdMode: "absolute",
hostsWithSecurityUpdatesWarningAt: 1,
hostsWithSecurityUpdatesCriticalAt: 1,
upToDateHostsThresholdMode: "percent",
upToDateHostsWarningAt: 90,
upToDateHostsCriticalAt: 90,
totalOutdatedPackagesThresholdMode: "absolute",
totalOutdatedPackagesWarningAt: 1,
totalOutdatedPackagesCriticalAt: 50,
};
const optionsSchema = z.object({
hostsNeedingUpdatesWarningAt: z.number().int().min(0),
hostsNeedingUpdatesCriticalAt: z.number().int().min(0),
securityUpdatesWarningAt: z.number().int().min(0),
securityUpdatesCriticalAt: z.number().int().min(0),
hostsWithSecurityUpdatesWarningAt: z.number().int().min(0),
hostsWithSecurityUpdatesCriticalAt: z.number().int().min(0),
upToDateHostsWarningAt: z.number().int().min(0),
upToDateHostsCriticalAt: z.number().int().min(0),
totalOutdatedPackagesWarningAt: z.number().int().min(0),
totalOutdatedPackagesCriticalAt: z.number().int().min(0),
enableThresholdColors: z.boolean(),
useCustomThresholds: z.boolean(),
hostsNeedingUpdatesThresholdMode: z.enum(["absolute", "percent"]),
securityUpdatesThresholdMode: z.enum(["absolute", "percent"]),
hostsWithSecurityUpdatesThresholdMode: z.enum(["absolute", "percent"]),
upToDateHostsThresholdMode: z.enum(["absolute", "percent"]),
totalOutdatedPackagesThresholdMode: z.enum(["absolute", "percent"]),
}).superRefine(patchmonOptionsSuperRefine);
describe("patchmonOptionsSuperRefine", () => {
test("accepts percent thresholds within 0-100", () => {
const result = optionsSchema.safeParse(baseOptions);
expect(result.success).toBe(true);
});
test("rejects percent thresholds above 100", () => {
const result = optionsSchema.safeParse({
...baseOptions,
hostsNeedingUpdatesWarningAt: 101,
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue) => issue.path.includes("hostsNeedingUpdatesWarningAt"))).toBe(true);
}
});
test("allows absolute thresholds above 100", () => {
const result = optionsSchema.safeParse({
...baseOptions,
hostsNeedingUpdatesThresholdMode: "absolute",
hostsNeedingUpdatesWarningAt: 150,
hostsNeedingUpdatesCriticalAt: 200,
});
expect(result.success).toBe(true);
});
test("skips validation when custom thresholds are disabled", () => {
const result = optionsSchema.safeParse({
...baseOptions,
useCustomThresholds: false,
hostsNeedingUpdatesWarningAt: 500,
});
expect(result.success).toBe(true);
});
});
describe("clampPercentThreshold", () => {
test("clamps percent values to 0-100", () => {
expect(clampPercentThreshold(150, "percent")).toBe(100);
expect(clampPercentThreshold(-5, "percent")).toBe(0);
expect(clampPercentThreshold(42.9, "percent")).toBe(42);
});
test("leaves absolute values unchanged", () => {
expect(clampPercentThreshold(150, "absolute")).toBe(150);
});
});
@@ -0,0 +1,59 @@
import type { RefinementCtx } from "zod/v4";
import { z } from "zod/v4";
import type { ThresholdMode } from "./stat-colors";
const percentThresholdSchema = z.number().int().min(0).max(100);
export const PATCHMON_THRESHOLD_PAIRS = [
{
mode: "hostsNeedingUpdatesThresholdMode",
warningAt: "hostsNeedingUpdatesWarningAt",
criticalAt: "hostsNeedingUpdatesCriticalAt",
},
{
mode: "securityUpdatesThresholdMode",
warningAt: "securityUpdatesWarningAt",
criticalAt: "securityUpdatesCriticalAt",
},
{
mode: "hostsWithSecurityUpdatesThresholdMode",
warningAt: "hostsWithSecurityUpdatesWarningAt",
criticalAt: "hostsWithSecurityUpdatesCriticalAt",
},
{
mode: "upToDateHostsThresholdMode",
warningAt: "upToDateHostsWarningAt",
criticalAt: "upToDateHostsCriticalAt",
},
{
mode: "totalOutdatedPackagesThresholdMode",
warningAt: "totalOutdatedPackagesWarningAt",
criticalAt: "totalOutdatedPackagesCriticalAt",
},
] as const;
export const clampPercentThreshold = (value: number, mode: ThresholdMode): number =>
mode === "percent" ? Math.min(100, Math.max(0, Math.trunc(value))) : value;
export const patchmonOptionsSuperRefine = (data: Record<string, unknown>, ctx: RefinementCtx) => {
if (!data.enableThresholdColors || !data.useCustomThresholds) {
return;
}
for (const { mode, warningAt, criticalAt } of PATCHMON_THRESHOLD_PAIRS) {
if (data[mode] !== "percent") {
continue;
}
for (const key of [warningAt, criticalAt] as const) {
const result = percentThresholdSchema.safeParse(data[key]);
if (!result.success) {
for (const issue of result.error.issues) {
ctx.addIssue({ ...issue, path: [key] });
}
}
}
}
};
+1 -1
View File
@@ -137,7 +137,7 @@ function ProtocolCard({ protocol, data }: { protocol: ProtocolKey; data: Traefik
return (
<div className={classes.protocolCard}>
<Text className={classes.protocolTitle}>{t(`protocol.${protocol}`)}</Text>
<Text className={classes.protocolTitle}>{protocol.toUpperCase()}</Text>
<div className={classes.protocolRows}>
{rows.map(({ key, icon: Icon, summary }) => (
<div key={key} className={classes.protocolRow}>