diff --git a/CODEOWNERS b/CODEOWNERS index ddaa51316..7082b9bac 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -135,6 +135,13 @@ packages/integrations/src/overseerr @manuel-rw apps/docs/docs/integrations/paperless-ngx @ajnart packages/integrations/src/paperless-ngx @ajnart +# PatchMon - introduced in https://github.com/homarr-labs/homarr/pull/6119 by @mrjoshuap +apps/docs/docs/integrations/patchmon @mrjoshuap +packages/integrations/src/patchmon @mrjoshuap +packages/widgets/src/patchmon @mrjoshuap +packages/api/src/router/widgets/patchmon.ts @mrjoshuap +packages/request-handler/src/patchmon.ts @mrjoshuap + # PeaNUT - introduced in https://github.com/homarr-labs/homarr/pull/5862 by @afonsojramos apps/docs/docs/integrations/peanut @afonsojramos packages/integrations/src/peanut @afonsojramos diff --git a/apps/docs/docs/integrations/patchmon/index.mdx b/apps/docs/docs/integrations/patchmon/index.mdx new file mode 100644 index 000000000..95b827df0 --- /dev/null +++ b/apps/docs/docs/integrations/patchmon/index.mdx @@ -0,0 +1,43 @@ +--- +title: 'PatchMon' +description: "PatchMon is a patch management and monitoring solution for Linux hosts." +hide_title: true +--- + +import { IntegrationHeader } from '@site/src/components/integrations/header'; +import { IntegrationCapabilites } from '@site/src/components/integrations/widgets'; +import { AddingIntegration } from '@site/src/components/integrations/adding'; +import { IntegrationSecrets } from '@site/src/components/integrations/secrets'; +import { patchmonIntegration } from '.'; +import { patchmonWidget } from '@site/docs/widgets/patchmon'; + + + + +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 + + +### Adding the integration + + +### Secrets + diff --git a/apps/docs/docs/integrations/patchmon/index.ts b/apps/docs/docs/integrations/patchmon/index.ts new file mode 100644 index 000000000..912c09f16 --- /dev/null +++ b/apps/docs/docs/integrations/patchmon/index.ts @@ -0,0 +1,9 @@ +import { IntegrationDefinition } from "@site/src/types"; + +export const patchmonIntegration: IntegrationDefinition = { + name: "PatchMon", + description: + "PatchMon is a patch management and monitoring solution for Linux hosts, tracking package updates and security patches across your infrastructure.", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/patchmon.svg", + path: "../../integrations/patchmon", +}; diff --git a/apps/docs/docs/management/mcp.mdx b/apps/docs/docs/management/mcp.mdx index 5317ecce2..7c79d9a8d 100644 --- a/apps/docs/docs/management/mcp.mdx +++ b/apps/docs/docs/management/mcp.mdx @@ -141,6 +141,7 @@ Homarr exposes 50+ tools organized by namespace. Each tool is either a **query** | `dnsHole` | DNS hole summary, toggle blocking | GET + POST | | `downloads` | Active downloads, pause/resume/delete | GET + POST | | `healthMonitoring` | Resource usage stats | GET | +| `patchmon` | Host patch and security update statistics | GET | | `mediaRequests` | Approve/decline media requests | GET + POST | | `mediaServer` | Media server sessions and stats | GET | | `smartHome` | Execute automations, device states | GET + POST | diff --git a/apps/docs/docs/widgets/patchmon/index.mdx b/apps/docs/docs/widgets/patchmon/index.mdx new file mode 100644 index 000000000..d16ab8d68 --- /dev/null +++ b/apps/docs/docs/widgets/patchmon/index.mdx @@ -0,0 +1,89 @@ +--- +title: 'PatchMon' +description: 'Displays host patch statistics from PatchMon including update and security counts.' +hide_title: true +--- + +import { WidgetHeader } from '@site/src/components/widgets/header'; +import { AddingWidget } from '@site/src/components/widgets/adding'; +import { WidgetConfig } from '@site/src/components/widgets/configuration'; +import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; +import { patchmonWidget } from '.'; +import { patchmonIntegration } from '@site/docs/integrations/patchmon'; + + + + +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 + + + +### Adding the widget + + + +### Configuration + diff --git a/apps/docs/docs/widgets/patchmon/index.ts b/apps/docs/docs/widgets/patchmon/index.ts new file mode 100644 index 000000000..0080dd8b4 --- /dev/null +++ b/apps/docs/docs/widgets/patchmon/index.ts @@ -0,0 +1,200 @@ +import { WidgetDefinition } from "@site/src/types"; +import { IconShieldCheck } from "@tabler/icons-react"; + +export const patchmonWidget: WidgetDefinition = { + icon: IconShieldCheck, + name: "PatchMon", + description: + "Displays host patch statistics including total hosts, hosts needing updates, and security update counts.", + path: "../../widgets/patchmon", + configuration: { + items: [ + { + name: "Show compliance hero", + description: "Display a compliance ring showing the percentage of up-to-date hosts", + values: { type: "boolean" }, + defaultValue: "yes", + }, + { + name: "Show total hosts", + description: "Displays the total number of monitored hosts", + values: { type: "boolean" }, + defaultValue: "yes", + }, + { + name: "Show hosts needing updates", + description: "Displays hosts with at least one outdated package", + values: { type: "boolean" }, + defaultValue: "yes", + }, + { + name: "Show security updates", + description: "Displays the total number of security updates available", + values: { type: "boolean" }, + defaultValue: "yes", + }, + { + name: "Show up-to-date hosts", + description: "Displays hosts with zero outdated packages", + values: { type: "boolean" }, + defaultValue: "no", + }, + { + name: "Show hosts with security updates", + description: "Displays hosts requiring security patches", + values: { type: "boolean" }, + defaultValue: "no", + }, + { + name: "Show recent updates (24h)", + description: "Displays the number of updates applied in the last 24 hours", + values: { type: "boolean" }, + defaultValue: "no", + }, + { + name: "Show outdated packages", + description: "Displays the total number of outdated packages across all hosts", + values: { type: "boolean" }, + defaultValue: "no", + }, + { + name: "Show repositories", + description: "Displays the number of active repositories being monitored", + values: { type: "boolean" }, + defaultValue: "no", + }, + { + name: "Enable threshold colors", + description: "Colors stat values green, yellow, or red based on built-in severity presets", + values: { type: "boolean" }, + defaultValue: "yes", + }, + { + name: "Advanced thresholds", + description: "Customize warning and critical thresholds per stat (absolute count or % of total hosts)", + values: { type: "boolean" }, + defaultValue: "no", + }, + { + name: "Needs updates threshold mode", + description: + "Compare the needs-updates count as an absolute value or percentage of total hosts. Shown when threshold colors and advanced thresholds are enabled.", + values: { type: "select", options: ["Absolute count", "Percent of total hosts"] }, + defaultValue: "Absolute count", + }, + { + name: "Needs updates warning threshold", + description: "Enter yellow when the value reaches this threshold", + values: "Integer ≥ 0", + defaultValue: "1", + }, + { + name: "Needs updates critical threshold", + description: "Enter red when the value reaches this threshold", + values: "Integer ≥ 0", + defaultValue: "5", + }, + { + name: "Security updates threshold mode", + description: + "Compare the security-updates count as an absolute value or percentage of total hosts. Shown when threshold colors and advanced thresholds are enabled.", + values: { type: "select", options: ["Absolute count", "Percent of total hosts"] }, + defaultValue: "Absolute count", + }, + { + name: "Security updates warning threshold", + description: "Enter yellow when the value reaches this threshold", + values: "Integer ≥ 0", + defaultValue: "1", + }, + { + name: "Security updates critical threshold", + description: "Enter red when the value reaches this threshold", + values: "Integer ≥ 0", + defaultValue: "10", + }, + { + name: "Hosts with security updates threshold mode", + description: + "Compare the host count as an absolute value or percentage of total hosts. Shown when threshold colors and advanced thresholds are enabled.", + values: { type: "select", options: ["Absolute count", "Percent of total hosts"] }, + defaultValue: "Absolute count", + }, + { + name: "Hosts with security updates warning threshold", + description: "Enter yellow when the value reaches this threshold", + values: "Integer ≥ 0", + defaultValue: "1", + }, + { + name: "Hosts with security updates critical threshold", + description: "Enter red when the value reaches this threshold", + values: "Integer ≥ 0", + defaultValue: "1", + }, + { + name: "Up-to-date hosts threshold mode", + description: + "Compare the up-to-date host count as an absolute value or percentage of total hosts. Shown when threshold colors and advanced thresholds are enabled.", + values: { type: "select", options: ["Absolute count", "Percent of total hosts"] }, + defaultValue: "Percent of total hosts", + }, + { + name: "Up-to-date hosts warning threshold", + description: "Enter yellow when the value is at or above this threshold but below full coverage", + values: "Integer ≥ 0", + defaultValue: "90", + }, + { + name: "Up-to-date hosts critical threshold", + description: "Enter red when the value falls below this threshold", + values: "Integer ≥ 0", + defaultValue: "90", + }, + { + name: "Outdated packages threshold mode", + description: + "Compare the outdated-packages count as an absolute value or percentage of total hosts. Shown when threshold colors, advanced thresholds, and Show outdated packages are enabled.", + values: { type: "select", options: ["Absolute count", "Percent of total hosts"] }, + defaultValue: "Absolute count", + }, + { + name: "Outdated packages warning threshold", + description: "Enter yellow when the value reaches this threshold", + values: "Integer ≥ 0", + defaultValue: "1", + }, + { + name: "Outdated packages critical threshold", + description: "Enter red when the value reaches this threshold", + values: "Integer ≥ 0", + defaultValue: "50", + }, + { + name: "Show OS distribution", + description: "Displays a horizontal bar breakdown of monitored hosts by operating system", + values: { type: "boolean" }, + defaultValue: "no", + }, + { + name: "OS display mode", + description: + "Choose how to display the operating system distribution. Shown when Show OS distribution is enabled.", + values: { type: "select", options: ["Horizontal bars", "Donut chart"] }, + defaultValue: "Horizontal bars", + }, + { + name: "OS distribution limit", + description: "Maximum number of operating systems to display in the distribution section", + values: { type: "select", options: ["Top 3", "Top 5", "Top 10", "All"] }, + defaultValue: "All", + }, + { + name: "Show OS version", + description: "Append the OS version to each distribution label when available", + values: { type: "boolean" }, + defaultValue: "yes", + }, + ], + }, +}; diff --git a/apps/docs/src/components/integrations/secrets.tsx b/apps/docs/src/components/integrations/secrets.tsx index 2fc09fc6d..e03e59430 100644 --- a/apps/docs/src/components/integrations/secrets.tsx +++ b/apps/docs/src/components/integrations/secrets.tsx @@ -47,6 +47,16 @@ const secretKinds = { description: "The Secret part of the API Key for authentication.", icon: IconPassword, }, + patchmonApiKey: { + name: "API Key", + description: "The API key from PatchMon's GetHomepage integration.", + icon: IconKey, + }, + patchmonApiSecret: { + name: "API Secret", + description: "The API secret from PatchMon's GetHomepage integration.", + icon: IconPassword, + }, githubAppId: { name: "App ID", description: "The ID of the GitHub App", icon: IconCode }, githubInstallationId: { name: "Installation ID", diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts b/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts index 18b32206e..1b2ed3c7b 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts +++ b/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts @@ -26,6 +26,8 @@ export const integrationSecretIcons = { url: IconLink, opnsenseApiKey: IconKey, opnsenseApiSecret: IconPassword, + patchmonApiKey: IconKey, + patchmonApiSecret: IconPassword, githubAppId: IconCode, githubInstallationId: IconPlug, privateKey: IconKey, diff --git a/packages/api/src/mcp.ts b/packages/api/src/mcp.ts index 266db0622..039943adb 100644 --- a/packages/api/src/mcp.ts +++ b/packages/api/src/mcp.ts @@ -18,6 +18,7 @@ import { downloadsRouter } from "./router/widgets/downloads"; import { healthMonitoringRouter } from "./router/widgets/health-monitoring"; import { mediaRequestsRouter } from "./router/widgets/media-requests"; import { mediaServerRouter } from "./router/widgets/media-server"; +import { patchmonRouter } from "./router/widgets/patchmon"; import { smartHomeRouter } from "./router/widgets/smart-home"; import { widgetSecretsRouter } from "./router/widgets/widget-secrets"; @@ -39,6 +40,7 @@ export const mcpRouter = createTRPCRouter({ healthMonitoring: healthMonitoringRouter, mediaRequests: mediaRequestsRouter, mediaServer: mediaServerRouter, + patchmon: patchmonRouter, smartHome: smartHomeRouter, widgetSecrets: widgetSecretsRouter, }); diff --git a/packages/api/src/router/test/widgets/patchmon.spec.ts b/packages/api/src/router/test/widgets/patchmon.spec.ts new file mode 100644 index 000000000..df37b5647 --- /dev/null +++ b/packages/api/src/router/test/widgets/patchmon.spec.ts @@ -0,0 +1,200 @@ +import { TRPCError } from "@trpc/server"; +import { describe, expect, test, vi } from "vitest"; + +import type { Session } from "@homarr/auth"; +import { createId } from "@homarr/common"; +import type { Database } from "@homarr/db"; +import { boards, integrationItems, integrations, integrationUserPermissions, items, users } from "@homarr/db/schema"; +import { createDb } from "@homarr/db/test"; +import type { PatchMonStats } from "@homarr/integrations"; + +import { patchmonRouter } from "../../widgets/patchmon"; + +const samplePatchMonStats = { + totalHosts: 42, + hostsNeedingUpdates: 15, + securityUpdates: 23, + upToDateHosts: 27, + hostsWithSecurityUpdates: 8, + recentUpdates24h: 34, + totalOutdatedPackages: 156, + totalRepos: 12, + lastUpdated: "2025-10-11T12:34:56.789Z", + osDistribution: [], +} satisfies PatchMonStats; + +vi.mock("@homarr/request-handler/patchmon", () => ({ + patchmonStatsRequestHandler: { + handler: () => ({ + getDataAsync: async () => ({ + data: samplePatchMonStats, + timestamp: new Date(), + }), + }), + }, +})); + +const createSession = (userId: string): Session => ({ + user: { + id: userId, + permissions: [], + colorScheme: "light", + }, + expires: new Date().toISOString(), +}); + +const createCaller = (db: Database, session: Session | null) => + patchmonRouter.createCaller({ + db, + deviceType: undefined, + session, + }); + +const createPatchMonIntegrationOnBoardAsync = async ( + db: Database, + options: { + isPublic: boolean; + boardCreatorId: string; + }, +) => { + const integrationId = createId(); + const boardId = createId(); + const itemId = createId(); + + await db.insert(integrations).values({ + id: integrationId, + kind: "patchmon", + name: "PatchMon", + url: "https://patchmon.example.com", + }); + + await db.insert(boards).values({ + id: boardId, + name: "patchmon-board", + creatorId: options.boardCreatorId, + isPublic: options.isPublic, + }); + + await db.insert(items).values({ + id: itemId, + kind: "patchmon", + boardId, + options: "{}", + }); + + await db.insert(integrationItems).values({ + integrationId, + itemId, + }); + + return { integrationId, boardId, itemId }; +}; + +describe("patchmonRouter.getStats access control", () => { + test("should throw UNAUTHORIZED for unauthenticated users even when integration is on a public board", async () => { + const db = createDb(); + const otherUserId = createId(); + await db.insert(users).values({ id: otherUserId }); + const { integrationId } = await createPatchMonIntegrationOnBoardAsync(db, { + isPublic: true, + boardCreatorId: otherUserId, + }); + const caller = createCaller(db, null); + + await expect(caller.getStats({ integrationId })).rejects.toThrow( + new TRPCError({ code: "UNAUTHORIZED" }), + ); + }); + + test("should throw FORBIDDEN for authenticated users without board or integration access", async () => { + const db = createDb(); + const boardOwnerId = createId(); + const sessionUserId = createId(); + await db.insert(users).values([{ id: boardOwnerId }, { id: sessionUserId }]); + const { integrationId } = await createPatchMonIntegrationOnBoardAsync(db, { + isPublic: false, + boardCreatorId: boardOwnerId, + }); + const caller = createCaller(db, createSession(sessionUserId)); + + await expect(caller.getStats({ integrationId })).rejects.toThrow( + new TRPCError({ + code: "FORBIDDEN", + message: "User does not have permission to query at least one of the specified integration", + }), + ); + }); + + test("should return stats for authenticated users when integration is on a public board", async () => { + const db = createDb(); + const boardOwnerId = createId(); + const sessionUserId = createId(); + await db.insert(users).values([{ id: boardOwnerId }, { id: sessionUserId }]); + const { integrationId } = await createPatchMonIntegrationOnBoardAsync(db, { + isPublic: true, + boardCreatorId: boardOwnerId, + }); + const caller = createCaller(db, createSession(sessionUserId)); + + const result = await caller.getStats({ integrationId }); + + expect(result).toStrictEqual(samplePatchMonStats); + }); + + test("should return stats for authenticated users with integration use permission and no board placement", async () => { + const db = createDb(); + const sessionUserId = createId(); + const integrationId = createId(); + await db.insert(users).values({ id: sessionUserId }); + await db.insert(integrations).values({ + id: integrationId, + kind: "patchmon", + name: "PatchMon", + url: "https://patchmon.example.com", + }); + await db.insert(integrationUserPermissions).values({ + integrationId, + userId: sessionUserId, + permission: "use", + }); + const caller = createCaller(db, createSession(sessionUserId)); + + const result = await caller.getStats({ integrationId }); + + expect(result).toStrictEqual(samplePatchMonStats); + }); + + test("should throw NOT_FOUND when integration id does not exist", async () => { + const db = createDb(); + const sessionUserId = createId(); + await db.insert(users).values({ id: sessionUserId }); + const caller = createCaller(db, createSession(sessionUserId)); + + await expect(caller.getStats({ integrationId: createId() })).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + }); + + test("should throw NOT_FOUND when integration is not a PatchMon integration", async () => { + const db = createDb(); + const sessionUserId = createId(); + const integrationId = createId(); + await db.insert(users).values({ id: sessionUserId }); + await db.insert(integrations).values({ + id: integrationId, + kind: "sonarr", + name: "Sonarr", + url: "https://sonarr.example.com", + }); + await db.insert(integrationUserPermissions).values({ + integrationId, + userId: sessionUserId, + permission: "use", + }); + const caller = createCaller(db, createSession(sessionUserId)); + + await expect(caller.getStats({ integrationId })).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + }); +}); diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index 4d6b4984a..5db62a93c 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -9,6 +9,7 @@ export const widgetRouter = createTRPCRouter({ coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)), immich: lazy(() => import("./immich").then((mod) => mod.immichRouter)), paperlessNgx: lazy(() => import("./paperless-ngx").then((mod) => mod.paperlessNgxRouter)), + patchmon: lazy(() => import("./patchmon").then((mod) => mod.patchmonRouter)), bazarr: lazy(() => import("./bazarr").then((mod) => mod.bazarrRouter)), notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)), weather: lazy(() => import("./weather").then((mod) => mod.weatherRouter)), diff --git a/packages/api/src/router/widgets/patchmon.ts b/packages/api/src/router/widgets/patchmon.ts new file mode 100644 index 000000000..aee758823 --- /dev/null +++ b/packages/api/src/router/widgets/patchmon.ts @@ -0,0 +1,21 @@ +import { patchmonStatsRequestHandler } from "@homarr/request-handler/patchmon"; + +import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, protectedProcedure } from "../../trpc"; + +export const patchmonRouter = createTRPCRouter({ + getStats: protectedProcedure + .meta({ + mcp: { + enabled: true, + description: + "Get PatchMon host patch statistics including total hosts, hosts needing updates, security update counts, up-to-date hosts, outdated packages, repositories, and OS distribution. REQUIRED: integrationId (single PatchMon integration ID from integration_all). Requires authenticated session (API key or browser login) and integration query access.", + }, + }) + .concat(createOneIntegrationMiddleware("query", "patchmon")) + .query(async ({ ctx }) => { + const innerHandler = patchmonStatsRequestHandler.handler(ctx.integration, {}); + const { data } = await innerHandler.getDataAsync(); + return data; + }), +}); diff --git a/packages/definitions/src/docker-integration-match.ts b/packages/definitions/src/docker-integration-match.ts index 7b0ea4c10..a25fb8c44 100644 --- a/packages/definitions/src/docker-integration-match.ts +++ b/packages/definitions/src/docker-integration-match.ts @@ -31,6 +31,10 @@ const integrationAliases: Partial> = audiobookshelf: ["audiobookshelf"], navidrome: ["navidrome"], paperlessNgx: ["paperless-ngx", "paperless"], + patchmon: ["patchmon", "patch-mon"], + searchCh: ["search-ch"], + gitHubContainerRegistry: ["ghcr"], + linuxServerIO: ["linuxserver"], coolify: ["coolify"], truenas: ["truenas"], bazarr: ["bazarr"], diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts index b83fff39b..21c17c497 100644 --- a/packages/definitions/src/docs/homarr-docs-sitemap.ts +++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts @@ -313,6 +313,7 @@ export type HomarrDocumentationPath = | "/docs/integrations/coolify" | "/docs/integrations/immich" | "/docs/integrations/paperless-ngx" + | "/docs/integrations/patchmon" | "/docs/integrations/tracearr" | "/docs/integrations/speedtest-tracker" | "/docs/integrations/uptime-kuma" @@ -360,6 +361,7 @@ export type HomarrDocumentationPath = | "/docs/widgets/immich-server-stats" | "/docs/widgets/immich-album-carousel" | "/docs/widgets/paperless-ngx" + | "/docs/widgets/patchmon" | "/docs/widgets/bazarr" | "/docs/widgets/tracearr" | "/docs/widgets/speedtest-tracker" diff --git a/packages/definitions/src/docs/integration-doc-slugs.ts b/packages/definitions/src/docs/integration-doc-slugs.ts index 9fd20050a..41ea8eb33 100644 --- a/packages/definitions/src/docs/integration-doc-slugs.ts +++ b/packages/definitions/src/docs/integration-doc-slugs.ts @@ -42,6 +42,7 @@ export const integrationDocSlugs: Record = { coolify: "coolify", immich: "immich", paperlessNgx: "paperless-ngx", + patchmon: "patchmon", tracearr: "tracearr", speedtestTracker: "speedtest-tracker", uptimeKuma: "uptime-kuma", diff --git a/packages/definitions/src/docs/widget-doc-slugs.ts b/packages/definitions/src/docs/widget-doc-slugs.ts index 405e7bad4..8e51bbddb 100644 --- a/packages/definitions/src/docs/widget-doc-slugs.ts +++ b/packages/definitions/src/docs/widget-doc-slugs.ts @@ -39,6 +39,7 @@ export const widgetDocSlugs: Record = { "immich-serverStats": "immich-server-stats", "immich-albumCarousel": "immich-album-carousel", paperlessNgx: "paperless-ngx", + patchmon: "patchmon", bazarr: "bazarr", tracearr: "tracearr", speedtestTracker: "speedtest-tracker", diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index 7798f6f2f..138a1c9f7 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -13,6 +13,8 @@ export const integrationSecretKindObject = { topic: { isPublic: true, multiline: false }, opnsenseApiKey: { isPublic: false, multiline: false }, opnsenseApiSecret: { isPublic: false, multiline: false }, + patchmonApiKey: { isPublic: true, multiline: false }, + patchmonApiSecret: { isPublic: false, multiline: false }, url: { isPublic: false, multiline: false }, privateKey: { isPublic: false, multiline: true }, githubAppId: { isPublic: true, multiline: false }, @@ -370,6 +372,15 @@ export const integrationDefs = { documentationUrl: null, defaultPort: 8000, }, + patchmon: { + name: "PatchMon", + secretKinds: [["patchmonApiKey", "patchmonApiSecret"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/patchmon.svg", + category: ["healthMonitoring"], + documentationUrl: createDocumentationLink("/docs/integrations/patchmon"), + defaultPort: 3413, + apiKeySettingsPath: "/settings/api-keys", + }, tracearr: { name: "Tracearr", secretKinds: [["apiKey"]], diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts index c1791f2df..1844fb662 100644 --- a/packages/definitions/src/widget-integration-map.ts +++ b/packages/definitions/src/widget-integration-map.ts @@ -24,7 +24,7 @@ export const widgetIntegrationSupport: Partial kind !== "patchmon"), firewall: getIntegrationKindsByCategory("firewall"), notifications: getIntegrationKindsByCategory("notifications"), mediaReleases: ["emby", "jellyfin", "plex"], @@ -34,6 +34,7 @@ export const widgetIntegrationSupport: Partial { + 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 { + 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 { + const credentials = Buffer.from( + `${this.getSecretValue("patchmonApiKey")}:${this.getSecretValue("patchmonApiSecret")}`, + ).toString("base64"); + + return { Authorization: `Basic ${credentials}` }; + } +} diff --git a/packages/integrations/src/patchmon/patchmon-types.ts b/packages/integrations/src/patchmon/patchmon-types.ts new file mode 100644 index 000000000..1f912d6c9 --- /dev/null +++ b/packages/integrations/src/patchmon/patchmon-types.ts @@ -0,0 +1,88 @@ +import { ParseError } from "@homarr/common/server"; +import { z } from "zod/v4"; + +const PATCHMON_STATS_PARSE_ERROR_MESSAGE = "Invalid PatchMon stats response"; + +export const patchmonOsDistributionEntrySchema = z.object({ + name: z.string(), + count: z.number(), + os_type: z.string().optional(), + os_version: z.string().optional(), +}); + +export const patchmonStatsResponseSchema = z.object({ + total_hosts: z.number(), + hosts_needing_updates: z.number(), + security_updates: z.number(), + up_to_date_hosts: z.number(), + hosts_with_security_updates: z.number(), + recent_updates_24h: z.number(), + total_outdated_packages: z.number(), + total_repos: z.number(), + last_updated: z.string(), + os_distribution: z.array(patchmonOsDistributionEntrySchema).default([]), +}); + +export interface PatchMonOsDistributionEntry { + name: string; + count: number; + osType?: string; + osVersion?: string; +} + +export interface PatchMonStats { + totalHosts: number; + hostsNeedingUpdates: number; + securityUpdates: number; + upToDateHosts: number; + hostsWithSecurityUpdates: number; + recentUpdates24h: number; + totalOutdatedPackages: number; + totalRepos: number; + lastUpdated: string; + osDistribution: PatchMonOsDistributionEntry[]; +} + +const mapOsDistributionEntry = ( + entry: z.infer, +): PatchMonOsDistributionEntry => ({ + name: entry.name, + count: entry.count, + osType: entry.os_type, + osVersion: entry.os_version, +}); + +export const parsePatchMonStatsResponseAsync = async ( + response: { json: () => Promise }, +): Promise> => { + 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): 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), +}); diff --git a/packages/integrations/src/patchmon/test/patchmon-integration.spec.ts b/packages/integrations/src/patchmon/test/patchmon-integration.spec.ts new file mode 100644 index 000000000..74924aecd --- /dev/null +++ b/packages/integrations/src/patchmon/test/patchmon-integration.spec.ts @@ -0,0 +1,167 @@ +// @vitest-environment node +import { ParseError } from "@homarr/common/server"; +import { Response } from "undici"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +vi.hoisted(() => { + process.env.SKIP_ENV_VALIDATION = "true"; + process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1"; +}); + +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import { IntegrationParseError } from "../../base/errors/parse/integration-parse-error"; +import type { IntegrationSecret } from "../../base/types"; +import { PatchMonIntegration } from "../patchmon-integration"; + +vi.mock("@homarr/core/infrastructure/http", () => ({ + fetchWithTrustedCertificatesAsync: vi.fn(), +})); + +const TEST_URL = "https://patchmon.example.com"; +const TEST_API_KEY = "test-api-key"; +const TEST_API_SECRET = "test-api-secret"; + +const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync); + +const sampleStatsResponse = { + total_hosts: 42, + hosts_needing_updates: 15, + security_updates: 23, + up_to_date_hosts: 27, + hosts_with_security_updates: 8, + recent_updates_24h: 34, + total_outdated_packages: 156, + total_repos: 12, + last_updated: "2025-10-11T12:34:56.789Z", + os_distribution: [ + { name: "Debian", count: 12, os_type: "linux", os_version: "12" }, + { name: "Ubuntu", count: 20, os_type: "linux", os_version: "22.04" }, + { name: "Rocky Linux", count: 10, os_type: "linux", os_version: "9" }, + ], +}; + +const createIntegration = (decryptedSecrets: IntegrationSecret[] = []) => + new PatchMonIntegration({ + id: "test-patchmon", + name: "Test PatchMon", + url: TEST_URL, + externalUrl: null, + decryptedSecrets: + decryptedSecrets.length > 0 + ? decryptedSecrets + : [ + { kind: "patchmonApiKey", value: TEST_API_KEY }, + { kind: "patchmonApiSecret", value: TEST_API_SECRET }, + ], + }); + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe("PatchMonIntegration getStatsAsync", () => { + test("maps API response fields to camelCase stats", async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify(sampleStatsResponse), { + status: 200, + headers: { "content-type": "application/json" }, + }) as unknown as Awaited>, + ); + + 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>, + ); + + 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>, + ); + + 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>, + ); + + 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>, + ); + + 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>, + ); + + 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); + }); +}); diff --git a/packages/request-handler/src/health-monitoring.ts b/packages/request-handler/src/health-monitoring.ts index 604fff9da..959d3e0ed 100644 --- a/packages/request-handler/src/health-monitoring.ts +++ b/packages/request-handler/src/health-monitoring.ts @@ -6,7 +6,7 @@ import { createIntegrationRequestHandler } from "./lib/integration-request-handl export const systemInfoRequestHandler = createIntegrationRequestHandler< SystemHealthMonitoring, - Exclude, "proxmox" | "coolify" | "beszel">, + Exclude, "proxmox" | "coolify" | "beszel" | "patchmon">, Record >({ async requestAsync(integration, _input) { diff --git a/packages/request-handler/src/patchmon.ts b/packages/request-handler/src/patchmon.ts new file mode 100644 index 000000000..021897511 --- /dev/null +++ b/packages/request-handler/src/patchmon.ts @@ -0,0 +1,15 @@ +import { createIntegrationAsync } from "@homarr/integrations"; +import type { PatchMonStats } from "@homarr/integrations"; + +import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; + +export const patchmonStatsRequestHandler = createIntegrationRequestHandler< + PatchMonStats, + "patchmon", + Record +>({ + async requestAsync(integration) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getStatsAsync(); + }, +}); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index d2f60a602..c314dd1ef 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1285,6 +1285,14 @@ "label": "API Key (Secret)", "newLabel": "New API Key (Secret)" }, + "patchmonApiKey": { + "label": "API Key", + "newLabel": "New API Key" + }, + "patchmonApiSecret": { + "label": "API Secret", + "newLabel": "New API Secret" + }, "githubAppId": { "label": "App Id", "newLabel": "New App Id" @@ -1336,6 +1344,10 @@ "name": "Paperless-ngx", "description": "Document management system for archiving and organizing scanned documents" }, + "patchmon": { + "name": "PatchMon", + "description": "Patch management and monitoring for Linux hosts" + }, "bazarr": { "name": "Bazarr", "description": "Companion application to Sonarr and Radarr that manages and downloads subtitles" @@ -2762,6 +2774,164 @@ "internalServerError": "Failed to fetch Paperless-ngx data" } }, + "patchmon": { + "name": "PatchMon", + "description": "Overview of host patch status from PatchMon", + "title": "PatchMon Statistics", + "totalHosts": "Total Hosts", + "hostsNeedingUpdates": "Needs Updates", + "securityUpdates": "Security Updates", + "upToDateHosts": "Up-to-Date Hosts", + "hostsWithSecurityUpdates": "Hosts with Security Updates", + "recentUpdates24h": "Updates (24h)", + "totalOutdatedPackages": "Outdated Packages", + "totalRepos": "Repositories", + "osDistribution": "OS Distribution", + "compliance": "Patch Compliance", + "complianceRatio": "{upToDate} / {total}", + "lastUpdated": "Updated {time}", + "option": { + "showTotalHosts": { + "label": "Show total hosts", + "description": "Display the total number of monitored hosts" + }, + "showHostsNeedingUpdates": { + "label": "Show hosts needing updates", + "description": "Display hosts with at least one outdated package" + }, + "showSecurityUpdates": { + "label": "Show security updates", + "description": "Display the total number of security updates available" + }, + "showUpToDateHosts": { + "label": "Show up-to-date hosts", + "description": "Display hosts with zero outdated packages" + }, + "showHostsWithSecurityUpdates": { + "label": "Show hosts with security updates", + "description": "Display hosts requiring security patches" + }, + "showRecentUpdates24h": { + "label": "Show recent updates (24h)", + "description": "Display the number of updates applied in the last 24 hours" + }, + "showTotalOutdatedPackages": { + "label": "Show outdated packages", + "description": "Display the total number of outdated packages across all hosts" + }, + "showTotalRepos": { + "label": "Show repositories", + "description": "Display the number of active repositories being monitored" + }, + "enableThresholdColors": { + "label": "Enable threshold colors", + "description": "Color stat values green, yellow, or red based on severity presets" + }, + "useCustomThresholds": { + "label": "Advanced thresholds", + "description": "Customize warning and critical thresholds per stat using absolute counts or percentages of total hosts" + }, + "thresholdMode": { + "option": { + "absolute": "Absolute count", + "percent": "Percent of total hosts" + } + }, + "hostsNeedingUpdatesThresholdMode": { + "label": "Needs updates threshold mode", + "description": "Compare the needs-updates count as an absolute value or percentage of total hosts" + }, + "hostsNeedingUpdatesWarningAt": { + "label": "Needs updates warning threshold", + "description": "Enter yellow when the value reaches this threshold" + }, + "hostsNeedingUpdatesCriticalAt": { + "label": "Needs updates critical threshold", + "description": "Enter red when the value reaches this threshold" + }, + "securityUpdatesThresholdMode": { + "label": "Security updates threshold mode", + "description": "Compare the security-updates count as an absolute value or percentage of total hosts" + }, + "securityUpdatesWarningAt": { + "label": "Security updates warning threshold", + "description": "Enter yellow when the value reaches this threshold" + }, + "securityUpdatesCriticalAt": { + "label": "Security updates critical threshold", + "description": "Enter red when the value reaches this threshold" + }, + "hostsWithSecurityUpdatesThresholdMode": { + "label": "Hosts with security updates threshold mode", + "description": "Compare the host count as an absolute value or percentage of total hosts" + }, + "hostsWithSecurityUpdatesWarningAt": { + "label": "Hosts with security updates warning threshold", + "description": "Enter yellow when the value reaches this threshold" + }, + "hostsWithSecurityUpdatesCriticalAt": { + "label": "Hosts with security updates critical threshold", + "description": "Enter red when the value reaches this threshold" + }, + "upToDateHostsThresholdMode": { + "label": "Up-to-date hosts threshold mode", + "description": "Compare the up-to-date host count as an absolute value or percentage of total hosts" + }, + "upToDateHostsWarningAt": { + "label": "Up-to-date hosts warning threshold", + "description": "Enter yellow when the value is at or above this threshold but below full coverage" + }, + "upToDateHostsCriticalAt": { + "label": "Up-to-date hosts critical threshold", + "description": "Enter red when the value falls below this threshold" + }, + "totalOutdatedPackagesThresholdMode": { + "label": "Outdated packages threshold mode", + "description": "Compare the outdated-packages count as an absolute value or percentage of total hosts" + }, + "totalOutdatedPackagesWarningAt": { + "label": "Outdated packages warning threshold", + "description": "Enter yellow when the value reaches this threshold" + }, + "totalOutdatedPackagesCriticalAt": { + "label": "Outdated packages critical threshold", + "description": "Enter red when the value reaches this threshold" + }, + "showOsDistribution": { + "label": "Show OS distribution", + "description": "Display a breakdown of monitored hosts by operating system" + }, + "osDistributionLimit": { + "label": "OS distribution limit", + "description": "Maximum number of operating systems to display", + "option": { + "3": "Top 3", + "5": "Top 5", + "10": "Top 10", + "all": "All" + } + }, + "showOsVersion": { + "label": "Show OS version", + "description": "Append the OS version to each distribution label when available" + }, + "showComplianceHero": { + "label": "Show compliance hero", + "description": "Display a compliance ring showing the percentage of up-to-date hosts" + }, + "osDisplayMode": { + "label": "OS display mode", + "description": "Choose how to display the operating system distribution", + "option": { + "bars": "Horizontal bars", + "donut": "Donut chart" + } + } + }, + "error": { + "internalServerError": "Failed to fetch PatchMon data" + } + }, "bazarr": { "name": "Bazarr", "description": "Overview of missing subtitles and health status from your Bazarr instance", diff --git a/packages/widgets/src/health-monitoring/index.ts b/packages/widgets/src/health-monitoring/index.ts index efb0cb40b..fe31514de 100644 --- a/packages/widgets/src/health-monitoring/index.ts +++ b/packages/widgets/src/health-monitoring/index.ts @@ -111,7 +111,7 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon }, ); }, - supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring"), + supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring").filter((kind) => kind !== "patchmon"), errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index 34b5be60b..061fc4b2d 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -39,6 +39,7 @@ import * as networkControllerStatus from "./network-controller/network-status"; import * as networkControllerSummary from "./network-controller/summary"; import * as notebook from "./notebook"; import * as paperlessNgx from "./paperless-ngx"; +import * as patchmon from "./patchmon"; import * as notifications from "./notifications"; import type { WidgetOptionDefinition } from "./options"; import * as releases from "./releases"; @@ -114,6 +115,7 @@ export const widgetImports = { "immich-serverStats": immichServerStats, "immich-albumCarousel": immichAlbumCarousel, paperlessNgx, + patchmon, tracearr, speedtestTracker, uptimeKuma, diff --git a/packages/widgets/src/modals/widget-edit-modal.tsx b/packages/widgets/src/modals/widget-edit-modal.tsx index d1713298c..3edb6ffe5 100644 --- a/packages/widgets/src/modals/widget-edit-modal.tsx +++ b/packages/widgets/src/modals/widget-edit-modal.tsx @@ -19,6 +19,7 @@ import { getInputForType } from "../_inputs"; import { FormProvider, useForm } from "../_inputs/form"; import type { BoardItemAdvancedOptions } from "../../../validation/src/shared"; import type { OptionsBuilderResult } from "../options"; +import { OPTIONS_SUPER_REFINE } from "../options"; import type { IntegrationSelectOption } from "../widget-integration-select"; import { WidgetIntegrationSelect } from "../widget-integration-select"; import { WidgetAdvancedOptionsModal } from "./widget-advanced-options-modal"; @@ -54,24 +55,29 @@ export const WidgetEditModal = createModal>(({ actions, i }); const { definition } = widgetImports[innerProps.kind]; const options = definition.createOptions(innerProps.settings) as Record; + const optionsSuperRefine = (options as Record)[OPTIONS_SUPER_REFINE] as + | ((data: Record, ctx: z.RefinementCtx) => void) + | undefined; + const optionsFieldSchema = objectEntries(options).reduce( + (acc, [key, value]: [string, { type: string; validate?: z.ZodType }]) => { + if (value.validate) { + acc[key] = value.type === "multiText" ? z.array(value.validate).optional() : value.validate; + } + + return acc; + }, + {} as Record>, + ); + const optionsSchema = optionsSuperRefine + ? z.object(optionsFieldSchema).superRefine(optionsSuperRefine) + : z.object(optionsFieldSchema); const form = useForm({ mode: "controlled", initialValues: innerProps.value, validate: schemaResolver( z.object({ - options: z.object( - objectEntries(options).reduce( - (acc, [key, value]: [string, { type: string; validate?: z.ZodType }]) => { - if (value.validate) { - acc[key] = value.type === "multiText" ? z.array(value.validate).optional() : value.validate; - } - - return acc; - }, - {} as Record>, - ), - ), + options: optionsSchema, integrationIds: z.array(z.string()), advancedOptions: z.object({ customCssClasses: z.array(z.string()), diff --git a/packages/widgets/src/options.ts b/packages/widgets/src/options.ts index e4a937e52..c699e68da 100644 --- a/packages/widgets/src/options.ts +++ b/packages/widgets/src/options.ts @@ -2,7 +2,7 @@ import type React from "react"; import type { DraggableAttributes, UniqueIdentifier } from "@dnd-kit/core"; import type { ActionIconProps } from "@mantine/core"; import { z } from "zod/v4"; -import type { ZodType } from "zod/v4"; +import type { RefinementCtx, ZodType } from "zod/v4"; import type { IntegrationKind } from "@homarr/definitions"; @@ -257,9 +257,12 @@ type ConfigurationInput = Partial< Record> >; +export const OPTIONS_SUPER_REFINE = Symbol("optionsSuperRefine"); + const createOptions = ( optionsCallback: (factory: WidgetOptionFactory) => TOptions, configuration?: ConfigurationInput, + optionsSuperRefine?: (data: inferOptionsFromDefinition, ctx: RefinementCtx) => void, ) => { const obj = {} as Record; const options = optionsCallback(optionsFactory); @@ -271,9 +274,18 @@ const createOptions = ( }; } - return obj as { + const result = obj as { [key in keyof TOptions]: TOptions[key] & FieldConfiguration; }; + + if (optionsSuperRefine) { + Object.defineProperty(result, OPTIONS_SUPER_REFINE, { + value: optionsSuperRefine, + enumerable: false, + }); + } + + return result; }; type OptionsBuilder = typeof createOptions; diff --git a/packages/widgets/src/patchmon/component.module.css b/packages/widgets/src/patchmon/component.module.css new file mode 100644 index 000000000..bb7dc2784 --- /dev/null +++ b/packages/widgets/src/patchmon/component.module.css @@ -0,0 +1,371 @@ +.root { + container-type: size; + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + padding: clamp(6px, 3cqi, 14px); + gap: clamp(4px, 2cqi, 10px); + overflow: hidden; +} + +.compactRoot { + padding: clamp(4px, 3cqi, 8px); + gap: 2px; +} + +.content { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + gap: clamp(4px, 2cqi, 8px); +} + +.hero { + display: flex; + align-items: center; + justify-content: space-between; + gap: clamp(8px, 3cqi, 18px); + flex-shrink: 0; + padding-bottom: clamp(4px, 2cqi, 10px); + border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)); +} + +.heroExpanded { + flex: 1; + border-bottom: none; + justify-content: center; + padding-bottom: 0; +} + +.heroTextOnly { + justify-content: center; +} + +.heroRingOnly { + justify-content: center; + padding-bottom: 0; + border-bottom: none; +} + +.heroWithText { + justify-content: center; + width: 100%; +} + +.heroWithText .heroText { + flex: 0 1 auto; +} + +.heroExpanded.heroRingOnly { + flex: 1; +} + +.heroTextOnly .heroText { + align-items: center; + text-align: center; +} + +.heroTextOnly .heroValue { + font-size: clamp(22px, 10cqi, 36px); +} + +.heroText { + display: flex; + flex-direction: column; + gap: clamp(2px, 1cqi, 4px); + min-width: 0; + flex: 1; +} + +.heroLabel { + font-size: clamp(9px, 2.8cqi, 13px); + color: var(--mantine-color-dimmed); + line-height: 1.2; +} + +.heroValue { + font-size: clamp(18px, 8cqi, 32px); + font-weight: 700; + line-height: 1.1; +} + +.ring { + flex-shrink: 0; +} + +.compactPrimary { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + min-height: 0; + min-width: 0; +} + +.statsSection { + flex: 1; + min-height: 0; +} + +.statsSectionCompact { + flex: 0 1 auto; +} + +.grid { + display: grid; + height: 100%; + min-height: 0; + gap: clamp(4px, 2cqi, 10px); + grid-template-columns: repeat(var(--stat-cols, 2), minmax(0, 1fr)); + grid-auto-rows: 1fr; + align-content: stretch; +} + +.statTile { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 0; + min-width: 0; + gap: clamp(2px, 1.5cqmin, 5px); + padding: clamp(4px, 2.5cqi, 10px); + border-radius: var(--mantine-radius-md); + background-color: var(--stat-bg, light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6))); + overflow: hidden; +} + +.statIcon { + flex-shrink: 0; +} + +.statLabel { + font-size: clamp(8px, 2.5cqmin, 12px); + color: var(--mantine-color-dimmed); + line-height: 1.2; + text-align: center; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.statValue { + font-size: clamp(14px, 5.5cqmin, 24px); + font-weight: 700; + line-height: 1.1; + text-align: center; +} + +.compactStatGrid { + display: grid; + flex: 1; + grid-template-columns: repeat(var(--compact-stat-cols, 1), minmax(0, 1fr)); + grid-auto-rows: minmax(0, 1fr); + gap: clamp(3px, 2cqi, 6px); + min-width: 0; + min-height: 0; + width: 100%; +} + +.compactStatTile { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-width: 0; + min-height: 0; + padding: clamp(4px, 3cqi, 8px); + border-radius: var(--mantine-radius-md); + background-color: var(--stat-bg, light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6))); + overflow: hidden; +} + +.compactStatValue { + font-size: clamp(22px, 17cqmin, 42px); + font-weight: 700; + line-height: 1; + text-align: center; +} + +.compactStatLabel { + max-width: 100%; + overflow: hidden; + color: var(--mantine-color-dimmed); + font-size: clamp(8px, 2.4cqmin, 11px); + line-height: 1.1; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} + +.emptyState { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + min-height: 0; + color: var(--mantine-color-dimmed); + font-size: clamp(11px, 3cqi, 14px); + text-align: center; +} + +.osSectionWrapper { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.osSectionWithStats { + padding-top: clamp(4px, 2cqi, 8px); + border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)); +} + +.osSection { + min-height: 0; +} + +.osHeading { + flex-shrink: 0; +} + +.osScroll { + flex: 1; + min-height: 0; +} + +.osRow { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.osLabel { + font-size: clamp(9px, 2.5cqmin, 12px); + line-height: 1.2; +} + +.osBarGroup { + width: 100%; + min-width: 0; +} + +.osBar { + flex: 1; + min-width: 0; +} + +.osCount { + flex: 0 0 auto; + min-width: 1.5rem; + text-align: right; + font-size: clamp(9px, 2.5cqmin, 12px); +} + +.compactOsBars { + display: flex; + flex: 1; + flex-direction: column; + justify-content: center; + gap: clamp(3px, 2cqmin, 5px); + min-width: 0; + min-height: 0; + width: 100%; +} + +.compactOsBar { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.compactOsLabel { + max-width: 100%; + overflow: hidden; + font-size: clamp(9px, 2.5cqmin, 12px); + line-height: 1.1; + text-overflow: ellipsis; + white-space: nowrap; +} + +.osDonutContent { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + min-height: 0; + max-width: 100%; + overflow: visible; +} + +.osDonutContentWithLegend { + justify-content: space-evenly; + gap: clamp(8px, 3cqi, 16px); +} + +.osDonut { + display: flex; + align-items: center; + justify-content: center; + flex: 0 1 auto; + min-width: 0; + max-width: 100%; + overflow: visible; +} + +.osLegend { + display: flex; + flex: 1 1 0; + flex-direction: column; + gap: 4px; + min-width: 0; + max-width: 50%; +} + +.osLegendItem { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + font-size: clamp(9px, 2.2cqmin, 12px); + line-height: 1.2; +} + +.osLegendSwatch { + width: 8px; + height: 8px; + flex: 0 0 auto; + border-radius: var(--mantine-radius-xl); + background-color: var(--os-color); +} + +.osLegendLabel { + min-width: 0; + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.osLegendValue { + flex: 0 0 auto; +} + +.footer { + flex-shrink: 0; + max-width: 100%; + overflow: hidden; + font-size: clamp(9px, 2.2cqmin, 12px); + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} + +.compactFooter { + font-size: clamp(8px, 2.2cqmin, 10px); +} diff --git a/packages/widgets/src/patchmon/component.tsx b/packages/widgets/src/patchmon/component.tsx new file mode 100644 index 000000000..b59224622 --- /dev/null +++ b/packages/widgets/src/patchmon/component.tsx @@ -0,0 +1,472 @@ +"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 { + resolveStatColor, + severityToIconColor, + severityToMantineBgColor, + severityToMantineColor, + type PatchMonStatKey, +} 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 = { + 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 ; + + const statValues: Record = { + 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" ? ( + + ) : compactPrimaryContent === "os" ? ( +
+ +
+ ) : compactPrimaryContent === "stats" ? ( + = 180 || height >= 140} + getLabel={t} + /> + ) : null; + + if (!compactContent) { + return ( +
+
+ + — + +
+
+ ); + } + + return ( +
+ {compactContent} + {showCompactFooter && ( + + {footerText} + + )} +
+ ); + } + + if (!hasContent) { + return ( +
+
+ + — + +
+
+ ); + } + + const statGridContent = showStats && ( +
+
+ {visibleStatKeys.map((statKey) => { + const severity = resolveStatColor(statKey, statValues[statKey], colorContext, options); + const Icon = statIcons[statKey]; + + const tile = ( +
+ {showIcons && ( + + )} + + + {statValues[statKey]} + + + {showLabels && {t(statKey)}} +
+ ); + + if (!showLabels) { + return ( + + {tile} + + ); + } + + return tile; + })} +
+
+ ); + + const osContent = showOsSection && ( +
0 ? classes.osSectionWithStats : ""}`}> + +
+ ); + + const needsScroll = layout === "mini" || layout === "compact"; + + return ( +
+ {showHero && ( + + )} + + {(statGridContent || osContent) && + (needsScroll ? ( + +
+ {statGridContent} + {osContent} +
+
+ ) : ( +
+ {statGridContent} + {osContent} +
+ ))} + + {showFooter && ( + + {footerText} + + )} +
+ ); +} + +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 ( +
+ {showText && ( +
+ {getLabel("compliance")} + + {getLabel("complianceRatio", { upToDate: upToDateHosts, total: totalHosts })} + +
+ )} + + {compliancePercent}% + + } + /> +
+ ); +} + +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; + colorContext: { totalHosts: number }; + options: WidgetComponentProps<"patchmon">["options"]; + columns: number; + showIcon: boolean; + getLabel: (key: PatchMonStatKey) => string; +}) { + return ( +
+ {statKeys.map((statKey) => { + const severity = resolveStatColor(statKey, statValues[statKey], colorContext, options); + const Icon = statIcons[statKey]; + const label = getLabel(statKey); + + return ( + +
+ {showIcon && ( + + )} + + + {statValues[statKey]} + + + {label} +
+
+ ); + })} +
+ ); +} diff --git a/packages/widgets/src/patchmon/index.ts b/packages/widgets/src/patchmon/index.ts new file mode 100644 index 000000000..6f97be83f --- /dev/null +++ b/packages/widgets/src/patchmon/index.ts @@ -0,0 +1,234 @@ +import { IconServerOff, IconShieldCheck } from "@tabler/icons-react"; +import { z } from "zod/v4"; + +import { createWidgetDefinition } from "../definition"; +import { optionsBuilder } from "../options"; +import { PATCHMON_STAT_COLOR_PRESETS } from "./stat-colors"; +import { patchmonOptionsSuperRefine } from "./threshold-validation"; + +export const { definition, componentLoader } = createWidgetDefinition("patchmon", { + icon: IconShieldCheck, + supportedIntegrations: ["patchmon"], + integrationsRequired: true, + createOptions() { + return optionsBuilder.from( + (factory) => ({ + showComplianceHero: factory.switch({ + defaultValue: true, + withDescription: true, + }), + showTotalHosts: factory.switch({ + defaultValue: true, + withDescription: true, + }), + showHostsNeedingUpdates: factory.switch({ + defaultValue: true, + withDescription: true, + }), + showSecurityUpdates: factory.switch({ + defaultValue: true, + withDescription: true, + }), + showUpToDateHosts: factory.switch({ + defaultValue: false, + withDescription: true, + }), + showHostsWithSecurityUpdates: factory.switch({ + defaultValue: false, + withDescription: true, + }), + showRecentUpdates24h: factory.switch({ + defaultValue: false, + withDescription: true, + }), + showTotalOutdatedPackages: factory.switch({ + defaultValue: false, + withDescription: true, + }), + showTotalRepos: factory.switch({ + defaultValue: false, + withDescription: true, + }), + enableThresholdColors: factory.switch({ + defaultValue: true, + withDescription: true, + }), + useCustomThresholds: factory.switch({ + defaultValue: false, + withDescription: true, + }), + hostsNeedingUpdatesThresholdMode: factory.select({ + defaultValue: PATCHMON_STAT_COLOR_PRESETS.hostsNeedingUpdates.mode, + withDescription: true, + options: [ + { value: "absolute", label: (t) => t("widget.patchmon.option.thresholdMode.option.absolute") }, + { value: "percent", label: (t) => t("widget.patchmon.option.thresholdMode.option.percent") }, + ] as const, + }), + hostsNeedingUpdatesWarningAt: factory.number({ + defaultValue: PATCHMON_STAT_COLOR_PRESETS.hostsNeedingUpdates.warningAt, + withDescription: true, + step: 1, + validate: z.number().int().min(0), + }), + hostsNeedingUpdatesCriticalAt: factory.number({ + defaultValue: PATCHMON_STAT_COLOR_PRESETS.hostsNeedingUpdates.criticalAt, + withDescription: true, + step: 1, + validate: z.number().int().min(0), + }), + securityUpdatesThresholdMode: factory.select({ + defaultValue: PATCHMON_STAT_COLOR_PRESETS.securityUpdates.mode, + withDescription: true, + options: [ + { value: "absolute", label: (t) => t("widget.patchmon.option.thresholdMode.option.absolute") }, + { value: "percent", label: (t) => t("widget.patchmon.option.thresholdMode.option.percent") }, + ] as const, + }), + securityUpdatesWarningAt: factory.number({ + defaultValue: PATCHMON_STAT_COLOR_PRESETS.securityUpdates.warningAt, + withDescription: true, + step: 1, + validate: z.number().int().min(0), + }), + securityUpdatesCriticalAt: factory.number({ + defaultValue: PATCHMON_STAT_COLOR_PRESETS.securityUpdates.criticalAt, + withDescription: true, + step: 1, + validate: z.number().int().min(0), + }), + hostsWithSecurityUpdatesThresholdMode: factory.select({ + defaultValue: PATCHMON_STAT_COLOR_PRESETS.hostsWithSecurityUpdates.mode, + withDescription: true, + options: [ + { value: "absolute", label: (t) => t("widget.patchmon.option.thresholdMode.option.absolute") }, + { value: "percent", label: (t) => t("widget.patchmon.option.thresholdMode.option.percent") }, + ] as const, + }), + hostsWithSecurityUpdatesWarningAt: factory.number({ + defaultValue: PATCHMON_STAT_COLOR_PRESETS.hostsWithSecurityUpdates.warningAt, + withDescription: true, + step: 1, + validate: z.number().int().min(0), + }), + hostsWithSecurityUpdatesCriticalAt: factory.number({ + defaultValue: PATCHMON_STAT_COLOR_PRESETS.hostsWithSecurityUpdates.criticalAt, + withDescription: true, + step: 1, + validate: z.number().int().min(0), + }), + upToDateHostsThresholdMode: factory.select({ + defaultValue: PATCHMON_STAT_COLOR_PRESETS.upToDateHosts.mode, + withDescription: true, + options: [ + { value: "absolute", label: (t) => t("widget.patchmon.option.thresholdMode.option.absolute") }, + { value: "percent", label: (t) => t("widget.patchmon.option.thresholdMode.option.percent") }, + ] as const, + }), + upToDateHostsWarningAt: factory.number({ + defaultValue: PATCHMON_STAT_COLOR_PRESETS.upToDateHosts.warningAt, + withDescription: true, + step: 1, + validate: z.number().int().min(0), + }), + upToDateHostsCriticalAt: factory.number({ + defaultValue: PATCHMON_STAT_COLOR_PRESETS.upToDateHosts.criticalAt, + withDescription: true, + step: 1, + validate: z.number().int().min(0), + }), + totalOutdatedPackagesThresholdMode: factory.select({ + defaultValue: PATCHMON_STAT_COLOR_PRESETS.totalOutdatedPackages.mode, + withDescription: true, + options: [ + { value: "absolute", label: (t) => t("widget.patchmon.option.thresholdMode.option.absolute") }, + { value: "percent", label: (t) => t("widget.patchmon.option.thresholdMode.option.percent") }, + ] as const, + }), + totalOutdatedPackagesWarningAt: factory.number({ + defaultValue: PATCHMON_STAT_COLOR_PRESETS.totalOutdatedPackages.warningAt, + withDescription: true, + step: 1, + validate: z.number().int().min(0), + }), + totalOutdatedPackagesCriticalAt: factory.number({ + defaultValue: PATCHMON_STAT_COLOR_PRESETS.totalOutdatedPackages.criticalAt, + withDescription: true, + step: 1, + validate: z.number().int().min(0), + }), + showOsDistribution: factory.switch({ + defaultValue: false, + withDescription: true, + }), + osDisplayMode: factory.select({ + defaultValue: "bars", + withDescription: true, + options: [ + { value: "bars", label: (t) => t("widget.patchmon.option.osDisplayMode.option.bars") }, + { value: "donut", label: (t) => t("widget.patchmon.option.osDisplayMode.option.donut") }, + ] as const, + }), + osDistributionLimit: factory.select({ + defaultValue: "0", + withDescription: true, + options: [ + { value: "3", label: (t) => t("widget.patchmon.option.osDistributionLimit.option.3") }, + { value: "5", label: (t) => t("widget.patchmon.option.osDistributionLimit.option.5") }, + { value: "10", label: (t) => t("widget.patchmon.option.osDistributionLimit.option.10") }, + { value: "0", label: (t) => t("widget.patchmon.option.osDistributionLimit.option.all") }, + ] as const, + }), + showOsVersion: factory.switch({ + defaultValue: true, + withDescription: true, + }), + }), + { + useCustomThresholds: { + shouldHide: (options) => !options.enableThresholdColors, + }, + hostsNeedingUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + hostsNeedingUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + hostsNeedingUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + securityUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + securityUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + securityUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + hostsWithSecurityUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + hostsWithSecurityUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + hostsWithSecurityUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + upToDateHostsThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + upToDateHostsWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + upToDateHostsCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + totalOutdatedPackagesThresholdMode: { + shouldHide: (options) => + !options.enableThresholdColors || !options.useCustomThresholds || !options.showTotalOutdatedPackages, + }, + totalOutdatedPackagesWarningAt: { + shouldHide: (options) => + !options.enableThresholdColors || !options.useCustomThresholds || !options.showTotalOutdatedPackages, + }, + totalOutdatedPackagesCriticalAt: { + shouldHide: (options) => + !options.enableThresholdColors || !options.useCustomThresholds || !options.showTotalOutdatedPackages, + }, + osDisplayMode: { + shouldHide: (options) => !options.showOsDistribution, + }, + osDistributionLimit: { + shouldHide: (options) => !options.showOsDistribution, + }, + showOsVersion: { + shouldHide: (options) => !options.showOsDistribution, + }, + }, + patchmonOptionsSuperRefine, + ); + }, + errors: { + INTERNAL_SERVER_ERROR: { + icon: IconServerOff, + message: (t) => t("widget.patchmon.error.internalServerError"), + }, + }, +}).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/patchmon/layout-utils.ts b/packages/widgets/src/patchmon/layout-utils.ts new file mode 100644 index 000000000..dd8ca1282 --- /dev/null +++ b/packages/widgets/src/patchmon/layout-utils.ts @@ -0,0 +1,15 @@ +const gridColsByWidth = [ + { minWidth: 500, cols: 4 }, + { minWidth: 380, cols: 3 }, + { minWidth: 220, cols: 2 }, + { minWidth: 0, cols: 1 }, +] as const; + +export function getGridCols(width: number): number { + const match = gridColsByWidth.find(({ minWidth }) => width >= minWidth); + return match?.cols ?? 1; +} + +export function shouldShowComplianceHeroText(width: number): boolean { + return getGridCols(width) >= 2; +} diff --git a/packages/widgets/src/patchmon/os-distribution-section.tsx b/packages/widgets/src/patchmon/os-distribution-section.tsx new file mode 100644 index 000000000..cb21cb203 --- /dev/null +++ b/packages/widgets/src/patchmon/os-distribution-section.tsx @@ -0,0 +1,167 @@ +"use client"; + +import type { CSSProperties } from "react"; +import { DonutChart } from "@mantine/charts"; +import { Group, Progress, ScrollArea, Stack, Text } from "@mantine/core"; + +import type { PatchMonOsDistributionEntry } from "@homarr/integrations"; +import { useScopedI18n } from "@homarr/translation/client"; + +import classes from "./component.module.css"; + +const OS_COLORS = [ + "blue.6", + "orange.6", + "green.6", + "grape.6", + "cyan.6", + "pink.6", + "yellow.6", + "teal.6", +] as const; + +interface OsDistributionSectionProps { + entries: PatchMonOsDistributionEntry[]; + limit: number; + showOsVersion: boolean; + displayMode: "bars" | "donut"; + width: number; + height: number; + showLegend: boolean; + compact?: boolean; +} + +export const formatOsLabel = (entry: PatchMonOsDistributionEntry, showVersion: boolean) => + showVersion && entry.osVersion ? `${entry.name} ${entry.osVersion}` : entry.name; + +const getMantineColorVariable = (color: string) => `var(--mantine-color-${color.replace(".", "-")})`; + +export function OsDistributionSection({ + entries, + limit, + showOsVersion, + displayMode, + width, + height, + showLegend, + compact = false, +}: OsDistributionSectionProps) { + const t = useScopedI18n("widget.patchmon"); + + if (entries.length === 0) { + return null; + } + + const visibleEntries = limit > 0 ? entries.slice(0, limit) : entries; + + if (displayMode === "donut") { + const chartData = visibleEntries.map((entry, i) => ({ + name: formatOsLabel(entry, showOsVersion), + value: entry.count, + color: OS_COLORS[i % OS_COLORS.length] as string, + })); + const donutSize = compact + ? Math.max(36, Math.min(width - 18, height - 10, 96)) + : Math.max(48, Math.min(width - 24, height * 0.55, showLegend ? 140 : 180)); + + return ( + + {!compact && ( + + {t("osDistribution")} + + )} +
+
+ +
+ {showLegend && ( +
+ {chartData.map((entry) => ( +
+ + + {entry.name} + + + {entry.value} + +
+ ))} +
+ )} +
+
+ ); + } + + 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 ( +
+ {compactEntries.map((entry, i) => ( +
+ + {formatOsLabel(entry, showOsVersion)} + + + + + {entry.count} + + +
+ ))} +
+ ); + } + + return ( + + + {t("osDistribution")} + + + + {visibleEntries.map((entry, i) => { + const barValue = (entry.count / maxCount) * 100; + const barColor = OS_COLORS[i % OS_COLORS.length] as string; + return ( +
+ + {formatOsLabel(entry, showOsVersion)} + + + + + {entry.count} + + +
+ ); + })} +
+
+
+ ); +} diff --git a/packages/widgets/src/patchmon/stat-colors.ts b/packages/widgets/src/patchmon/stat-colors.ts new file mode 100644 index 000000000..7ecd035ac --- /dev/null +++ b/packages/widgets/src/patchmon/stat-colors.ts @@ -0,0 +1,215 @@ +import type { WidgetComponentProps } from "../definition"; +import { clampPercentThreshold } from "./threshold-validation"; + +export type PatchMonStatKey = + | "totalHosts" + | "hostsNeedingUpdates" + | "securityUpdates" + | "upToDateHosts" + | "hostsWithSecurityUpdates" + | "recentUpdates24h" + | "totalOutdatedPackages" + | "totalRepos"; + +export type ColorablePatchMonStatKey = Extract< + PatchMonStatKey, + | "hostsNeedingUpdates" + | "securityUpdates" + | "hostsWithSecurityUpdates" + | "upToDateHosts" + | "totalOutdatedPackages" +>; + +export type StatSeverity = "green" | "yellow" | "red" | "neutral"; +export type ThresholdMode = "absolute" | "percent"; + +export interface StatColorContext { + totalHosts: number; +} + +export interface StatThresholdRule { + direction: "lowerIsBetter" | "higherIsBetter"; + mode: ThresholdMode; + warningAt: number; + criticalAt: number; + skipYellow?: boolean; +} + +type PatchMonColorOptions = WidgetComponentProps<"patchmon">["options"]; + +export const COLORABLE_PATCHMON_STATS = [ + "hostsNeedingUpdates", + "securityUpdates", + "hostsWithSecurityUpdates", + "upToDateHosts", + "totalOutdatedPackages", +] as const satisfies readonly ColorablePatchMonStatKey[]; + +export const NEUTRAL_PATCHMON_STATS = ["totalHosts", "totalRepos", "recentUpdates24h"] as const; + +export const PATCHMON_STAT_COLOR_PRESETS: Record = { + 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)`; +}; diff --git a/packages/widgets/src/patchmon/test/layout-utils.spec.ts b/packages/widgets/src/patchmon/test/layout-utils.spec.ts new file mode 100644 index 000000000..b2a486c67 --- /dev/null +++ b/packages/widgets/src/patchmon/test/layout-utils.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "vitest"; + +import { getGridCols, shouldShowComplianceHeroText } from "../layout-utils"; + +describe("getGridCols", () => { + test("returns 1 column for narrow widths", () => { + expect(getGridCols(128)).toBe(1); + expect(getGridCols(219)).toBe(1); + }); + + test("returns 2 columns at 220px breakpoint", () => { + expect(getGridCols(220)).toBe(2); + expect(getGridCols(256)).toBe(2); + }); + + test("returns 3 columns at 380px breakpoint", () => { + expect(getGridCols(380)).toBe(3); + }); + + test("returns 4 columns at 500px breakpoint", () => { + expect(getGridCols(500)).toBe(4); + }); +}); + +describe("shouldShowComplianceHeroText", () => { + test("returns false for 1-column-wide layouts", () => { + expect(shouldShowComplianceHeroText(128)).toBe(false); + expect(shouldShowComplianceHeroText(219)).toBe(false); + }); + + test("returns true for 2+-column-wide layouts", () => { + expect(shouldShowComplianceHeroText(220)).toBe(true); + expect(shouldShowComplianceHeroText(256)).toBe(true); + expect(shouldShowComplianceHeroText(384)).toBe(true); + }); +}); diff --git a/packages/widgets/src/patchmon/test/stat-colors.spec.ts b/packages/widgets/src/patchmon/test/stat-colors.spec.ts new file mode 100644 index 000000000..12596f499 --- /dev/null +++ b/packages/widgets/src/patchmon/test/stat-colors.spec.ts @@ -0,0 +1,171 @@ +import { describe, expect, test } from "vitest"; + +import type { WidgetComponentProps } from "../../definition"; +import { resolveStatColor } from "../stat-colors"; + +const defaultOptions = { + enableThresholdColors: true, + useCustomThresholds: false, + showTotalHosts: true, + showHostsNeedingUpdates: true, + showSecurityUpdates: true, + showUpToDateHosts: false, + showHostsWithSecurityUpdates: false, + showRecentUpdates24h: false, + showTotalOutdatedPackages: false, + showTotalRepos: false, + showOsDistribution: false, + osDistributionLimit: "0", + showOsVersion: true, + hostsNeedingUpdatesThresholdMode: "absolute", + hostsNeedingUpdatesWarningAt: 1, + hostsNeedingUpdatesCriticalAt: 5, + securityUpdatesThresholdMode: "absolute", + securityUpdatesWarningAt: 1, + securityUpdatesCriticalAt: 10, + hostsWithSecurityUpdatesThresholdMode: "absolute", + hostsWithSecurityUpdatesWarningAt: 1, + hostsWithSecurityUpdatesCriticalAt: 1, + upToDateHostsThresholdMode: "percent", + upToDateHostsWarningAt: 90, + upToDateHostsCriticalAt: 90, + totalOutdatedPackagesThresholdMode: "absolute", + totalOutdatedPackagesWarningAt: 1, + totalOutdatedPackagesCriticalAt: 50, + showComplianceHero: true, + osDisplayMode: "bars", +} satisfies WidgetComponentProps<"patchmon">["options"]; + +const context = { totalHosts: 100 }; + +describe("resolveStatColor presets", () => { + test("hostsNeedingUpdates uses green, yellow, and red boundaries", () => { + expect(resolveStatColor("hostsNeedingUpdates", 0, context, defaultOptions)).toBe("green"); + expect(resolveStatColor("hostsNeedingUpdates", 1, context, defaultOptions)).toBe("yellow"); + expect(resolveStatColor("hostsNeedingUpdates", 4, context, defaultOptions)).toBe("yellow"); + expect(resolveStatColor("hostsNeedingUpdates", 5, context, defaultOptions)).toBe("red"); + }); + + test("securityUpdates uses green and yellow for low counts", () => { + expect(resolveStatColor("securityUpdates", 0, context, defaultOptions)).toBe("green"); + expect(resolveStatColor("securityUpdates", 1, context, defaultOptions)).toBe("yellow"); + expect(resolveStatColor("securityUpdates", 10, context, defaultOptions)).toBe("red"); + }); + + test("hostsWithSecurityUpdates skips yellow", () => { + expect(resolveStatColor("hostsWithSecurityUpdates", 0, context, defaultOptions)).toBe("green"); + expect(resolveStatColor("hostsWithSecurityUpdates", 1, context, defaultOptions)).toBe("red"); + }); + + test("upToDateHosts uses percent of total hosts", () => { + expect(resolveStatColor("upToDateHosts", 100, context, defaultOptions)).toBe("green"); + expect(resolveStatColor("upToDateHosts", 95, context, defaultOptions)).toBe("green"); + expect(resolveStatColor("upToDateHosts", 90, context, defaultOptions)).toBe("green"); + expect(resolveStatColor("upToDateHosts", 80, context, defaultOptions)).toBe("red"); + }); + + test("neutral stats ignore threshold coloring", () => { + expect(resolveStatColor("totalHosts", 100, context, defaultOptions)).toBe("neutral"); + expect(resolveStatColor("totalRepos", 12, context, defaultOptions)).toBe("neutral"); + expect(resolveStatColor("recentUpdates24h", 34, context, defaultOptions)).toBe("neutral"); + }); +}); + +describe("resolveStatColor options", () => { + test("returns neutral when threshold colors are disabled", () => { + expect( + resolveStatColor("hostsNeedingUpdates", 10, context, { + ...defaultOptions, + enableThresholdColors: false, + }), + ).toBe("neutral"); + }); + + test("supports custom absolute thresholds", () => { + expect( + resolveStatColor("hostsNeedingUpdates", 4, context, { + ...defaultOptions, + useCustomThresholds: true, + hostsNeedingUpdatesWarningAt: 5, + hostsNeedingUpdatesCriticalAt: 10, + }), + ).toBe("green"); + }); + + test("supports custom percent thresholds for lower-is-better stats", () => { + expect( + resolveStatColor("hostsNeedingUpdates", 5, context, { + ...defaultOptions, + useCustomThresholds: true, + hostsNeedingUpdatesThresholdMode: "percent", + hostsNeedingUpdatesWarningAt: 10, + hostsNeedingUpdatesCriticalAt: 20, + }), + ).toBe("green"); + + expect( + resolveStatColor("hostsNeedingUpdates", 15, context, { + ...defaultOptions, + useCustomThresholds: true, + hostsNeedingUpdatesThresholdMode: "percent", + hostsNeedingUpdatesWarningAt: 10, + hostsNeedingUpdatesCriticalAt: 20, + }), + ).toBe("yellow"); + }); + + test("handles zero total hosts for percent-based stats", () => { + expect(resolveStatColor("upToDateHosts", 0, { totalHosts: 0 }, defaultOptions)).toBe("green"); + expect( + resolveStatColor("hostsNeedingUpdates", 1, { totalHosts: 0 }, { + ...defaultOptions, + useCustomThresholds: true, + hostsNeedingUpdatesThresholdMode: "percent", + }), + ).toBe("red"); + }); + + test("clamps invalid percent thresholds when resolving colors", () => { + expect( + resolveStatColor("hostsNeedingUpdates", 50, context, { + ...defaultOptions, + useCustomThresholds: true, + hostsNeedingUpdatesThresholdMode: "percent", + hostsNeedingUpdatesWarningAt: 40, + hostsNeedingUpdatesCriticalAt: 200, + }), + ).toBe("yellow"); + }); + + test("supports custom absolute thresholds for higher-is-better stats", () => { + expect( + resolveStatColor("upToDateHosts", 85, context, { + ...defaultOptions, + useCustomThresholds: true, + upToDateHostsThresholdMode: "absolute", + upToDateHostsWarningAt: 80, + upToDateHostsCriticalAt: 60, + }), + ).toBe("green"); + + expect( + resolveStatColor("upToDateHosts", 70, context, { + ...defaultOptions, + useCustomThresholds: true, + upToDateHostsThresholdMode: "absolute", + upToDateHostsWarningAt: 80, + upToDateHostsCriticalAt: 60, + }), + ).toBe("yellow"); + + expect( + resolveStatColor("upToDateHosts", 50, context, { + ...defaultOptions, + useCustomThresholds: true, + upToDateHostsThresholdMode: "absolute", + upToDateHostsWarningAt: 80, + upToDateHostsCriticalAt: 60, + }), + ).toBe("red"); + }); +}); diff --git a/packages/widgets/src/patchmon/test/threshold-validation.spec.ts b/packages/widgets/src/patchmon/test/threshold-validation.spec.ts new file mode 100644 index 000000000..00dafba3b --- /dev/null +++ b/packages/widgets/src/patchmon/test/threshold-validation.spec.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from "vitest"; +import { z } from "zod/v4"; + +import { clampPercentThreshold, patchmonOptionsSuperRefine } from "../threshold-validation"; + +const baseOptions = { + enableThresholdColors: true, + useCustomThresholds: true, + hostsNeedingUpdatesThresholdMode: "percent", + hostsNeedingUpdatesWarningAt: 10, + hostsNeedingUpdatesCriticalAt: 20, + securityUpdatesThresholdMode: "absolute", + securityUpdatesWarningAt: 1, + securityUpdatesCriticalAt: 10, + hostsWithSecurityUpdatesThresholdMode: "absolute", + hostsWithSecurityUpdatesWarningAt: 1, + hostsWithSecurityUpdatesCriticalAt: 1, + upToDateHostsThresholdMode: "percent", + upToDateHostsWarningAt: 90, + upToDateHostsCriticalAt: 90, + totalOutdatedPackagesThresholdMode: "absolute", + totalOutdatedPackagesWarningAt: 1, + totalOutdatedPackagesCriticalAt: 50, +}; + +const optionsSchema = z.object({ + hostsNeedingUpdatesWarningAt: z.number().int().min(0), + hostsNeedingUpdatesCriticalAt: z.number().int().min(0), + securityUpdatesWarningAt: z.number().int().min(0), + securityUpdatesCriticalAt: z.number().int().min(0), + hostsWithSecurityUpdatesWarningAt: z.number().int().min(0), + hostsWithSecurityUpdatesCriticalAt: z.number().int().min(0), + upToDateHostsWarningAt: z.number().int().min(0), + upToDateHostsCriticalAt: z.number().int().min(0), + totalOutdatedPackagesWarningAt: z.number().int().min(0), + totalOutdatedPackagesCriticalAt: z.number().int().min(0), + enableThresholdColors: z.boolean(), + useCustomThresholds: z.boolean(), + hostsNeedingUpdatesThresholdMode: z.enum(["absolute", "percent"]), + securityUpdatesThresholdMode: z.enum(["absolute", "percent"]), + hostsWithSecurityUpdatesThresholdMode: z.enum(["absolute", "percent"]), + upToDateHostsThresholdMode: z.enum(["absolute", "percent"]), + totalOutdatedPackagesThresholdMode: z.enum(["absolute", "percent"]), +}).superRefine(patchmonOptionsSuperRefine); + +describe("patchmonOptionsSuperRefine", () => { + test("accepts percent thresholds within 0-100", () => { + const result = optionsSchema.safeParse(baseOptions); + + expect(result.success).toBe(true); + }); + + test("rejects percent thresholds above 100", () => { + const result = optionsSchema.safeParse({ + ...baseOptions, + hostsNeedingUpdatesWarningAt: 101, + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some((issue) => issue.path.includes("hostsNeedingUpdatesWarningAt"))).toBe(true); + } + }); + + test("allows absolute thresholds above 100", () => { + const result = optionsSchema.safeParse({ + ...baseOptions, + hostsNeedingUpdatesThresholdMode: "absolute", + hostsNeedingUpdatesWarningAt: 150, + hostsNeedingUpdatesCriticalAt: 200, + }); + + expect(result.success).toBe(true); + }); + + test("skips validation when custom thresholds are disabled", () => { + const result = optionsSchema.safeParse({ + ...baseOptions, + useCustomThresholds: false, + hostsNeedingUpdatesWarningAt: 500, + }); + + expect(result.success).toBe(true); + }); +}); + +describe("clampPercentThreshold", () => { + test("clamps percent values to 0-100", () => { + expect(clampPercentThreshold(150, "percent")).toBe(100); + expect(clampPercentThreshold(-5, "percent")).toBe(0); + expect(clampPercentThreshold(42.9, "percent")).toBe(42); + }); + + test("leaves absolute values unchanged", () => { + expect(clampPercentThreshold(150, "absolute")).toBe(150); + }); +}); diff --git a/packages/widgets/src/patchmon/threshold-validation.ts b/packages/widgets/src/patchmon/threshold-validation.ts new file mode 100644 index 000000000..965c8f263 --- /dev/null +++ b/packages/widgets/src/patchmon/threshold-validation.ts @@ -0,0 +1,59 @@ +import type { RefinementCtx } from "zod/v4"; +import { z } from "zod/v4"; + +import type { ThresholdMode } from "./stat-colors"; + +const percentThresholdSchema = z.number().int().min(0).max(100); + +export const PATCHMON_THRESHOLD_PAIRS = [ + { + mode: "hostsNeedingUpdatesThresholdMode", + warningAt: "hostsNeedingUpdatesWarningAt", + criticalAt: "hostsNeedingUpdatesCriticalAt", + }, + { + mode: "securityUpdatesThresholdMode", + warningAt: "securityUpdatesWarningAt", + criticalAt: "securityUpdatesCriticalAt", + }, + { + mode: "hostsWithSecurityUpdatesThresholdMode", + warningAt: "hostsWithSecurityUpdatesWarningAt", + criticalAt: "hostsWithSecurityUpdatesCriticalAt", + }, + { + mode: "upToDateHostsThresholdMode", + warningAt: "upToDateHostsWarningAt", + criticalAt: "upToDateHostsCriticalAt", + }, + { + mode: "totalOutdatedPackagesThresholdMode", + warningAt: "totalOutdatedPackagesWarningAt", + criticalAt: "totalOutdatedPackagesCriticalAt", + }, +] as const; + +export const clampPercentThreshold = (value: number, mode: ThresholdMode): number => + mode === "percent" ? Math.min(100, Math.max(0, Math.trunc(value))) : value; + +export const patchmonOptionsSuperRefine = (data: Record, 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] }); + } + } + } + } +};