Compare commits

..
Author SHA1 Message Date
ajnart af1ae122ba style: auto-fix lint and format 2026-07-10 15:54:51 +00:00
ajnart d33ed20149 fix(releases): bind token to saved provider:identifier match
The token must only be used for repositories that are registered in the
widget's saved options, matched by provider:identifier (not by fragile
cuid2 id). This prevents tokens from being sent to arbitrary providerUrl
values submitted by the caller.

The saved providerUrl is used in preference to the request's providerUrl
when a token is being applied.
2026-07-10 17:53:26 +02:00
ajnart 173881f468 style: auto-fix lint and format 2026-07-10 15:39:45 +00:00
ajnart e48402ac4e fix(releases): remove broken allowedRepoIds token gate
The token was only applied if the repository's id matched the saved
widget options, but the token is already scoped to the widget's itemId
and provider kind. This check silently failed when saved options lacked
ids (old data) or when ids mismatched, causing all GitHub API calls to
be unauthenticated and hit the 60 req/hr rate limit.

Changes:
- Remove allowedRepoIds check in releases.ts — use token by provider
- Remove unused SuperJSON import
- Enable Octokit throttling for authenticated requests
2026-07-10 17:38:15 +02:00
55 changed files with 71 additions and 3048 deletions

No files matched your search

-1
View File
@@ -33,7 +33,6 @@ 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,5 +1,3 @@
## [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,13 +135,6 @@ 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.21.0](https://img.shields.io/badge/Version-8.21.0-informational?style=flat)
![Version: 8.20.0](https://img.shields.io/badge/Version-8.20.0-informational?style=flat)
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat)
![AppVersion: v1.69.2](https://img.shields.io/badge/AppVersion-v1.69.2-informational?style=flat)
![AppVersion: v1.68.0](https://img.shields.io/badge/AppVersion-v1.68.0-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.69.2"` | Overrides the image tag whose default is the chart appVersion |
| image.tag | string | `"v1.60.0"` | 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 |
@@ -1,43 +0,0 @@
---
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',
]
}]} />
@@ -1,9 +0,0 @@
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,7 +141,6 @@ 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 |
-89
View File
@@ -1,89 +0,0 @@
---
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
@@ -1,200 +0,0 @@
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,16 +47,6 @@ 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",
@@ -26,8 +26,6 @@ export const integrationSecretIcons = {
url: IconLink,
opnsenseApiKey: IconKey,
opnsenseApiSecret: IconPassword,
patchmonApiKey: IconKey,
patchmonApiSecret: IconPassword,
githubAppId: IconCode,
githubInstallationId: IconPlug,
privateKey: IconKey,
@@ -105,7 +105,7 @@ export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProp
content: (
<TourStepContent
description={t("welcome.description")}
documentationHref={createDocumentationLink("/docs/management/boards")}
documentationHref={createDocumentationLink("/docs/category/management")}
/>
),
},
+26 -40
View File
@@ -95,27 +95,20 @@
</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></tr>
<tr><td align="center">
</td>
<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>
<td align="center">
</td></tr>
<tr><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/>
@@ -156,15 +149,15 @@
<br/>
<p align="center">Gotify</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<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>
<td align="center">
</td></tr>
<tr><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/>
@@ -205,15 +198,15 @@
<br/>
<p align="center">Navidrome</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<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>
<td align="center">
</td></tr>
<tr><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/>
@@ -254,13 +247,6 @@
<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">
@@ -268,8 +254,8 @@
<br/>
<p align="center">PeaNUT</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><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/>
@@ -303,8 +289,8 @@
<br/>
<p align="center">qBittorrent</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<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/>
@@ -317,8 +303,8 @@
<br/>
<p align="center">Readarr</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><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/>
@@ -352,8 +338,8 @@
<br/>
<p align="center">Speedtest<br/>Tracker</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<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/>
@@ -366,8 +352,8 @@
<br/>
<p align="center">Tdarr</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><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/>
@@ -401,8 +387,8 @@
<br/>
<p align="center">TrueNAS</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<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/>
@@ -415,8 +401,8 @@
<br/>
<p align="center">Unifi<br/>Controller</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><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/>
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.70.0",
"version": "1.69.2",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -43,6 +43,7 @@
"@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,7 +18,6 @@ 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";
@@ -40,7 +39,6 @@ export const mcpRouter = createTRPCRouter({
healthMonitoring: healthMonitoringRouter,
mediaRequests: mediaRequestsRouter,
mediaServer: mediaServerRouter,
patchmon: patchmonRouter,
smartHome: smartHomeRouter,
widgetSecrets: widgetSecretsRouter,
});
@@ -1,200 +0,0 @@
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",
});
});
});
-1
View File
@@ -9,7 +9,6 @@ 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.getDataAsync();
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integrationId: integration.id,
...data,
@@ -1,21 +0,0 @@
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;
}),
});
@@ -295,13 +295,6 @@
"when": 1780764500000,
"tag": "0041_merge_custom_widget_url",
"breakpoints": true
},
{
"idx": 42,
"version": "5",
"when": 1783176100870,
"tag": "0042_add_widget_secret",
"breakpoints": true
}
]
}
@@ -57,13 +57,6 @@
"when": 1780764500000,
"tag": "0007_merge_custom_widget_url",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1783176100870,
"tag": "0008_add_widget_secret",
"breakpoints": true
}
]
}
@@ -281,13 +281,6 @@
"when": 1780764500000,
"tag": "0039_merge_custom_widget_url",
"breakpoints": true
},
{
"idx": 40,
"version": "6",
"when": 1783176100870,
"tag": "0040_add_widget_secret",
"breakpoints": true
}
]
}
@@ -31,7 +31,6 @@ 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"],
@@ -318,7 +318,6 @@ 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"
@@ -367,7 +366,6 @@ 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,7 +42,6 @@ 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,7 +39,6 @@ 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,8 +13,6 @@ 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 },
@@ -372,15 +370,6 @@ export const integrationDefs = {
documentationUrl: null,
defaultPort: 8000,
},
patchmon: {
name: "PatchMon",
secretKinds: [["patchmonApiKey", "patchmonApiSecret"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/patchmon.svg",
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/patchmon"),
defaultPort: 3413,
apiKeySettingsPath: "/settings/api-keys",
},
tracearr: {
name: "Tracearr",
secretKinds: [["apiKey"]],
@@ -24,7 +24,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
networkControllerSummary: getIntegrationKindsByCategory("networkController"),
networkControllerStatus: getIntegrationKindsByCategory("networkController"),
indexerManager: getIntegrationKindsByCategory("indexerManager"),
healthMonitoring: getIntegrationKindsByCategory("healthMonitoring").filter((kind) => kind !== "patchmon"),
healthMonitoring: getIntegrationKindsByCategory("healthMonitoring"),
firewall: getIntegrationKindsByCategory("firewall"),
notifications: getIntegrationKindsByCategory("notifications"),
mediaReleases: ["emby", "jellyfin", "plex"],
@@ -34,7 +34,6 @@ 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,7 +37,6 @@ export const widgetKinds = [
"immich-serverStats",
"immich-albumCarousel",
"paperlessNgx",
"patchmon",
"bazarr",
"tracearr",
"speedtestTracker",
@@ -60,7 +59,6 @@ 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,7 +37,6 @@ 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";
@@ -121,7 +120,6 @@ export const integrationCreators = {
glances: GlancesIntegration,
immich: ImmichIntegration,
paperlessNgx: PaperlessNgxIntegration,
patchmon: PatchMonIntegration,
speedtestTracker: SpeedtestTrackerIntegration,
audiobookshelf: AudiobookshelfIntegration,
navidrome: NavidromeIntegration,
-1
View File
@@ -70,7 +70,6 @@ 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,
@@ -1,58 +0,0 @@
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}` };
}
}
@@ -1,88 +0,0 @@
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),
});
@@ -1,167 +0,0 @@
// @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,7 +6,6 @@ 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,
@@ -24,6 +23,8 @@ import {
synologyUpgradeCheckDataSchema,
synologyUpgradeStatusDataSchema,
synologyUtilizationDataSchema,
type SynologyDiskRecord,
type SynologyVolumeRecord,
} from "./synology-types";
const logger = createLogger({ module: "synologyClient" });
@@ -157,7 +158,13 @@ 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,7 +2,6 @@
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { z } from "zod";
vi.hoisted(() => {
process.env.CI = "true";
@@ -38,7 +37,6 @@ 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);
@@ -204,8 +202,8 @@ const mockSuccessfulFlow = (options?: {
success: boolean;
data: Record<string, unknown>;
};
utilization?: { success: boolean; data: z.input<typeof synologyUtilizationDataSchema> };
storageV2?: { success: boolean; data: z.input<typeof synologyStorageV2DataSchema> };
utilization?: typeof utilizationResponse;
storageV2?: typeof storageV2Response;
storageLoadInfo?: typeof storageLoadInfoResponse;
}) => {
mockFetch.mockImplementation((url) => {
@@ -243,9 +241,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") {
@@ -6,7 +6,7 @@ import { createIntegrationRequestHandler } from "./lib/integration-request-handl
export const systemInfoRequestHandler = createIntegrationRequestHandler<
SystemHealthMonitoring,
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify" | "beszel" | "patchmon">,
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify" | "beszel">,
Record<string, never>
>({
async requestAsync(integration, _input) {
@@ -1,8 +1,10 @@
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 { createIntegrationRequestHandler } from "./lib/integration-request-handler";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
interface MediaOrganizerData {
missing: MissingMediaItem[];
@@ -11,7 +13,7 @@ interface MediaOrganizerData {
queuedCount: number;
}
export const mediaOrganizerRequestHandler = createIntegrationRequestHandler<
export const mediaOrganizerRequestHandler = createCachedIntegrationRequestHandler<
MediaOrganizerData,
IntegrationKindByCategory<"mediaOrganizer">,
{ pageSize: number }
@@ -29,5 +31,6 @@ export const mediaOrganizerRequestHandler = createIntegrationRequestHandler<
queuedCount: queueResult.totalCount,
};
},
cacheTtlMs: 60_000,
cacheDuration: dayjs.duration(1, "minute"),
queryKey: "mediaOrganizer",
});
-15
View File
@@ -1,15 +0,0 @@
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();
},
});
-170
View File
@@ -1285,14 +1285,6 @@
"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"
@@ -1344,10 +1336,6 @@
"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"
@@ -2774,164 +2762,6 @@
"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",
@@ -111,7 +111,7 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon
},
);
},
supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring").filter((kind) => kind !== "patchmon"),
supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring"),
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
-2
View File
@@ -39,7 +39,6 @@ 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";
@@ -115,7 +114,6 @@ export const widgetImports = {
"immich-serverStats": immichServerStats,
"immich-albumCarousel": immichAlbumCarousel,
paperlessNgx,
patchmon,
tracearr,
speedtestTracker,
uptimeKuma,
@@ -19,7 +19,6 @@ 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";
@@ -55,29 +54,24 @@ 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: optionsSchema,
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>>,
),
),
integrationIds: z.array(z.string()),
advancedOptions: z.object({
customCssClasses: z.array(z.string()),
+2 -14
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 { RefinementCtx, ZodType } from "zod/v4";
import type { ZodType } from "zod/v4";
import type { IntegrationKind } from "@homarr/definitions";
@@ -257,12 +257,9 @@ 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);
@@ -274,18 +271,9 @@ const createOptions = <TOptions extends WidgetOptionsRecord>(
};
}
const result = obj as {
return 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;
@@ -1,371 +0,0 @@
.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
@@ -1,458 +0,0 @@
"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
@@ -1,234 +0,0 @@
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"));
@@ -1,15 +0,0 @@
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;
}
@@ -1,167 +0,0 @@
"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>
);
}
@@ -1,215 +0,0 @@
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)`;
};
@@ -1,36 +0,0 @@
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);
});
});
@@ -1,171 +0,0 @@
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");
});
});
@@ -1,97 +0,0 @@
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);
});
});
@@ -1,59 +0,0 @@
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}>{protocol.toUpperCase()}</Text>
<Text className={classes.protocolTitle}>{t(`protocol.${protocol}`)}</Text>
<div className={classes.protocolRows}>
{rows.map(({ key, icon: Icon, summary }) => (
<div key={key} className={classes.protocolRow}>