"use client"; import type { CSSProperties } from "react"; import { RingProgress, ScrollArea, Text, Tooltip } from "@mantine/core"; import { IconCircleCheck, IconClockCheck, IconDatabase, IconPackages, IconRefreshAlert, IconServer, IconShieldExclamation, IconShieldX, } from "@tabler/icons-react"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; import { clientApi } from "@homarr/api/client"; import { useScopedI18n } from "@homarr/translation/client"; import type { TablerIcon } from "@homarr/ui"; import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; import classes from "./component.module.css"; import { getGridCols, shouldShowComplianceHeroText } from "./layout-utils"; import { OsDistributionSection } from "./os-distribution-section"; import type { PatchMonStatKey } from "./stat-colors"; import { resolveStatColor, severityToIconColor, severityToMantineBgColor, severityToMantineColor } from "./stat-colors"; dayjs.extend(relativeTime); const statVisibilityByOption = { showTotalHosts: "totalHosts", showHostsNeedingUpdates: "hostsNeedingUpdates", showSecurityUpdates: "securityUpdates", showUpToDateHosts: "upToDateHosts", showHostsWithSecurityUpdates: "hostsWithSecurityUpdates", showRecentUpdates24h: "recentUpdates24h", showTotalOutdatedPackages: "totalOutdatedPackages", showTotalRepos: "totalRepos", } as const; const statIcons: Record = { totalHosts: IconServer, hostsNeedingUpdates: IconRefreshAlert, securityUpdates: IconShieldExclamation, upToDateHosts: IconCircleCheck, hostsWithSecurityUpdates: IconShieldX, recentUpdates24h: IconClockCheck, totalOutdatedPackages: IconPackages, totalRepos: IconDatabase, }; const iconSizeByWidth = [ { minWidth: 320, size: 22 }, { minWidth: 200, size: 18 }, { minWidth: 0, size: 16 }, ] as const; const ringSizeByWidth = [ { minWidth: 400, size: 88 }, { minWidth: 320, size: 72 }, { minWidth: 220, size: 56 }, { minWidth: 0, size: 40 }, ] as const; type LayoutMode = "mini" | "compact" | "comfortable" | "spacious"; type CompactPrimaryContent = "hero" | "os" | "stats" | null; const getLayoutMode = (width: number, height: number): LayoutMode => { const minDim = Math.min(width, height); if (minDim < 140) return "mini"; if (minDim < 220) return "compact"; if (width >= 380 && height >= 350) return "spacious"; return "comfortable"; }; export default function PatchMonWidget({ integrationIds, options, width, height }: WidgetComponentProps<"patchmon">) { const t = useScopedI18n("widget.patchmon"); const integrationId = integrationIds[0] ?? ""; const { data: stats } = clientApi.widget.patchmon.getStats.useQuery({ integrationId }, { staleTime: 60 * 1000 }); if (!stats) return ; const statValues: Record = { totalHosts: stats.totalHosts, hostsNeedingUpdates: stats.hostsNeedingUpdates, securityUpdates: stats.securityUpdates, upToDateHosts: stats.upToDateHosts, hostsWithSecurityUpdates: stats.hostsWithSecurityUpdates, recentUpdates24h: stats.recentUpdates24h, totalOutdatedPackages: stats.totalOutdatedPackages, totalRepos: stats.totalRepos, }; const visibleStatKeys = Object.entries(statVisibilityByOption) .filter(([optionKey]) => options[optionKey as keyof typeof options]) .map(([, statKey]) => statKey); const colorContext = { totalHosts: stats.totalHosts }; const layout = getLayoutMode(width, height); const isLandscape = width / height > 2.5; const isTinySquare = width < 160 && height < 160; const isShortLandscape = width >= 160 && height < 160; const isCompactSurface = isTinySquare || isShortLandscape; const isNarrow = width < 180; const osDisplayMode = options.osDisplayMode as "bars" | "donut"; const showOsData = options.showOsDistribution && stats.osDistribution.length > 0; const showIcons = layout !== "mini"; const showLabels = layout !== "mini"; const showHero = options.showComplianceHero && (!isLandscape || isCompactSurface) && width >= 90 && height >= 90; const showHeroText = showHero && shouldShowComplianceHeroText(width); const showHeroRingOnly = showHero && !showHeroText; const compactPrimaryContent = isCompactSurface ? getCompactPrimaryContent({ showHero, showOsDistribution: showOsData, visibleStatKeys, }) : null; const showStats = visibleStatKeys.length > 0 && !isCompactSurface; const showOsSection = showOsData && !isCompactSurface && height > 200; const showCompactFooter = isCompactSurface && height >= 110; const showFooter = isCompactSurface ? showCompactFooter : !isLandscape && height > 220; const showOsLegend = osDisplayMode === "donut" && width >= 380 && height >= 300; const hasContent = compactPrimaryContent !== null || showStats || showOsSection || showHero; const gridCols = isLandscape ? Math.min(visibleStatKeys.length, 4) : Math.min(getGridCols(width), visibleStatKeys.length || 1); const iconSize = getIconSize(width); const ringSize = getRingSize(width, height, showHeroRingOnly); const compliancePercent = stats.totalHosts > 0 ? Math.round((stats.upToDateHosts / stats.totalHosts) * 100) : 100; const complianceSeverity = resolveStatColor("upToDateHosts", stats.upToDateHosts, colorContext, options); const complianceColor = complianceSeverity === "neutral" ? "blue" : complianceSeverity; const timeAgo = dayjs(stats.lastUpdated).fromNow(); const compactTimeAgo = getCompactTimeAgo(stats.lastUpdated); const footerText = isNarrow ? compactTimeAgo : t("lastUpdated", { time: timeAgo }); if (isCompactSurface) { const compactContentHeight = showCompactFooter ? height - 18 : height; const compactStatCols = Math.max(1, Math.floor(width / 120)); const compactStatKeys = visibleStatKeys.slice(0, Math.min(visibleStatKeys.length, compactStatCols * 2)); const compactContent = compactPrimaryContent === "hero" ? ( ) : compactPrimaryContent === "os" ? (
) : compactPrimaryContent === "stats" ? ( = 180 || height >= 140} getLabel={t} /> ) : null; if (!compactContent) { return (
—
); } return (
{compactContent} {showCompactFooter && ( {footerText} )}
); } if (!hasContent) { return (
—
); } const statGridContent = showStats && (
{visibleStatKeys.map((statKey) => { const severity = resolveStatColor(statKey, statValues[statKey], colorContext, options); const Icon = statIcons[statKey]; const tile = (
{showIcons && ( )} {statValues[statKey]} {showLabels && {t(statKey)}}
); if (!showLabels) { return ( {tile} ); } return tile; })}
); const osContent = showOsSection && (
0 ? classes.osSectionWithStats : ""}`}>
); const needsScroll = layout === "mini" || layout === "compact"; return (
{showHero && ( )} {(statGridContent || osContent) && (needsScroll ? (
{statGridContent} {osContent}
) : (
{statGridContent} {osContent}
))} {showFooter && ( {footerText} )}
); } function ComplianceHero({ compliancePercent, complianceColor, upToDateHosts, totalHosts, ringSize, showText, expanded = false, compact = false, getLabel, }: { compliancePercent: number; complianceColor: string; upToDateHosts: number; totalHosts: number; ringSize: number; showText: boolean; expanded?: boolean; compact?: boolean; getLabel: (key: "compliance" | "complianceRatio", params?: { upToDate: number; total: number }) => string; }) { const heroClasses = [ compact && classes.compactPrimary, classes.hero, expanded && classes.heroExpanded, showText ? classes.heroWithText : classes.heroRingOnly, ] .filter(Boolean) .join(" "); return (
{showText && (
{getLabel("compliance")} {getLabel("complianceRatio", { upToDate: upToDateHosts, total: totalHosts })}
)} {compliancePercent}% } />
); } function getIconSize(width: number): number { const match = iconSizeByWidth.find(({ minWidth }) => width >= minWidth); return match?.size ?? 16; } function getRingSize(width: number, height: number, compact: boolean): number { if (compact) { return Math.max(36, Math.min(64, width - 28, height - 28)); } const match = ringSizeByWidth.find(({ minWidth }) => width >= minWidth); return match?.size ?? 44; } function getCompactPrimaryContent({ showHero, showOsDistribution, visibleStatKeys, }: { showHero: boolean; showOsDistribution: boolean; visibleStatKeys: PatchMonStatKey[]; }): CompactPrimaryContent { if (showHero) return "hero"; if (showOsDistribution) return "os"; return visibleStatKeys.length > 0 ? "stats" : null; } function getCompactTimeAgo(lastUpdated: string): string { const minutes = Math.max(0, dayjs().diff(dayjs(lastUpdated), "minute")); if (minutes < 1) return "now"; if (minutes < 60) return `${minutes}m ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ago`; const days = Math.floor(hours / 24); return `${days}d ago`; } function CompactStatGrid({ statKeys, statValues, colorContext, options, columns, showIcon, getLabel, }: { statKeys: PatchMonStatKey[]; statValues: Record; colorContext: { totalHosts: number }; options: WidgetComponentProps<"patchmon">["options"]; columns: number; showIcon: boolean; getLabel: (key: PatchMonStatKey) => string; }) { return (
{statKeys.map((statKey) => { const severity = resolveStatColor(statKey, statValues[statKey], colorContext, options); const Icon = statIcons[statKey]; const label = getLabel(statKey); return (
{showIcon && ( )} {statValues[statKey]} {label}
); })}
); }