Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af1ae122ba | ||
|
|
d33ed20149 | ||
|
|
173881f468 | ||
|
|
e48402ac4e |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
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",
|
||||
};
|
||||
@@ -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 |
|
||||
|
||||
@@ -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} />
|
||||
@@ -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",
|
||||
|
||||
-2
@@ -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
@@ -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
@@ -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:",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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] });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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}>
|
||||
|
||||
Reference in new issue
Block a user