Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af1ae122ba | ||
|
|
d33ed20149 | ||
|
|
173881f468 | ||
|
|
e48402ac4e |
No files matched your search
@@ -33,7 +33,6 @@ body:
|
|||||||
options:
|
options:
|
||||||
# The below comment is used to insert a new version with on-release.yml
|
# The below comment is used to insert a new version with on-release.yml
|
||||||
#NEXT_VERSION#
|
#NEXT_VERSION#
|
||||||
- 1.70.0
|
|
||||||
- 1.69.2
|
- 1.69.2
|
||||||
- 1.69.0
|
- 1.69.0
|
||||||
- 1.68.0
|
- 1.68.0
|
||||||
|
|||||||
@@ -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)
|
## [1.69.2](https://github.com/homarr-labs/homarr/compare/v1.69.1...v1.69.2) (2026-07-06)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -135,13 +135,6 @@ packages/integrations/src/overseerr @manuel-rw
|
|||||||
apps/docs/docs/integrations/paperless-ngx @ajnart
|
apps/docs/docs/integrations/paperless-ngx @ajnart
|
||||||
packages/integrations/src/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
|
# PeaNUT - introduced in https://github.com/homarr-labs/homarr/pull/5862 by @afonsojramos
|
||||||
apps/docs/docs/integrations/peanut @afonsojramos
|
apps/docs/docs/integrations/peanut @afonsojramos
|
||||||
packages/integrations/src/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">
|
<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
|
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 |
|
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
|
||||||
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
|
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
|
||||||
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
|
| 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 |
|
| imagePullSecrets | list | `[]` | Secrets for Docker registry |
|
||||||
| ingress.annotations | object | `{}` | Ingress annotations |
|
| ingress.annotations | object | `{}` | Ingress annotations |
|
||||||
| ingress.enabled | bool | `false` | Enable ingress |
|
| 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 |
|
| `dnsHole` | DNS hole summary, toggle blocking | GET + POST |
|
||||||
| `downloads` | Active downloads, pause/resume/delete | GET + POST |
|
| `downloads` | Active downloads, pause/resume/delete | GET + POST |
|
||||||
| `healthMonitoring` | Resource usage stats | GET |
|
| `healthMonitoring` | Resource usage stats | GET |
|
||||||
| `patchmon` | Host patch and security update statistics | GET |
|
|
||||||
| `mediaRequests` | Approve/decline media requests | GET + POST |
|
| `mediaRequests` | Approve/decline media requests | GET + POST |
|
||||||
| `mediaServer` | Media server sessions and stats | GET |
|
| `mediaServer` | Media server sessions and stats | GET |
|
||||||
| `smartHome` | Execute automations, device states | GET + POST |
|
| `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.",
|
description: "The Secret part of the API Key for authentication.",
|
||||||
icon: IconPassword,
|
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 },
|
githubAppId: { name: "App ID", description: "The ID of the GitHub App", icon: IconCode },
|
||||||
githubInstallationId: {
|
githubInstallationId: {
|
||||||
name: "Installation ID",
|
name: "Installation ID",
|
||||||
|
|||||||
-2
@@ -26,8 +26,6 @@ export const integrationSecretIcons = {
|
|||||||
url: IconLink,
|
url: IconLink,
|
||||||
opnsenseApiKey: IconKey,
|
opnsenseApiKey: IconKey,
|
||||||
opnsenseApiSecret: IconPassword,
|
opnsenseApiSecret: IconPassword,
|
||||||
patchmonApiKey: IconKey,
|
|
||||||
patchmonApiSecret: IconPassword,
|
|
||||||
githubAppId: IconCode,
|
githubAppId: IconCode,
|
||||||
githubInstallationId: IconPlug,
|
githubInstallationId: IconPlug,
|
||||||
privateKey: IconKey,
|
privateKey: IconKey,
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProp
|
|||||||
content: (
|
content: (
|
||||||
<TourStepContent
|
<TourStepContent
|
||||||
description={t("welcome.description")}
|
description={t("welcome.description")}
|
||||||
documentationHref={createDocumentationLink("/docs/management/boards")}
|
documentationHref={createDocumentationLink("/docs/category/management")}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
+26
-40
@@ -95,27 +95,20 @@
|
|||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<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">
|
<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" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg" alt="Beszel" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
<p align="center">Beszel</p>
|
<p align="center">Beszel</p>
|
||||||
</a>
|
</a>
|
||||||
</td></tr>
|
</td>
|
||||||
<tr><td align="center">
|
<td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/coolify" target="_blank" rel="noreferrer noopener">
|
<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" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/coolify.svg" alt="Coolify" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
<p align="center">Coolify</p>
|
<p align="center">Coolify</p>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td></tr>
|
||||||
<td align="center">
|
<tr><td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/dash-dot" target="_blank" rel="noreferrer noopener">
|
<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" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/dashdot.png" alt="Dash." width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
@@ -156,15 +149,15 @@
|
|||||||
<br/>
|
<br/>
|
||||||
<p align="center">Gotify</p>
|
<p align="center">Gotify</p>
|
||||||
</a>
|
</a>
|
||||||
</td></tr>
|
</td>
|
||||||
<tr><td align="center">
|
<td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/home-assistant" target="_blank" rel="noreferrer noopener">
|
<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" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/home-assistant.svg" alt="Home Assistant" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
<p align="center">Home<br/>Assistant</p>
|
<p align="center">Home<br/>Assistant</p>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td></tr>
|
||||||
<td align="center">
|
<tr><td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/ical" target="_blank" rel="noreferrer noopener">
|
<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" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ical.svg" alt="iCal" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
@@ -205,15 +198,15 @@
|
|||||||
<br/>
|
<br/>
|
||||||
<p align="center">Navidrome</p>
|
<p align="center">Navidrome</p>
|
||||||
</a>
|
</a>
|
||||||
</td></tr>
|
</td>
|
||||||
<tr><td align="center">
|
<td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/nextcloud" target="_blank" rel="noreferrer noopener">
|
<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" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nextcloud.svg" alt="Nextcloud" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
<p align="center">Nextcloud</p>
|
<p align="center">Nextcloud</p>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td></tr>
|
||||||
<td align="center">
|
<tr><td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/ntfy" target="_blank" rel="noreferrer noopener">
|
<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" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ntfy.svg" alt="ntfy" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
@@ -254,13 +247,6 @@
|
|||||||
<br/>
|
<br/>
|
||||||
<p align="center">Paperless-ngx</p>
|
<p align="center">Paperless-ngx</p>
|
||||||
</a>
|
</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>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="null" target="_blank" rel="noreferrer noopener">
|
<a href="null" target="_blank" rel="noreferrer noopener">
|
||||||
@@ -268,8 +254,8 @@
|
|||||||
<br/>
|
<br/>
|
||||||
<p align="center">PeaNUT</p>
|
<p align="center">PeaNUT</p>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td></tr>
|
||||||
<td align="center">
|
<tr><td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/pi-hole" target="_blank" rel="noreferrer noopener">
|
<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" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg" alt="Pi-hole" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
@@ -303,8 +289,8 @@
|
|||||||
<br/>
|
<br/>
|
||||||
<p align="center">qBittorrent</p>
|
<p align="center">qBittorrent</p>
|
||||||
</a>
|
</a>
|
||||||
</td></tr>
|
</td>
|
||||||
<tr><td align="center">
|
<td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/radarr" target="_blank" rel="noreferrer noopener">
|
<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" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/radarr.svg" alt="Radarr" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
@@ -317,8 +303,8 @@
|
|||||||
<br/>
|
<br/>
|
||||||
<p align="center">Readarr</p>
|
<p align="center">Readarr</p>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td></tr>
|
||||||
<td align="center">
|
<tr><td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/sabnzbd" target="_blank" rel="noreferrer noopener">
|
<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" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg" alt="SABnzbd" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
@@ -352,8 +338,8 @@
|
|||||||
<br/>
|
<br/>
|
||||||
<p align="center">Speedtest<br/>Tracker</p>
|
<p align="center">Speedtest<br/>Tracker</p>
|
||||||
</a>
|
</a>
|
||||||
</td></tr>
|
</td>
|
||||||
<tr><td align="center">
|
<td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/synology" target="_blank" rel="noreferrer noopener">
|
<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" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/synology.svg" alt="Synology DiskStation" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
@@ -366,8 +352,8 @@
|
|||||||
<br/>
|
<br/>
|
||||||
<p align="center">Tdarr</p>
|
<p align="center">Tdarr</p>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td></tr>
|
||||||
<td align="center">
|
<tr><td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/technitium-dns" target="_blank" rel="noreferrer noopener">
|
<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" />
|
<img src="https://cdn.jsdelivr.net/gh/selfhst/icons/svg/technitium.svg" alt="Technitium DNS" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
@@ -401,8 +387,8 @@
|
|||||||
<br/>
|
<br/>
|
||||||
<p align="center">TrueNAS</p>
|
<p align="center">TrueNAS</p>
|
||||||
</a>
|
</a>
|
||||||
</td></tr>
|
</td>
|
||||||
<tr><td align="center">
|
<td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/umami" target="_blank" rel="noreferrer noopener">
|
<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" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/umami.svg" alt="Umami" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
@@ -415,8 +401,8 @@
|
|||||||
<br/>
|
<br/>
|
||||||
<p align="center">Unifi<br/>Controller</p>
|
<p align="center">Unifi<br/>Controller</p>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td></tr>
|
||||||
<td align="center">
|
<tr><td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/unraid" target="_blank" rel="noreferrer noopener">
|
<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" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/unraid.svg" alt="Unraid" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homarr",
|
"name": "homarr",
|
||||||
"version": "1.70.0",
|
"version": "1.69.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "cross-env CI=true turbo build",
|
"build": "cross-env CI=true turbo build",
|
||||||
@@ -43,6 +43,7 @@
|
|||||||
"@semantic-release/release-notes-generator": "catalog:",
|
"@semantic-release/release-notes-generator": "catalog:",
|
||||||
"@testcontainers/redis": "catalog:",
|
"@testcontainers/redis": "catalog:",
|
||||||
"@turbo/gen": "catalog:",
|
"@turbo/gen": "catalog:",
|
||||||
|
|
||||||
"@vitejs/plugin-react": "catalog:",
|
"@vitejs/plugin-react": "catalog:",
|
||||||
"@vitest/coverage-v8": "catalog:",
|
"@vitest/coverage-v8": "catalog:",
|
||||||
"@vitest/ui": "catalog:",
|
"@vitest/ui": "catalog:",
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { downloadsRouter } from "./router/widgets/downloads";
|
|||||||
import { healthMonitoringRouter } from "./router/widgets/health-monitoring";
|
import { healthMonitoringRouter } from "./router/widgets/health-monitoring";
|
||||||
import { mediaRequestsRouter } from "./router/widgets/media-requests";
|
import { mediaRequestsRouter } from "./router/widgets/media-requests";
|
||||||
import { mediaServerRouter } from "./router/widgets/media-server";
|
import { mediaServerRouter } from "./router/widgets/media-server";
|
||||||
import { patchmonRouter } from "./router/widgets/patchmon";
|
|
||||||
import { smartHomeRouter } from "./router/widgets/smart-home";
|
import { smartHomeRouter } from "./router/widgets/smart-home";
|
||||||
import { widgetSecretsRouter } from "./router/widgets/widget-secrets";
|
import { widgetSecretsRouter } from "./router/widgets/widget-secrets";
|
||||||
|
|
||||||
@@ -40,7 +39,6 @@ export const mcpRouter = createTRPCRouter({
|
|||||||
healthMonitoring: healthMonitoringRouter,
|
healthMonitoring: healthMonitoringRouter,
|
||||||
mediaRequests: mediaRequestsRouter,
|
mediaRequests: mediaRequestsRouter,
|
||||||
mediaServer: mediaServerRouter,
|
mediaServer: mediaServerRouter,
|
||||||
patchmon: patchmonRouter,
|
|
||||||
smartHome: smartHomeRouter,
|
smartHome: smartHomeRouter,
|
||||||
widgetSecrets: widgetSecretsRouter,
|
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)),
|
coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)),
|
||||||
immich: lazy(() => import("./immich").then((mod) => mod.immichRouter)),
|
immich: lazy(() => import("./immich").then((mod) => mod.immichRouter)),
|
||||||
paperlessNgx: lazy(() => import("./paperless-ngx").then((mod) => mod.paperlessNgxRouter)),
|
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)),
|
bazarr: lazy(() => import("./bazarr").then((mod) => mod.bazarrRouter)),
|
||||||
notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)),
|
notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)),
|
||||||
weather: lazy(() => import("./weather").then((mod) => mod.weatherRouter)),
|
weather: lazy(() => import("./weather").then((mod) => mod.weatherRouter)),
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const mediaOrganizerRouter = createTRPCRouter({
|
|||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
ctx.integrations.map(async (integration) => {
|
ctx.integrations.map(async (integration) => {
|
||||||
const innerHandler = mediaOrganizerRequestHandler.handler(integration, { pageSize: input.pageSize });
|
const innerHandler = mediaOrganizerRequestHandler.handler(integration, { pageSize: input.pageSize });
|
||||||
const { data } = await innerHandler.getDataAsync();
|
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||||
return {
|
return {
|
||||||
integrationId: integration.id,
|
integrationId: integration.id,
|
||||||
...data,
|
...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"],
|
audiobookshelf: ["audiobookshelf"],
|
||||||
navidrome: ["navidrome"],
|
navidrome: ["navidrome"],
|
||||||
paperlessNgx: ["paperless-ngx", "paperless"],
|
paperlessNgx: ["paperless-ngx", "paperless"],
|
||||||
patchmon: ["patchmon", "patch-mon"],
|
|
||||||
coolify: ["coolify"],
|
coolify: ["coolify"],
|
||||||
truenas: ["truenas"],
|
truenas: ["truenas"],
|
||||||
bazarr: ["bazarr"],
|
bazarr: ["bazarr"],
|
||||||
|
|||||||
@@ -318,7 +318,6 @@ export type HomarrDocumentationPath =
|
|||||||
| "/docs/integrations/coolify"
|
| "/docs/integrations/coolify"
|
||||||
| "/docs/integrations/immich"
|
| "/docs/integrations/immich"
|
||||||
| "/docs/integrations/paperless-ngx"
|
| "/docs/integrations/paperless-ngx"
|
||||||
| "/docs/integrations/patchmon"
|
|
||||||
| "/docs/integrations/tracearr"
|
| "/docs/integrations/tracearr"
|
||||||
| "/docs/integrations/speedtest-tracker"
|
| "/docs/integrations/speedtest-tracker"
|
||||||
| "/docs/integrations/uptime-kuma"
|
| "/docs/integrations/uptime-kuma"
|
||||||
@@ -367,7 +366,6 @@ export type HomarrDocumentationPath =
|
|||||||
| "/docs/widgets/immich-server-stats"
|
| "/docs/widgets/immich-server-stats"
|
||||||
| "/docs/widgets/immich-album-carousel"
|
| "/docs/widgets/immich-album-carousel"
|
||||||
| "/docs/widgets/paperless-ngx"
|
| "/docs/widgets/paperless-ngx"
|
||||||
| "/docs/widgets/patchmon"
|
|
||||||
| "/docs/widgets/bazarr"
|
| "/docs/widgets/bazarr"
|
||||||
| "/docs/widgets/tracearr"
|
| "/docs/widgets/tracearr"
|
||||||
| "/docs/widgets/speedtest-tracker"
|
| "/docs/widgets/speedtest-tracker"
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
|
|||||||
coolify: "coolify",
|
coolify: "coolify",
|
||||||
immich: "immich",
|
immich: "immich",
|
||||||
paperlessNgx: "paperless-ngx",
|
paperlessNgx: "paperless-ngx",
|
||||||
patchmon: "patchmon",
|
|
||||||
tracearr: "tracearr",
|
tracearr: "tracearr",
|
||||||
speedtestTracker: "speedtest-tracker",
|
speedtestTracker: "speedtest-tracker",
|
||||||
uptimeKuma: "uptime-kuma",
|
uptimeKuma: "uptime-kuma",
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
|
|||||||
"immich-serverStats": "immich-server-stats",
|
"immich-serverStats": "immich-server-stats",
|
||||||
"immich-albumCarousel": "immich-album-carousel",
|
"immich-albumCarousel": "immich-album-carousel",
|
||||||
paperlessNgx: "paperless-ngx",
|
paperlessNgx: "paperless-ngx",
|
||||||
patchmon: "patchmon",
|
|
||||||
bazarr: "bazarr",
|
bazarr: "bazarr",
|
||||||
tracearr: "tracearr",
|
tracearr: "tracearr",
|
||||||
speedtestTracker: "speedtest-tracker",
|
speedtestTracker: "speedtest-tracker",
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ export const integrationSecretKindObject = {
|
|||||||
topic: { isPublic: true, multiline: false },
|
topic: { isPublic: true, multiline: false },
|
||||||
opnsenseApiKey: { isPublic: false, multiline: false },
|
opnsenseApiKey: { isPublic: false, multiline: false },
|
||||||
opnsenseApiSecret: { isPublic: false, multiline: false },
|
opnsenseApiSecret: { isPublic: false, multiline: false },
|
||||||
patchmonApiKey: { isPublic: true, multiline: false },
|
|
||||||
patchmonApiSecret: { isPublic: false, multiline: false },
|
|
||||||
url: { isPublic: false, multiline: false },
|
url: { isPublic: false, multiline: false },
|
||||||
privateKey: { isPublic: false, multiline: true },
|
privateKey: { isPublic: false, multiline: true },
|
||||||
githubAppId: { isPublic: true, multiline: false },
|
githubAppId: { isPublic: true, multiline: false },
|
||||||
@@ -372,15 +370,6 @@ export const integrationDefs = {
|
|||||||
documentationUrl: null,
|
documentationUrl: null,
|
||||||
defaultPort: 8000,
|
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: {
|
tracearr: {
|
||||||
name: "Tracearr",
|
name: "Tracearr",
|
||||||
secretKinds: [["apiKey"]],
|
secretKinds: [["apiKey"]],
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
|
|||||||
networkControllerSummary: getIntegrationKindsByCategory("networkController"),
|
networkControllerSummary: getIntegrationKindsByCategory("networkController"),
|
||||||
networkControllerStatus: getIntegrationKindsByCategory("networkController"),
|
networkControllerStatus: getIntegrationKindsByCategory("networkController"),
|
||||||
indexerManager: getIntegrationKindsByCategory("indexerManager"),
|
indexerManager: getIntegrationKindsByCategory("indexerManager"),
|
||||||
healthMonitoring: getIntegrationKindsByCategory("healthMonitoring").filter((kind) => kind !== "patchmon"),
|
healthMonitoring: getIntegrationKindsByCategory("healthMonitoring"),
|
||||||
firewall: getIntegrationKindsByCategory("firewall"),
|
firewall: getIntegrationKindsByCategory("firewall"),
|
||||||
notifications: getIntegrationKindsByCategory("notifications"),
|
notifications: getIntegrationKindsByCategory("notifications"),
|
||||||
mediaReleases: ["emby", "jellyfin", "plex"],
|
mediaReleases: ["emby", "jellyfin", "plex"],
|
||||||
@@ -34,7 +34,6 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
|
|||||||
"immich-serverStats": ["immich"],
|
"immich-serverStats": ["immich"],
|
||||||
"immich-albumCarousel": ["immich"],
|
"immich-albumCarousel": ["immich"],
|
||||||
paperlessNgx: ["paperlessNgx"],
|
paperlessNgx: ["paperlessNgx"],
|
||||||
patchmon: ["patchmon"],
|
|
||||||
bazarr: ["bazarr"],
|
bazarr: ["bazarr"],
|
||||||
tracearr: ["tracearr"],
|
tracearr: ["tracearr"],
|
||||||
speedtestTracker: ["speedtestTracker"],
|
speedtestTracker: ["speedtestTracker"],
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ export const widgetKinds = [
|
|||||||
"immich-serverStats",
|
"immich-serverStats",
|
||||||
"immich-albumCarousel",
|
"immich-albumCarousel",
|
||||||
"paperlessNgx",
|
"paperlessNgx",
|
||||||
"patchmon",
|
|
||||||
"bazarr",
|
"bazarr",
|
||||||
"tracearr",
|
"tracearr",
|
||||||
"speedtestTracker",
|
"speedtestTracker",
|
||||||
@@ -60,7 +59,6 @@ export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; hei
|
|||||||
uptimeKuma: { width: 2, height: 3 },
|
uptimeKuma: { width: 2, height: 3 },
|
||||||
audioStats: { width: 2, height: 2 },
|
audioStats: { width: 2, height: 2 },
|
||||||
paperlessNgx: { width: 2, height: 2 },
|
paperlessNgx: { width: 2, height: 2 },
|
||||||
patchmon: { width: 2, height: 2 },
|
|
||||||
mediaMissing: { width: 4, height: 3 },
|
mediaMissing: { width: 4, height: 3 },
|
||||||
bazarr: { width: 2, height: 2 },
|
bazarr: { width: 2, height: 2 },
|
||||||
};
|
};
|
||||||
@@ -37,7 +37,6 @@ import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-inte
|
|||||||
import { OPNsenseIntegration } from "../opnsense/opnsense-integration";
|
import { OPNsenseIntegration } from "../opnsense/opnsense-integration";
|
||||||
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
|
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
|
||||||
import { PaperlessNgxIntegration } from "../paperless-ngx/paperless-ngx-integration";
|
import { PaperlessNgxIntegration } from "../paperless-ngx/paperless-ngx-integration";
|
||||||
import { PatchMonIntegration } from "../patchmon/patchmon-integration";
|
|
||||||
import { PeaNutIntegration } from "../peanut/peanut-integration";
|
import { PeaNutIntegration } from "../peanut/peanut-integration";
|
||||||
import { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-factory";
|
import { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-factory";
|
||||||
import { createTechnitiumDnsIntegrationAsync } from "../technitium/technitium-integration-factory";
|
import { createTechnitiumDnsIntegrationAsync } from "../technitium/technitium-integration-factory";
|
||||||
@@ -121,7 +120,6 @@ export const integrationCreators = {
|
|||||||
glances: GlancesIntegration,
|
glances: GlancesIntegration,
|
||||||
immich: ImmichIntegration,
|
immich: ImmichIntegration,
|
||||||
paperlessNgx: PaperlessNgxIntegration,
|
paperlessNgx: PaperlessNgxIntegration,
|
||||||
patchmon: PatchMonIntegration,
|
|
||||||
speedtestTracker: SpeedtestTrackerIntegration,
|
speedtestTracker: SpeedtestTrackerIntegration,
|
||||||
audiobookshelf: AudiobookshelfIntegration,
|
audiobookshelf: AudiobookshelfIntegration,
|
||||||
navidrome: NavidromeIntegration,
|
navidrome: NavidromeIntegration,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export class QBitTorrentIntegration extends Integration implements IDownloadClie
|
|||||||
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
|
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
|
||||||
const type = "torrent";
|
const type = "torrent";
|
||||||
const client = await this.getClientAsync();
|
const client = await this.getClientAsync();
|
||||||
const torrents = await client.listTorrents({ limit: input.limit, sort: "progress", reverse: false });
|
const torrents = await client.listTorrents({ limit: input.limit });
|
||||||
const rates = torrents.reduce(
|
const rates = torrents.reduce(
|
||||||
({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }),
|
({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }),
|
||||||
{ down: 0, up: 0 },
|
{ down: 0, up: 0 },
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ export type {
|
|||||||
export type { Notification } from "./interfaces/notifications/notification-types";
|
export type { Notification } from "./interfaces/notifications/notification-types";
|
||||||
export type { ImmichServerStats, ImmichAlbum, ImmichAsset } from "./immich/immich-integration";
|
export type { ImmichServerStats, ImmichAlbum, ImmichAsset } from "./immich/immich-integration";
|
||||||
export type { PaperlessNgxStats } from "./paperless-ngx/paperless-ngx-types";
|
export type { PaperlessNgxStats } from "./paperless-ngx/paperless-ngx-types";
|
||||||
export type { PatchMonStats, PatchMonOsDistributionEntry } from "./patchmon/patchmon-types";
|
|
||||||
export type {
|
export type {
|
||||||
AnchorNote,
|
AnchorNote,
|
||||||
AnchorNotePermission,
|
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 { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
|
|
||||||
import type { SessionStore } from "../base/session-store";
|
import type { SessionStore } from "../base/session-store";
|
||||||
import type { SynologyDiskRecord, SynologyVolumeRecord } from "./synology-types";
|
|
||||||
import {
|
import {
|
||||||
AUTH_API_NAME,
|
AUTH_API_NAME,
|
||||||
INFO_API_NAME,
|
INFO_API_NAME,
|
||||||
@@ -24,6 +23,8 @@ import {
|
|||||||
synologyUpgradeCheckDataSchema,
|
synologyUpgradeCheckDataSchema,
|
||||||
synologyUpgradeStatusDataSchema,
|
synologyUpgradeStatusDataSchema,
|
||||||
synologyUtilizationDataSchema,
|
synologyUtilizationDataSchema,
|
||||||
|
type SynologyDiskRecord,
|
||||||
|
type SynologyVolumeRecord,
|
||||||
} from "./synology-types";
|
} from "./synology-types";
|
||||||
|
|
||||||
const logger = createLogger({ module: "synologyClient" });
|
const logger = createLogger({ module: "synologyClient" });
|
||||||
@@ -157,7 +158,13 @@ export class SynologyClient {
|
|||||||
if (!name) {
|
if (!name) {
|
||||||
return null;
|
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);
|
.filter((volume): volume is SynologyVolumeRecord => volume !== null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { Response } from "undici";
|
import { Response } from "undici";
|
||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import type { z } from "zod";
|
|
||||||
|
|
||||||
vi.hoisted(() => {
|
vi.hoisted(() => {
|
||||||
process.env.CI = "true";
|
process.env.CI = "true";
|
||||||
@@ -38,7 +37,6 @@ import { IntegrationResponseError } from "../../base/errors/http/integration-res
|
|||||||
|
|
||||||
import type { IntegrationSecret } from "../../base/types";
|
import type { IntegrationSecret } from "../../base/types";
|
||||||
import { SynologyIntegration } from "../synology-integration";
|
import { SynologyIntegration } from "../synology-integration";
|
||||||
import type { synologyStorageV2DataSchema, synologyUtilizationDataSchema } from "../synology-types";
|
|
||||||
|
|
||||||
const TEST_URL = "https://synology.example.com:5001";
|
const TEST_URL = "https://synology.example.com:5001";
|
||||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||||
@@ -204,8 +202,8 @@ const mockSuccessfulFlow = (options?: {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
utilization?: { success: boolean; data: z.input<typeof synologyUtilizationDataSchema> };
|
utilization?: typeof utilizationResponse;
|
||||||
storageV2?: { success: boolean; data: z.input<typeof synologyStorageV2DataSchema> };
|
storageV2?: typeof storageV2Response;
|
||||||
storageLoadInfo?: typeof storageLoadInfoResponse;
|
storageLoadInfo?: typeof storageLoadInfoResponse;
|
||||||
}) => {
|
}) => {
|
||||||
mockFetch.mockImplementation((url) => {
|
mockFetch.mockImplementation((url) => {
|
||||||
@@ -243,9 +241,9 @@ const mockSuccessfulFlow = (options?: {
|
|||||||
new Response(JSON.stringify({ success: false, error: { code: 103 } }), { status: 200 }),
|
new Response(JSON.stringify({ success: false, error: { code: 103 } }), { status: 200 }),
|
||||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
||||||
}
|
}
|
||||||
return Promise.resolve(
|
return Promise.resolve(new Response(JSON.stringify(options?.storageV2 ?? storageV2Response), { status: 200 })) as unknown as ReturnType<
|
||||||
new Response(JSON.stringify(options?.storageV2 ?? storageV2Response), { status: 200 }),
|
typeof fetchWithTrustedCertificatesAsync
|
||||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (apiName === "SYNO.Core.System" && method === "info" && type === "storage") {
|
if (apiName === "SYNO.Core.System" && method === "info" && type === "storage") {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { createIntegrationRequestHandler } from "./lib/integration-request-handl
|
|||||||
|
|
||||||
export const systemInfoRequestHandler = createIntegrationRequestHandler<
|
export const systemInfoRequestHandler = createIntegrationRequestHandler<
|
||||||
SystemHealthMonitoring,
|
SystemHealthMonitoring,
|
||||||
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify" | "beszel" | "patchmon">,
|
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify" | "beszel">,
|
||||||
Record<string, never>
|
Record<string, never>
|
||||||
>({
|
>({
|
||||||
async requestAsync(integration, _input) {
|
async requestAsync(integration, _input) {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||||
import { createIntegrationAsync } from "@homarr/integrations";
|
import { createIntegrationAsync } from "@homarr/integrations";
|
||||||
import type { IMediaOrganizerIntegration, MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/types";
|
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 {
|
interface MediaOrganizerData {
|
||||||
missing: MissingMediaItem[];
|
missing: MissingMediaItem[];
|
||||||
@@ -11,7 +13,7 @@ interface MediaOrganizerData {
|
|||||||
queuedCount: number;
|
queuedCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mediaOrganizerRequestHandler = createIntegrationRequestHandler<
|
export const mediaOrganizerRequestHandler = createCachedIntegrationRequestHandler<
|
||||||
MediaOrganizerData,
|
MediaOrganizerData,
|
||||||
IntegrationKindByCategory<"mediaOrganizer">,
|
IntegrationKindByCategory<"mediaOrganizer">,
|
||||||
{ pageSize: number }
|
{ pageSize: number }
|
||||||
@@ -29,5 +31,6 @@ export const mediaOrganizerRequestHandler = createIntegrationRequestHandler<
|
|||||||
queuedCount: queueResult.totalCount,
|
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)",
|
"label": "API Key (Secret)",
|
||||||
"newLabel": "New 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": {
|
"githubAppId": {
|
||||||
"label": "App Id",
|
"label": "App Id",
|
||||||
"newLabel": "New App Id"
|
"newLabel": "New App Id"
|
||||||
@@ -1344,10 +1336,6 @@
|
|||||||
"name": "Paperless-ngx",
|
"name": "Paperless-ngx",
|
||||||
"description": "Document management system for archiving and organizing scanned documents"
|
"description": "Document management system for archiving and organizing scanned documents"
|
||||||
},
|
},
|
||||||
"patchmon": {
|
|
||||||
"name": "PatchMon",
|
|
||||||
"description": "Patch management and monitoring for Linux hosts"
|
|
||||||
},
|
|
||||||
"bazarr": {
|
"bazarr": {
|
||||||
"name": "Bazarr",
|
"name": "Bazarr",
|
||||||
"description": "Companion application to Sonarr and Radarr that manages and downloads subtitles"
|
"description": "Companion application to Sonarr and Radarr that manages and downloads subtitles"
|
||||||
@@ -2774,164 +2762,6 @@
|
|||||||
"internalServerError": "Failed to fetch Paperless-ngx data"
|
"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": {
|
"bazarr": {
|
||||||
"name": "Bazarr",
|
"name": "Bazarr",
|
||||||
"description": "Overview of missing subtitles and health status from your Bazarr instance",
|
"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: {
|
errors: {
|
||||||
INTERNAL_SERVER_ERROR: {
|
INTERNAL_SERVER_ERROR: {
|
||||||
icon: IconServerOff,
|
icon: IconServerOff,
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ import * as networkControllerStatus from "./network-controller/network-status";
|
|||||||
import * as networkControllerSummary from "./network-controller/summary";
|
import * as networkControllerSummary from "./network-controller/summary";
|
||||||
import * as notebook from "./notebook";
|
import * as notebook from "./notebook";
|
||||||
import * as paperlessNgx from "./paperless-ngx";
|
import * as paperlessNgx from "./paperless-ngx";
|
||||||
import * as patchmon from "./patchmon";
|
|
||||||
import * as notifications from "./notifications";
|
import * as notifications from "./notifications";
|
||||||
import type { WidgetOptionDefinition } from "./options";
|
import type { WidgetOptionDefinition } from "./options";
|
||||||
import * as releases from "./releases";
|
import * as releases from "./releases";
|
||||||
@@ -115,7 +114,6 @@ export const widgetImports = {
|
|||||||
"immich-serverStats": immichServerStats,
|
"immich-serverStats": immichServerStats,
|
||||||
"immich-albumCarousel": immichAlbumCarousel,
|
"immich-albumCarousel": immichAlbumCarousel,
|
||||||
paperlessNgx,
|
paperlessNgx,
|
||||||
patchmon,
|
|
||||||
tracearr,
|
tracearr,
|
||||||
speedtestTracker,
|
speedtestTracker,
|
||||||
uptimeKuma,
|
uptimeKuma,
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { getInputForType } from "../_inputs";
|
|||||||
import { FormProvider, useForm } from "../_inputs/form";
|
import { FormProvider, useForm } from "../_inputs/form";
|
||||||
import type { BoardItemAdvancedOptions } from "../../../validation/src/shared";
|
import type { BoardItemAdvancedOptions } from "../../../validation/src/shared";
|
||||||
import type { OptionsBuilderResult } from "../options";
|
import type { OptionsBuilderResult } from "../options";
|
||||||
import { OPTIONS_SUPER_REFINE } from "../options";
|
|
||||||
import type { IntegrationSelectOption } from "../widget-integration-select";
|
import type { IntegrationSelectOption } from "../widget-integration-select";
|
||||||
import { WidgetIntegrationSelect } from "../widget-integration-select";
|
import { WidgetIntegrationSelect } from "../widget-integration-select";
|
||||||
import { WidgetAdvancedOptionsModal } from "./widget-advanced-options-modal";
|
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 { definition } = widgetImports[innerProps.kind];
|
||||||
const options = definition.createOptions(innerProps.settings) as Record<string, OptionsBuilderResult[string]>;
|
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({
|
const form = useForm({
|
||||||
mode: "controlled",
|
mode: "controlled",
|
||||||
initialValues: innerProps.value,
|
initialValues: innerProps.value,
|
||||||
validate: schemaResolver(
|
validate: schemaResolver(
|
||||||
z.object({
|
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()),
|
integrationIds: z.array(z.string()),
|
||||||
advancedOptions: z.object({
|
advancedOptions: z.object({
|
||||||
customCssClasses: z.array(z.string()),
|
customCssClasses: z.array(z.string()),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type React from "react";
|
|||||||
import type { DraggableAttributes, UniqueIdentifier } from "@dnd-kit/core";
|
import type { DraggableAttributes, UniqueIdentifier } from "@dnd-kit/core";
|
||||||
import type { ActionIconProps } from "@mantine/core";
|
import type { ActionIconProps } from "@mantine/core";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
import type { RefinementCtx, ZodType } from "zod/v4";
|
import type { ZodType } from "zod/v4";
|
||||||
|
|
||||||
import type { IntegrationKind } from "@homarr/definitions";
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
|
|
||||||
@@ -257,12 +257,9 @@ type ConfigurationInput<TOptions extends WidgetOptionsRecord> = Partial<
|
|||||||
Record<keyof TOptions, FieldConfiguration<TOptions>>
|
Record<keyof TOptions, FieldConfiguration<TOptions>>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export const OPTIONS_SUPER_REFINE = Symbol("optionsSuperRefine");
|
|
||||||
|
|
||||||
const createOptions = <TOptions extends WidgetOptionsRecord>(
|
const createOptions = <TOptions extends WidgetOptionsRecord>(
|
||||||
optionsCallback: (factory: WidgetOptionFactory) => TOptions,
|
optionsCallback: (factory: WidgetOptionFactory) => TOptions,
|
||||||
configuration?: ConfigurationInput<TOptions>,
|
configuration?: ConfigurationInput<TOptions>,
|
||||||
optionsSuperRefine?: (data: inferOptionsFromDefinition<TOptions>, ctx: RefinementCtx) => void,
|
|
||||||
) => {
|
) => {
|
||||||
const obj = {} as Record<keyof TOptions, unknown>;
|
const obj = {} as Record<keyof TOptions, unknown>;
|
||||||
const options = optionsCallback(optionsFactory);
|
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>;
|
[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;
|
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 (
|
return (
|
||||||
<div className={classes.protocolCard}>
|
<div className={classes.protocolCard}>
|
||||||
<Text className={classes.protocolTitle}>{protocol.toUpperCase()}</Text>
|
<Text className={classes.protocolTitle}>{t(`protocol.${protocol}`)}</Text>
|
||||||
<div className={classes.protocolRows}>
|
<div className={classes.protocolRows}>
|
||||||
{rows.map(({ key, icon: Icon, summary }) => (
|
{rows.map(({ key, icon: Icon, summary }) => (
|
||||||
<div key={key} className={classes.protocolRow}>
|
<div key={key} className={classes.protocolRow}>
|
||||||
|
|||||||
Reference in new issue
Block a user