feat(integrations): add PatchMon integration and widget (#6119)
Co-authored-by: Thomas <thomas@ajnart.fr>
This commit is contained in:
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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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] });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in new issue
Block a user