feat(integrations): add PatchMon integration and widget (#6119)

Co-authored-by: Thomas <thomas@ajnart.fr>
This commit is contained in:
Josh PrestonandThomas authored and GitHub committed 2026-07-10 17:48:43 +02:00
1 parent 174c5f98f8
commit 33395ae226
41 files changed
+2982 -17

No files matched your search

@@ -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);
}
+472
View File
@@ -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<PatchMonStatKey, TablerIcon> = {
totalHosts: IconServer,
hostsNeedingUpdates: IconRefreshAlert,
securityUpdates: IconShieldExclamation,
upToDateHosts: IconCircleCheck,
hostsWithSecurityUpdates: IconShieldX,
recentUpdates24h: IconClockCheck,
totalOutdatedPackages: IconPackages,
totalRepos: IconDatabase,
};
const iconSizeByWidth = [
{ minWidth: 320, size: 22 },
{ minWidth: 200, size: 18 },
{ minWidth: 0, size: 16 },
] as const;
const ringSizeByWidth = [
{ minWidth: 400, size: 88 },
{ minWidth: 320, size: 72 },
{ minWidth: 220, size: 56 },
{ minWidth: 0, size: 40 },
] as const;
type LayoutMode = "mini" | "compact" | "comfortable" | "spacious";
type CompactPrimaryContent = "hero" | "os" | "stats" | null;
const getLayoutMode = (width: number, height: number): LayoutMode => {
const minDim = Math.min(width, height);
if (minDim < 140) return "mini";
if (minDim < 220) return "compact";
if (width >= 380 && height >= 350) return "spacious";
return "comfortable";
};
export default function PatchMonWidget({ integrationIds, options, width, height }: WidgetComponentProps<"patchmon">) {
const t = useScopedI18n("widget.patchmon");
const integrationId = integrationIds[0] ?? "";
const { data: stats } = clientApi.widget.patchmon.getStats.useQuery(
{ integrationId },
{ staleTime: 60 * 1000 },
);
if (!stats) return <WidgetEmptyState />;
const statValues: Record<PatchMonStatKey, number> = {
totalHosts: stats.totalHosts,
hostsNeedingUpdates: stats.hostsNeedingUpdates,
securityUpdates: stats.securityUpdates,
upToDateHosts: stats.upToDateHosts,
hostsWithSecurityUpdates: stats.hostsWithSecurityUpdates,
recentUpdates24h: stats.recentUpdates24h,
totalOutdatedPackages: stats.totalOutdatedPackages,
totalRepos: stats.totalRepos,
};
const visibleStatKeys = Object.entries(statVisibilityByOption)
.filter(([optionKey]) => options[optionKey as keyof typeof options])
.map(([, statKey]) => statKey);
const colorContext = { totalHosts: stats.totalHosts };
const layout = getLayoutMode(width, height);
const isLandscape = width / height > 2.5;
const isTinySquare = width < 160 && height < 160;
const isShortLandscape = width >= 160 && height < 160;
const isCompactSurface = isTinySquare || isShortLandscape;
const isNarrow = width < 180;
const osDisplayMode = options.osDisplayMode as "bars" | "donut";
const showOsData = options.showOsDistribution && stats.osDistribution.length > 0;
const showIcons = layout !== "mini";
const showLabels = layout !== "mini";
const showHero = options.showComplianceHero && (!isLandscape || isCompactSurface) && width >= 90 && height >= 90;
const showHeroText = showHero && shouldShowComplianceHeroText(width);
const showHeroRingOnly = showHero && !showHeroText;
const compactPrimaryContent = isCompactSurface
? getCompactPrimaryContent({
showHero,
showOsDistribution: showOsData,
visibleStatKeys,
})
: null;
const showStats = visibleStatKeys.length > 0 && !isCompactSurface;
const showOsSection = showOsData && !isCompactSurface && height > 200;
const showCompactFooter = isCompactSurface && height >= 110;
const showFooter = isCompactSurface ? showCompactFooter : !isLandscape && height > 220;
const showOsLegend = osDisplayMode === "donut" && width >= 380 && height >= 300;
const hasContent = compactPrimaryContent !== null || showStats || showOsSection || showHero;
const gridCols = isLandscape
? Math.min(visibleStatKeys.length, 4)
: Math.min(getGridCols(width), visibleStatKeys.length || 1);
const iconSize = getIconSize(width);
const ringSize = getRingSize(width, height, showHeroRingOnly);
const compliancePercent =
stats.totalHosts > 0 ? Math.round((stats.upToDateHosts / stats.totalHosts) * 100) : 100;
const complianceSeverity = resolveStatColor("upToDateHosts", stats.upToDateHosts, colorContext, options);
const complianceColor = complianceSeverity === "neutral" ? "blue" : complianceSeverity;
const timeAgo = dayjs(stats.lastUpdated).fromNow();
const compactTimeAgo = getCompactTimeAgo(stats.lastUpdated);
const footerText = isNarrow ? compactTimeAgo : t("lastUpdated", { time: timeAgo });
if (isCompactSurface) {
const compactContentHeight = showCompactFooter ? height - 18 : height;
const compactStatCols = Math.max(1, Math.floor(width / 120));
const compactStatKeys = visibleStatKeys.slice(0, Math.min(visibleStatKeys.length, compactStatCols * 2));
const compactContent =
compactPrimaryContent === "hero" ? (
<ComplianceHero
compliancePercent={compliancePercent}
complianceColor={complianceColor}
upToDateHosts={stats.upToDateHosts}
totalHosts={stats.totalHosts}
ringSize={getRingSize(width, compactContentHeight, showHeroRingOnly)}
showText={showHeroText}
expanded
compact
getLabel={t}
/>
) : compactPrimaryContent === "os" ? (
<div className={classes.compactPrimary}>
<OsDistributionSection
entries={stats.osDistribution}
limit={Number(options.osDistributionLimit)}
showOsVersion={options.showOsVersion}
displayMode={osDisplayMode}
width={width}
height={compactContentHeight}
showLegend={false}
compact
/>
</div>
) : compactPrimaryContent === "stats" ? (
<CompactStatGrid
statKeys={compactStatKeys}
statValues={statValues}
colorContext={colorContext}
options={options}
columns={compactStatCols}
showIcon={width >= 180 || height >= 140}
getLabel={t}
/>
) : null;
if (!compactContent) {
return (
<div className={`${classes.root} ${classes.compactRoot}`}>
<div className={classes.emptyState}>
<Text size="sm" c="dimmed">
—
</Text>
</div>
</div>
);
}
return (
<div className={`${classes.root} ${classes.compactRoot}`}>
{compactContent}
{showCompactFooter && (
<Text size="xs" c="dimmed" ta="center" className={`${classes.footer} ${classes.compactFooter}`}>
{footerText}
</Text>
)}
</div>
);
}
if (!hasContent) {
return (
<div className={classes.root}>
<div className={classes.emptyState}>
<Text size="sm" c="dimmed">
—
</Text>
</div>
</div>
);
}
const statGridContent = showStats && (
<div className={`${classes.statsSection} ${showOsSection ? classes.statsSectionCompact : ""}`}>
<div className={classes.grid} style={{ "--stat-cols": gridCols } as CSSProperties}>
{visibleStatKeys.map((statKey) => {
const severity = resolveStatColor(statKey, statValues[statKey], colorContext, options);
const Icon = statIcons[statKey];
const tile = (
<div
key={statKey}
className={classes.statTile}
style={{ "--stat-bg": severityToMantineBgColor(severity) } as CSSProperties}
>
{showIcons && (
<Icon
className={classes.statIcon}
size={iconSize}
stroke={1.5}
color={severityToIconColor(severity)}
/>
)}
<span className={classes.statValue}>
<Text component="span" fw={700} size="inherit" c={severityToMantineColor(severity)}>
{statValues[statKey]}
</Text>
</span>
{showLabels && <span className={classes.statLabel}>{t(statKey)}</span>}
</div>
);
if (!showLabels) {
return (
<Tooltip key={statKey} label={t(statKey)} withArrow>
{tile}
</Tooltip>
);
}
return tile;
})}
</div>
</div>
);
const osContent = showOsSection && (
<div className={`${classes.osSectionWrapper} ${visibleStatKeys.length > 0 ? classes.osSectionWithStats : ""}`}>
<OsDistributionSection
entries={stats.osDistribution}
limit={Number(options.osDistributionLimit)}
showOsVersion={options.showOsVersion}
displayMode={osDisplayMode}
width={width}
height={height}
showLegend={showOsLegend}
/>
</div>
);
const needsScroll = layout === "mini" || layout === "compact";
return (
<div className={classes.root}>
{showHero && (
<ComplianceHero
compliancePercent={compliancePercent}
complianceColor={complianceColor}
upToDateHosts={stats.upToDateHosts}
totalHosts={stats.totalHosts}
ringSize={ringSize}
showText={showHeroText}
expanded={!showStats && !showOsSection}
getLabel={t}
/>
)}
{(statGridContent || osContent) &&
(needsScroll ? (
<ScrollArea style={{ flex: 1, minHeight: 0 }} scrollbars="y">
<div className={classes.content}>
{statGridContent}
{osContent}
</div>
</ScrollArea>
) : (
<div className={classes.content}>
{statGridContent}
{osContent}
</div>
))}
{showFooter && (
<Text size="xs" c="dimmed" ta={isNarrow ? "center" : "right"} className={classes.footer}>
{footerText}
</Text>
)}
</div>
);
}
function ComplianceHero({
compliancePercent,
complianceColor,
upToDateHosts,
totalHosts,
ringSize,
showText,
expanded = false,
compact = false,
getLabel,
}: {
compliancePercent: number;
complianceColor: string;
upToDateHosts: number;
totalHosts: number;
ringSize: number;
showText: boolean;
expanded?: boolean;
compact?: boolean;
getLabel: (key: "compliance" | "complianceRatio", params?: { upToDate: number; total: number }) => string;
}) {
const heroClasses = [
compact && classes.compactPrimary,
classes.hero,
expanded && classes.heroExpanded,
showText ? classes.heroWithText : classes.heroRingOnly,
]
.filter(Boolean)
.join(" ");
return (
<div className={heroClasses}>
{showText && (
<div className={classes.heroText}>
<span className={classes.heroLabel}>{getLabel("compliance")}</span>
<span className={classes.heroValue}>
{getLabel("complianceRatio", { upToDate: upToDateHosts, total: totalHosts })}
</span>
</div>
)}
<RingProgress
className={classes.ring}
size={ringSize}
thickness={Math.max(4, Math.round(ringSize / 10))}
roundCaps
sections={[{ value: compliancePercent, color: complianceColor }]}
label={
<Text ta="center" size="xs" fw={700}>
{compliancePercent}%
</Text>
}
/>
</div>
);
}
function getIconSize(width: number): number {
const match = iconSizeByWidth.find(({ minWidth }) => width >= minWidth);
return match?.size ?? 16;
}
function getRingSize(width: number, height: number, compact: boolean): number {
if (compact) {
return Math.max(36, Math.min(64, width - 28, height - 28));
}
const match = ringSizeByWidth.find(({ minWidth }) => width >= minWidth);
return match?.size ?? 44;
}
function getCompactPrimaryContent({
showHero,
showOsDistribution,
visibleStatKeys,
}: {
showHero: boolean;
showOsDistribution: boolean;
visibleStatKeys: PatchMonStatKey[];
}): CompactPrimaryContent {
if (showHero) return "hero";
if (showOsDistribution) return "os";
return visibleStatKeys.length > 0 ? "stats" : null;
}
function getCompactTimeAgo(lastUpdated: string): string {
const minutes = Math.max(0, dayjs().diff(dayjs(lastUpdated), "minute"));
if (minutes < 1) return "now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function CompactStatGrid({
statKeys,
statValues,
colorContext,
options,
columns,
showIcon,
getLabel,
}: {
statKeys: PatchMonStatKey[];
statValues: Record<PatchMonStatKey, number>;
colorContext: { totalHosts: number };
options: WidgetComponentProps<"patchmon">["options"];
columns: number;
showIcon: boolean;
getLabel: (key: PatchMonStatKey) => string;
}) {
return (
<div className={classes.compactStatGrid} style={{ "--compact-stat-cols": columns } as CSSProperties}>
{statKeys.map((statKey) => {
const severity = resolveStatColor(statKey, statValues[statKey], colorContext, options);
const Icon = statIcons[statKey];
const label = getLabel(statKey);
return (
<Tooltip key={statKey} label={label} withArrow>
<div
className={classes.compactStatTile}
style={{ "--stat-bg": severityToMantineBgColor(severity) } as CSSProperties}
>
{showIcon && (
<Icon className={classes.statIcon} size={18} stroke={1.5} color={severityToIconColor(severity)} />
)}
<span className={classes.compactStatValue}>
<Text component="span" fw={700} size="inherit" c={severityToMantineColor(severity)}>
{statValues[statKey]}
</Text>
</span>
<span className={classes.compactStatLabel}>{label}</span>
</div>
</Tooltip>
);
})}
</div>
);
}
+234
View File
@@ -0,0 +1,234 @@
import { IconServerOff, IconShieldCheck } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
import { PATCHMON_STAT_COLOR_PRESETS } from "./stat-colors";
import { patchmonOptionsSuperRefine } from "./threshold-validation";
export const { definition, componentLoader } = createWidgetDefinition("patchmon", {
icon: IconShieldCheck,
supportedIntegrations: ["patchmon"],
integrationsRequired: true,
createOptions() {
return optionsBuilder.from(
(factory) => ({
showComplianceHero: factory.switch({
defaultValue: true,
withDescription: true,
}),
showTotalHosts: factory.switch({
defaultValue: true,
withDescription: true,
}),
showHostsNeedingUpdates: factory.switch({
defaultValue: true,
withDescription: true,
}),
showSecurityUpdates: factory.switch({
defaultValue: true,
withDescription: true,
}),
showUpToDateHosts: factory.switch({
defaultValue: false,
withDescription: true,
}),
showHostsWithSecurityUpdates: factory.switch({
defaultValue: false,
withDescription: true,
}),
showRecentUpdates24h: factory.switch({
defaultValue: false,
withDescription: true,
}),
showTotalOutdatedPackages: factory.switch({
defaultValue: false,
withDescription: true,
}),
showTotalRepos: factory.switch({
defaultValue: false,
withDescription: true,
}),
enableThresholdColors: factory.switch({
defaultValue: true,
withDescription: true,
}),
useCustomThresholds: factory.switch({
defaultValue: false,
withDescription: true,
}),
hostsNeedingUpdatesThresholdMode: factory.select({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.hostsNeedingUpdates.mode,
withDescription: true,
options: [
{ value: "absolute", label: (t) => t("widget.patchmon.option.thresholdMode.option.absolute") },
{ value: "percent", label: (t) => t("widget.patchmon.option.thresholdMode.option.percent") },
] as const,
}),
hostsNeedingUpdatesWarningAt: factory.number({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.hostsNeedingUpdates.warningAt,
withDescription: true,
step: 1,
validate: z.number().int().min(0),
}),
hostsNeedingUpdatesCriticalAt: factory.number({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.hostsNeedingUpdates.criticalAt,
withDescription: true,
step: 1,
validate: z.number().int().min(0),
}),
securityUpdatesThresholdMode: factory.select({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.securityUpdates.mode,
withDescription: true,
options: [
{ value: "absolute", label: (t) => t("widget.patchmon.option.thresholdMode.option.absolute") },
{ value: "percent", label: (t) => t("widget.patchmon.option.thresholdMode.option.percent") },
] as const,
}),
securityUpdatesWarningAt: factory.number({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.securityUpdates.warningAt,
withDescription: true,
step: 1,
validate: z.number().int().min(0),
}),
securityUpdatesCriticalAt: factory.number({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.securityUpdates.criticalAt,
withDescription: true,
step: 1,
validate: z.number().int().min(0),
}),
hostsWithSecurityUpdatesThresholdMode: factory.select({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.hostsWithSecurityUpdates.mode,
withDescription: true,
options: [
{ value: "absolute", label: (t) => t("widget.patchmon.option.thresholdMode.option.absolute") },
{ value: "percent", label: (t) => t("widget.patchmon.option.thresholdMode.option.percent") },
] as const,
}),
hostsWithSecurityUpdatesWarningAt: factory.number({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.hostsWithSecurityUpdates.warningAt,
withDescription: true,
step: 1,
validate: z.number().int().min(0),
}),
hostsWithSecurityUpdatesCriticalAt: factory.number({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.hostsWithSecurityUpdates.criticalAt,
withDescription: true,
step: 1,
validate: z.number().int().min(0),
}),
upToDateHostsThresholdMode: factory.select({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.upToDateHosts.mode,
withDescription: true,
options: [
{ value: "absolute", label: (t) => t("widget.patchmon.option.thresholdMode.option.absolute") },
{ value: "percent", label: (t) => t("widget.patchmon.option.thresholdMode.option.percent") },
] as const,
}),
upToDateHostsWarningAt: factory.number({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.upToDateHosts.warningAt,
withDescription: true,
step: 1,
validate: z.number().int().min(0),
}),
upToDateHostsCriticalAt: factory.number({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.upToDateHosts.criticalAt,
withDescription: true,
step: 1,
validate: z.number().int().min(0),
}),
totalOutdatedPackagesThresholdMode: factory.select({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.totalOutdatedPackages.mode,
withDescription: true,
options: [
{ value: "absolute", label: (t) => t("widget.patchmon.option.thresholdMode.option.absolute") },
{ value: "percent", label: (t) => t("widget.patchmon.option.thresholdMode.option.percent") },
] as const,
}),
totalOutdatedPackagesWarningAt: factory.number({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.totalOutdatedPackages.warningAt,
withDescription: true,
step: 1,
validate: z.number().int().min(0),
}),
totalOutdatedPackagesCriticalAt: factory.number({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.totalOutdatedPackages.criticalAt,
withDescription: true,
step: 1,
validate: z.number().int().min(0),
}),
showOsDistribution: factory.switch({
defaultValue: false,
withDescription: true,
}),
osDisplayMode: factory.select({
defaultValue: "bars",
withDescription: true,
options: [
{ value: "bars", label: (t) => t("widget.patchmon.option.osDisplayMode.option.bars") },
{ value: "donut", label: (t) => t("widget.patchmon.option.osDisplayMode.option.donut") },
] as const,
}),
osDistributionLimit: factory.select({
defaultValue: "0",
withDescription: true,
options: [
{ value: "3", label: (t) => t("widget.patchmon.option.osDistributionLimit.option.3") },
{ value: "5", label: (t) => t("widget.patchmon.option.osDistributionLimit.option.5") },
{ value: "10", label: (t) => t("widget.patchmon.option.osDistributionLimit.option.10") },
{ value: "0", label: (t) => t("widget.patchmon.option.osDistributionLimit.option.all") },
] as const,
}),
showOsVersion: factory.switch({
defaultValue: true,
withDescription: true,
}),
}),
{
useCustomThresholds: {
shouldHide: (options) => !options.enableThresholdColors,
},
hostsNeedingUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
hostsNeedingUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
hostsNeedingUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
securityUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
securityUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
securityUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
hostsWithSecurityUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
hostsWithSecurityUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
hostsWithSecurityUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
upToDateHostsThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
upToDateHostsWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
upToDateHostsCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
totalOutdatedPackagesThresholdMode: {
shouldHide: (options) =>
!options.enableThresholdColors || !options.useCustomThresholds || !options.showTotalOutdatedPackages,
},
totalOutdatedPackagesWarningAt: {
shouldHide: (options) =>
!options.enableThresholdColors || !options.useCustomThresholds || !options.showTotalOutdatedPackages,
},
totalOutdatedPackagesCriticalAt: {
shouldHide: (options) =>
!options.enableThresholdColors || !options.useCustomThresholds || !options.showTotalOutdatedPackages,
},
osDisplayMode: {
shouldHide: (options) => !options.showOsDistribution,
},
osDistributionLimit: {
shouldHide: (options) => !options.showOsDistribution,
},
showOsVersion: {
shouldHide: (options) => !options.showOsDistribution,
},
},
patchmonOptionsSuperRefine,
);
},
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
message: (t) => t("widget.patchmon.error.internalServerError"),
},
},
}).withDynamicImport(() => import("./component"));
@@ -0,0 +1,15 @@
const gridColsByWidth = [
{ minWidth: 500, cols: 4 },
{ minWidth: 380, cols: 3 },
{ minWidth: 220, cols: 2 },
{ minWidth: 0, cols: 1 },
] as const;
export function getGridCols(width: number): number {
const match = gridColsByWidth.find(({ minWidth }) => width >= minWidth);
return match?.cols ?? 1;
}
export function shouldShowComplianceHeroText(width: number): boolean {
return getGridCols(width) >= 2;
}
@@ -0,0 +1,167 @@
"use client";
import type { CSSProperties } from "react";
import { DonutChart } from "@mantine/charts";
import { Group, Progress, ScrollArea, Stack, Text } from "@mantine/core";
import type { PatchMonOsDistributionEntry } from "@homarr/integrations";
import { useScopedI18n } from "@homarr/translation/client";
import classes from "./component.module.css";
const OS_COLORS = [
"blue.6",
"orange.6",
"green.6",
"grape.6",
"cyan.6",
"pink.6",
"yellow.6",
"teal.6",
] as const;
interface OsDistributionSectionProps {
entries: PatchMonOsDistributionEntry[];
limit: number;
showOsVersion: boolean;
displayMode: "bars" | "donut";
width: number;
height: number;
showLegend: boolean;
compact?: boolean;
}
export const formatOsLabel = (entry: PatchMonOsDistributionEntry, showVersion: boolean) =>
showVersion && entry.osVersion ? `${entry.name} ${entry.osVersion}` : entry.name;
const getMantineColorVariable = (color: string) => `var(--mantine-color-${color.replace(".", "-")})`;
export function OsDistributionSection({
entries,
limit,
showOsVersion,
displayMode,
width,
height,
showLegend,
compact = false,
}: OsDistributionSectionProps) {
const t = useScopedI18n("widget.patchmon");
if (entries.length === 0) {
return null;
}
const visibleEntries = limit > 0 ? entries.slice(0, limit) : entries;
if (displayMode === "donut") {
const chartData = visibleEntries.map((entry, i) => ({
name: formatOsLabel(entry, showOsVersion),
value: entry.count,
color: OS_COLORS[i % OS_COLORS.length] as string,
}));
const donutSize = compact
? Math.max(36, Math.min(width - 18, height - 10, 96))
: Math.max(48, Math.min(width - 24, height * 0.55, showLegend ? 140 : 180));
return (
<Stack gap={4} className={classes.osSection} h="100%">
{!compact && (
<Text size="xs" c="dimmed" fw={500} className={classes.osHeading}>
{t("osDistribution")}
</Text>
)}
<div className={`${classes.osDonutContent} ${showLegend ? classes.osDonutContentWithLegend : ""}`}>
<div className={classes.osDonut}>
<DonutChart
data={chartData}
size={donutSize}
withTooltip
tooltipDataSource="segment"
paddingAngle={2}
/>
</div>
{showLegend && (
<div className={classes.osLegend}>
{chartData.map((entry) => (
<div key={entry.name} className={classes.osLegendItem}>
<span
className={classes.osLegendSwatch}
style={{ "--os-color": getMantineColorVariable(entry.color) } as CSSProperties}
/>
<span className={classes.osLegendLabel} title={entry.name}>
{entry.name}
</span>
<Text component="span" size="xs" fw={600} className={classes.osLegendValue}>
{entry.value}
</Text>
</div>
))}
</div>
)}
</div>
</Stack>
);
}
const maxCount = Math.max(...visibleEntries.map((entry) => entry.count), 1);
if (compact) {
const maxRows = Math.max(2, Math.min(visibleEntries.length, Math.floor(height / 28), width < 160 ? 2 : 4));
const compactEntries = visibleEntries.slice(0, maxRows);
if (compactEntries.length === 0) return null;
return (
<div className={classes.compactOsBars}>
{compactEntries.map((entry, i) => (
<div key={`${entry.name}-${entry.osVersion ?? ""}`} className={classes.compactOsBar}>
<Text size="xs" className={classes.compactOsLabel} truncate="end" title={formatOsLabel(entry, showOsVersion)}>
{formatOsLabel(entry, showOsVersion)}
</Text>
<Group gap={6} wrap="nowrap" className={classes.osBarGroup}>
<Progress
value={(entry.count / maxCount) * 100}
size="sm"
radius="sm"
className={classes.osBar}
color={OS_COLORS[i % OS_COLORS.length]}
/>
<Text size="xs" fw={700} className={classes.osCount}>
{entry.count}
</Text>
</Group>
</div>
))}
</div>
);
}
return (
<Stack gap={4} className={classes.osSection} h="100%">
<Text size="xs" c="dimmed" fw={500} className={classes.osHeading}>
{t("osDistribution")}
</Text>
<ScrollArea className={classes.osScroll} scrollbars="y">
<Stack gap={6} pr={4}>
{visibleEntries.map((entry, i) => {
const barValue = (entry.count / maxCount) * 100;
const barColor = OS_COLORS[i % OS_COLORS.length] as string;
return (
<div key={`${entry.name}-${entry.osVersion ?? ""}`} className={classes.osRow}>
<Text size="xs" className={classes.osLabel} truncate="end" title={formatOsLabel(entry, showOsVersion)}>
{formatOsLabel(entry, showOsVersion)}
</Text>
<Group gap={6} wrap="nowrap" className={classes.osBarGroup}>
<Progress value={barValue} size="sm" radius="sm" className={classes.osBar} color={barColor} />
<Text size="xs" fw={600} className={classes.osCount}>
{entry.count}
</Text>
</Group>
</div>
);
})}
</Stack>
</ScrollArea>
</Stack>
);
}
@@ -0,0 +1,215 @@
import type { WidgetComponentProps } from "../definition";
import { clampPercentThreshold } from "./threshold-validation";
export type PatchMonStatKey =
| "totalHosts"
| "hostsNeedingUpdates"
| "securityUpdates"
| "upToDateHosts"
| "hostsWithSecurityUpdates"
| "recentUpdates24h"
| "totalOutdatedPackages"
| "totalRepos";
export type ColorablePatchMonStatKey = Extract<
PatchMonStatKey,
| "hostsNeedingUpdates"
| "securityUpdates"
| "hostsWithSecurityUpdates"
| "upToDateHosts"
| "totalOutdatedPackages"
>;
export type StatSeverity = "green" | "yellow" | "red" | "neutral";
export type ThresholdMode = "absolute" | "percent";
export interface StatColorContext {
totalHosts: number;
}
export interface StatThresholdRule {
direction: "lowerIsBetter" | "higherIsBetter";
mode: ThresholdMode;
warningAt: number;
criticalAt: number;
skipYellow?: boolean;
}
type PatchMonColorOptions = WidgetComponentProps<"patchmon">["options"];
export const COLORABLE_PATCHMON_STATS = [
"hostsNeedingUpdates",
"securityUpdates",
"hostsWithSecurityUpdates",
"upToDateHosts",
"totalOutdatedPackages",
] as const satisfies readonly ColorablePatchMonStatKey[];
export const NEUTRAL_PATCHMON_STATS = ["totalHosts", "totalRepos", "recentUpdates24h"] as const;
export const PATCHMON_STAT_COLOR_PRESETS: Record<ColorablePatchMonStatKey, StatThresholdRule> = {
hostsNeedingUpdates: {
direction: "lowerIsBetter",
mode: "absolute",
warningAt: 1,
criticalAt: 5,
},
securityUpdates: {
direction: "lowerIsBetter",
mode: "absolute",
warningAt: 1,
criticalAt: 10,
},
hostsWithSecurityUpdates: {
direction: "lowerIsBetter",
mode: "absolute",
warningAt: 1,
criticalAt: 1,
skipYellow: true,
},
upToDateHosts: {
direction: "higherIsBetter",
mode: "percent",
warningAt: 90,
criticalAt: 90,
},
totalOutdatedPackages: {
direction: "lowerIsBetter",
mode: "absolute",
warningAt: 1,
criticalAt: 50,
},
};
const thresholdModeOptionKey = {
hostsNeedingUpdates: "hostsNeedingUpdatesThresholdMode",
securityUpdates: "securityUpdatesThresholdMode",
hostsWithSecurityUpdates: "hostsWithSecurityUpdatesThresholdMode",
upToDateHosts: "upToDateHostsThresholdMode",
totalOutdatedPackages: "totalOutdatedPackagesThresholdMode",
} as const;
const warningAtOptionKey = {
hostsNeedingUpdates: "hostsNeedingUpdatesWarningAt",
securityUpdates: "securityUpdatesWarningAt",
hostsWithSecurityUpdates: "hostsWithSecurityUpdatesWarningAt",
upToDateHosts: "upToDateHostsWarningAt",
totalOutdatedPackages: "totalOutdatedPackagesWarningAt",
} as const;
const criticalAtOptionKey = {
hostsNeedingUpdates: "hostsNeedingUpdatesCriticalAt",
securityUpdates: "securityUpdatesCriticalAt",
hostsWithSecurityUpdates: "hostsWithSecurityUpdatesCriticalAt",
upToDateHosts: "upToDateHostsCriticalAt",
totalOutdatedPackages: "totalOutdatedPackagesCriticalAt",
} as const;
const isColorableStat = (statKey: PatchMonStatKey): statKey is ColorablePatchMonStatKey =>
COLORABLE_PATCHMON_STATS.includes(statKey as ColorablePatchMonStatKey);
const getComparableValue = (
value: number,
mode: ThresholdMode,
context: StatColorContext,
direction: StatThresholdRule["direction"],
): number => {
if (mode === "absolute") {
return value;
}
if (context.totalHosts <= 0) {
if (direction === "higherIsBetter") {
return 100;
}
return value > 0 ? 100 : 0;
}
return (value / context.totalHosts) * 100;
};
const resolveLowerIsBetter = (comparable: number, rule: StatThresholdRule): StatSeverity => {
if (comparable < rule.warningAt) {
return "green";
}
if (rule.skipYellow || comparable >= rule.criticalAt) {
return "red";
}
return "yellow";
};
const resolveHigherIsBetter = (
value: number,
comparable: number,
context: StatColorContext,
rule: StatThresholdRule,
): StatSeverity => {
if (context.totalHosts > 0 && value >= context.totalHosts) {
return "green";
}
if (comparable >= rule.warningAt) {
return "green";
}
if (rule.skipYellow || comparable < rule.criticalAt) {
return "red";
}
return "yellow";
};
const getThresholdRule = (statKey: ColorablePatchMonStatKey, options: PatchMonColorOptions): StatThresholdRule => {
if (!options.useCustomThresholds) {
return PATCHMON_STAT_COLOR_PRESETS[statKey];
}
const mode = options[thresholdModeOptionKey[statKey]] as ThresholdMode;
const warningAt = clampPercentThreshold(options[warningAtOptionKey[statKey]] as number, mode);
const criticalAt = clampPercentThreshold(options[criticalAtOptionKey[statKey]] as number, mode);
const preset = PATCHMON_STAT_COLOR_PRESETS[statKey];
return {
direction: preset.direction,
mode,
warningAt,
criticalAt,
skipYellow: preset.skipYellow && warningAt === criticalAt,
};
};
export const resolveStatColor = (
statKey: PatchMonStatKey,
value: number,
context: StatColorContext,
options: PatchMonColorOptions,
): StatSeverity => {
if (!options.enableThresholdColors || !isColorableStat(statKey)) {
return "neutral";
}
const rule = getThresholdRule(statKey, options);
const comparable = getComparableValue(value, rule.mode, context, rule.direction);
if (rule.direction === "higherIsBetter") {
return resolveHigherIsBetter(value, comparable, context, rule);
}
return resolveLowerIsBetter(comparable, rule);
};
export const severityToMantineColor = (severity: StatSeverity): string =>
severity === "neutral" ? "var(--mantine-primary-color-filled)" : severity;
export const severityToMantineBgColor = (severity: StatSeverity): string => {
if (severity === "neutral") return "light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6))";
return `var(--mantine-color-${severity}-light)`;
};
export const severityToIconColor = (severity: StatSeverity): string => {
if (severity === "neutral") return "var(--mantine-color-dimmed)";
return `var(--mantine-color-${severity}-6)`;
};
@@ -0,0 +1,36 @@
import { describe, expect, test } from "vitest";
import { getGridCols, shouldShowComplianceHeroText } from "../layout-utils";
describe("getGridCols", () => {
test("returns 1 column for narrow widths", () => {
expect(getGridCols(128)).toBe(1);
expect(getGridCols(219)).toBe(1);
});
test("returns 2 columns at 220px breakpoint", () => {
expect(getGridCols(220)).toBe(2);
expect(getGridCols(256)).toBe(2);
});
test("returns 3 columns at 380px breakpoint", () => {
expect(getGridCols(380)).toBe(3);
});
test("returns 4 columns at 500px breakpoint", () => {
expect(getGridCols(500)).toBe(4);
});
});
describe("shouldShowComplianceHeroText", () => {
test("returns false for 1-column-wide layouts", () => {
expect(shouldShowComplianceHeroText(128)).toBe(false);
expect(shouldShowComplianceHeroText(219)).toBe(false);
});
test("returns true for 2+-column-wide layouts", () => {
expect(shouldShowComplianceHeroText(220)).toBe(true);
expect(shouldShowComplianceHeroText(256)).toBe(true);
expect(shouldShowComplianceHeroText(384)).toBe(true);
});
});
@@ -0,0 +1,171 @@
import { describe, expect, test } from "vitest";
import type { WidgetComponentProps } from "../../definition";
import { resolveStatColor } from "../stat-colors";
const defaultOptions = {
enableThresholdColors: true,
useCustomThresholds: false,
showTotalHosts: true,
showHostsNeedingUpdates: true,
showSecurityUpdates: true,
showUpToDateHosts: false,
showHostsWithSecurityUpdates: false,
showRecentUpdates24h: false,
showTotalOutdatedPackages: false,
showTotalRepos: false,
showOsDistribution: false,
osDistributionLimit: "0",
showOsVersion: true,
hostsNeedingUpdatesThresholdMode: "absolute",
hostsNeedingUpdatesWarningAt: 1,
hostsNeedingUpdatesCriticalAt: 5,
securityUpdatesThresholdMode: "absolute",
securityUpdatesWarningAt: 1,
securityUpdatesCriticalAt: 10,
hostsWithSecurityUpdatesThresholdMode: "absolute",
hostsWithSecurityUpdatesWarningAt: 1,
hostsWithSecurityUpdatesCriticalAt: 1,
upToDateHostsThresholdMode: "percent",
upToDateHostsWarningAt: 90,
upToDateHostsCriticalAt: 90,
totalOutdatedPackagesThresholdMode: "absolute",
totalOutdatedPackagesWarningAt: 1,
totalOutdatedPackagesCriticalAt: 50,
showComplianceHero: true,
osDisplayMode: "bars",
} satisfies WidgetComponentProps<"patchmon">["options"];
const context = { totalHosts: 100 };
describe("resolveStatColor presets", () => {
test("hostsNeedingUpdates uses green, yellow, and red boundaries", () => {
expect(resolveStatColor("hostsNeedingUpdates", 0, context, defaultOptions)).toBe("green");
expect(resolveStatColor("hostsNeedingUpdates", 1, context, defaultOptions)).toBe("yellow");
expect(resolveStatColor("hostsNeedingUpdates", 4, context, defaultOptions)).toBe("yellow");
expect(resolveStatColor("hostsNeedingUpdates", 5, context, defaultOptions)).toBe("red");
});
test("securityUpdates uses green and yellow for low counts", () => {
expect(resolveStatColor("securityUpdates", 0, context, defaultOptions)).toBe("green");
expect(resolveStatColor("securityUpdates", 1, context, defaultOptions)).toBe("yellow");
expect(resolveStatColor("securityUpdates", 10, context, defaultOptions)).toBe("red");
});
test("hostsWithSecurityUpdates skips yellow", () => {
expect(resolveStatColor("hostsWithSecurityUpdates", 0, context, defaultOptions)).toBe("green");
expect(resolveStatColor("hostsWithSecurityUpdates", 1, context, defaultOptions)).toBe("red");
});
test("upToDateHosts uses percent of total hosts", () => {
expect(resolveStatColor("upToDateHosts", 100, context, defaultOptions)).toBe("green");
expect(resolveStatColor("upToDateHosts", 95, context, defaultOptions)).toBe("green");
expect(resolveStatColor("upToDateHosts", 90, context, defaultOptions)).toBe("green");
expect(resolveStatColor("upToDateHosts", 80, context, defaultOptions)).toBe("red");
});
test("neutral stats ignore threshold coloring", () => {
expect(resolveStatColor("totalHosts", 100, context, defaultOptions)).toBe("neutral");
expect(resolveStatColor("totalRepos", 12, context, defaultOptions)).toBe("neutral");
expect(resolveStatColor("recentUpdates24h", 34, context, defaultOptions)).toBe("neutral");
});
});
describe("resolveStatColor options", () => {
test("returns neutral when threshold colors are disabled", () => {
expect(
resolveStatColor("hostsNeedingUpdates", 10, context, {
...defaultOptions,
enableThresholdColors: false,
}),
).toBe("neutral");
});
test("supports custom absolute thresholds", () => {
expect(
resolveStatColor("hostsNeedingUpdates", 4, context, {
...defaultOptions,
useCustomThresholds: true,
hostsNeedingUpdatesWarningAt: 5,
hostsNeedingUpdatesCriticalAt: 10,
}),
).toBe("green");
});
test("supports custom percent thresholds for lower-is-better stats", () => {
expect(
resolveStatColor("hostsNeedingUpdates", 5, context, {
...defaultOptions,
useCustomThresholds: true,
hostsNeedingUpdatesThresholdMode: "percent",
hostsNeedingUpdatesWarningAt: 10,
hostsNeedingUpdatesCriticalAt: 20,
}),
).toBe("green");
expect(
resolveStatColor("hostsNeedingUpdates", 15, context, {
...defaultOptions,
useCustomThresholds: true,
hostsNeedingUpdatesThresholdMode: "percent",
hostsNeedingUpdatesWarningAt: 10,
hostsNeedingUpdatesCriticalAt: 20,
}),
).toBe("yellow");
});
test("handles zero total hosts for percent-based stats", () => {
expect(resolveStatColor("upToDateHosts", 0, { totalHosts: 0 }, defaultOptions)).toBe("green");
expect(
resolveStatColor("hostsNeedingUpdates", 1, { totalHosts: 0 }, {
...defaultOptions,
useCustomThresholds: true,
hostsNeedingUpdatesThresholdMode: "percent",
}),
).toBe("red");
});
test("clamps invalid percent thresholds when resolving colors", () => {
expect(
resolveStatColor("hostsNeedingUpdates", 50, context, {
...defaultOptions,
useCustomThresholds: true,
hostsNeedingUpdatesThresholdMode: "percent",
hostsNeedingUpdatesWarningAt: 40,
hostsNeedingUpdatesCriticalAt: 200,
}),
).toBe("yellow");
});
test("supports custom absolute thresholds for higher-is-better stats", () => {
expect(
resolveStatColor("upToDateHosts", 85, context, {
...defaultOptions,
useCustomThresholds: true,
upToDateHostsThresholdMode: "absolute",
upToDateHostsWarningAt: 80,
upToDateHostsCriticalAt: 60,
}),
).toBe("green");
expect(
resolveStatColor("upToDateHosts", 70, context, {
...defaultOptions,
useCustomThresholds: true,
upToDateHostsThresholdMode: "absolute",
upToDateHostsWarningAt: 80,
upToDateHostsCriticalAt: 60,
}),
).toBe("yellow");
expect(
resolveStatColor("upToDateHosts", 50, context, {
...defaultOptions,
useCustomThresholds: true,
upToDateHostsThresholdMode: "absolute",
upToDateHostsWarningAt: 80,
upToDateHostsCriticalAt: 60,
}),
).toBe("red");
});
});
@@ -0,0 +1,97 @@
import { describe, expect, test } from "vitest";
import { z } from "zod/v4";
import { clampPercentThreshold, patchmonOptionsSuperRefine } from "../threshold-validation";
const baseOptions = {
enableThresholdColors: true,
useCustomThresholds: true,
hostsNeedingUpdatesThresholdMode: "percent",
hostsNeedingUpdatesWarningAt: 10,
hostsNeedingUpdatesCriticalAt: 20,
securityUpdatesThresholdMode: "absolute",
securityUpdatesWarningAt: 1,
securityUpdatesCriticalAt: 10,
hostsWithSecurityUpdatesThresholdMode: "absolute",
hostsWithSecurityUpdatesWarningAt: 1,
hostsWithSecurityUpdatesCriticalAt: 1,
upToDateHostsThresholdMode: "percent",
upToDateHostsWarningAt: 90,
upToDateHostsCriticalAt: 90,
totalOutdatedPackagesThresholdMode: "absolute",
totalOutdatedPackagesWarningAt: 1,
totalOutdatedPackagesCriticalAt: 50,
};
const optionsSchema = z.object({
hostsNeedingUpdatesWarningAt: z.number().int().min(0),
hostsNeedingUpdatesCriticalAt: z.number().int().min(0),
securityUpdatesWarningAt: z.number().int().min(0),
securityUpdatesCriticalAt: z.number().int().min(0),
hostsWithSecurityUpdatesWarningAt: z.number().int().min(0),
hostsWithSecurityUpdatesCriticalAt: z.number().int().min(0),
upToDateHostsWarningAt: z.number().int().min(0),
upToDateHostsCriticalAt: z.number().int().min(0),
totalOutdatedPackagesWarningAt: z.number().int().min(0),
totalOutdatedPackagesCriticalAt: z.number().int().min(0),
enableThresholdColors: z.boolean(),
useCustomThresholds: z.boolean(),
hostsNeedingUpdatesThresholdMode: z.enum(["absolute", "percent"]),
securityUpdatesThresholdMode: z.enum(["absolute", "percent"]),
hostsWithSecurityUpdatesThresholdMode: z.enum(["absolute", "percent"]),
upToDateHostsThresholdMode: z.enum(["absolute", "percent"]),
totalOutdatedPackagesThresholdMode: z.enum(["absolute", "percent"]),
}).superRefine(patchmonOptionsSuperRefine);
describe("patchmonOptionsSuperRefine", () => {
test("accepts percent thresholds within 0-100", () => {
const result = optionsSchema.safeParse(baseOptions);
expect(result.success).toBe(true);
});
test("rejects percent thresholds above 100", () => {
const result = optionsSchema.safeParse({
...baseOptions,
hostsNeedingUpdatesWarningAt: 101,
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue) => issue.path.includes("hostsNeedingUpdatesWarningAt"))).toBe(true);
}
});
test("allows absolute thresholds above 100", () => {
const result = optionsSchema.safeParse({
...baseOptions,
hostsNeedingUpdatesThresholdMode: "absolute",
hostsNeedingUpdatesWarningAt: 150,
hostsNeedingUpdatesCriticalAt: 200,
});
expect(result.success).toBe(true);
});
test("skips validation when custom thresholds are disabled", () => {
const result = optionsSchema.safeParse({
...baseOptions,
useCustomThresholds: false,
hostsNeedingUpdatesWarningAt: 500,
});
expect(result.success).toBe(true);
});
});
describe("clampPercentThreshold", () => {
test("clamps percent values to 0-100", () => {
expect(clampPercentThreshold(150, "percent")).toBe(100);
expect(clampPercentThreshold(-5, "percent")).toBe(0);
expect(clampPercentThreshold(42.9, "percent")).toBe(42);
});
test("leaves absolute values unchanged", () => {
expect(clampPercentThreshold(150, "absolute")).toBe(150);
});
});
@@ -0,0 +1,59 @@
import type { RefinementCtx } from "zod/v4";
import { z } from "zod/v4";
import type { ThresholdMode } from "./stat-colors";
const percentThresholdSchema = z.number().int().min(0).max(100);
export const PATCHMON_THRESHOLD_PAIRS = [
{
mode: "hostsNeedingUpdatesThresholdMode",
warningAt: "hostsNeedingUpdatesWarningAt",
criticalAt: "hostsNeedingUpdatesCriticalAt",
},
{
mode: "securityUpdatesThresholdMode",
warningAt: "securityUpdatesWarningAt",
criticalAt: "securityUpdatesCriticalAt",
},
{
mode: "hostsWithSecurityUpdatesThresholdMode",
warningAt: "hostsWithSecurityUpdatesWarningAt",
criticalAt: "hostsWithSecurityUpdatesCriticalAt",
},
{
mode: "upToDateHostsThresholdMode",
warningAt: "upToDateHostsWarningAt",
criticalAt: "upToDateHostsCriticalAt",
},
{
mode: "totalOutdatedPackagesThresholdMode",
warningAt: "totalOutdatedPackagesWarningAt",
criticalAt: "totalOutdatedPackagesCriticalAt",
},
] as const;
export const clampPercentThreshold = (value: number, mode: ThresholdMode): number =>
mode === "percent" ? Math.min(100, Math.max(0, Math.trunc(value))) : value;
export const patchmonOptionsSuperRefine = (data: Record<string, unknown>, ctx: RefinementCtx) => {
if (!data.enableThresholdColors || !data.useCustomThresholds) {
return;
}
for (const { mode, warningAt, criticalAt } of PATCHMON_THRESHOLD_PAIRS) {
if (data[mode] !== "percent") {
continue;
}
for (const key of [warningAt, criticalAt] as const) {
const result = percentThresholdSchema.safeParse(data[key]);
if (!result.success) {
for (const issue of result.error.issues) {
ctx.addIssue({ ...issue, path: [key] });
}
}
}
}
};