feat: add TLS certificate expiry check to HTTPS network monitors (#2401)

Co-authored-by: henrygd <hank@henrygd.me>
This commit is contained in:
Sven van Ginkel
2026-09-23 23:07:29 +02:00
committed by GitHub
parent 0870716052
commit a99fe5e997
16 changed files with 547 additions and 19 deletions

View File

@@ -16,6 +16,7 @@ import {
PlayCircleIcon,
CopyIcon,
CopyPlusIcon,
ShieldCheckIcon,
} from "lucide-react"
import { t } from "@lingui/core/macro"
import type { NetworkMonitorRecord, SystemRecord } from "@/types"
@@ -29,7 +30,7 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Trans } from "@lingui/react/macro"
import { Plural, Trans } from "@lingui/react/macro"
import { $allSystemsById, $longestSystemName } from "@/lib/stores"
import { useStore } from "@nanostores/react"
import { SystemStatus } from "@/lib/enums"
@@ -37,9 +38,11 @@ import { Checkbox } from "@/components/ui/checkbox"
import { useMemo } from "react"
import { formatBulkMonitorLine } from "@/components/network-monitors-table/monitor-dialog"
import { Badge } from "../ui/badge"
import { getMonitorTarget } from "@/lib/network-monitor-utils"
import { getCertDaysLeft, getCertExpiryLevel, getMonitorTarget } from "@/lib/network-monitor-utils"
import { pb } from "@/lib/api"
const certExpiryDotColors = { ok: "bg-green-500", warning: "bg-yellow-500", critical: "bg-red-500" }
declare module "@tanstack/react-table" {
interface ColumnMeta<TData, TValue> {
label?: string
@@ -249,6 +252,31 @@ export function getMonitorColumns(
)
},
},
{
id: "cert",
meta: { label: t`Certificate` },
accessorFn: (record) => record.certInfo?.expires,
header: ({ column }) => <HeaderButton column={column} name={t`Certificate`} Icon={ShieldCheckIcon} />,
cell: ({ row }) => {
const { certInfo, system } = row.original
const systemRecord = useStore($allSystemsById)[system]
if (!certInfo?.expires) {
return <span className="ms-1.5 text-muted-foreground">-</span>
}
const daysLeft = getCertDaysLeft(certInfo)
const color = isMuted(row.original, systemRecord)
? "bg-muted-foreground/50"
: certExpiryDotColors[getCertExpiryLevel(daysLeft)]
return (
<span className="ms-1.5 tabular-nums flex gap-2 items-center">
<span className={cn("shrink-0 size-2 rounded-full", color)} />
{daysLeft < 0 ? <Trans>Expired</Trans> : <Plural value={daysLeft} one="# day" other="# days" />}
</span>
)
},
},
{
id: "updated",
meta: { label: t`Updated` },

View File

@@ -1,6 +1,6 @@
import { getMonitorTarget } from "@/lib/network-monitor-utils"
import { getCertDaysLeft, getCertExpiryLevel, getMonitorTarget } from "@/lib/network-monitor-utils"
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import { Plural, Trans } from "@lingui/react/macro"
import {
type ColumnFiltersState,
flexRender,
@@ -37,14 +37,8 @@ import { isReadOnlyUser, queueUserSettings } from "@/lib/api"
import { pb } from "@/lib/api"
import { SystemStatus } from "@/lib/enums"
import { $allSystemsById, $direction, $userSettings, getUserChartTime } from "@/lib/stores"
import {
cn,
isVisuallyLonger,
matchesFilterGroups,
parseFilterGroups,
parseSemVer,
} from "@/lib/utils"
import type { ChartData, NetworkMonitorRecord } from "@/types"
import { cn, formatShortDate, isVisuallyLonger, matchesFilterGroups, parseFilterGroups, parseSemVer } from "@/lib/utils"
import type { ChartData, MonitorCertInfo, NetworkMonitorRecord } from "@/types"
import { AddMonitorDialog, EditMonitorDialog } from "./monitor-dialog"
import {
ArrowDownIcon,
@@ -53,9 +47,11 @@ import {
ArrowUpIcon,
EthernetPortIcon,
EyeIcon,
LandmarkIcon,
LoaderCircleIcon,
ServerIcon,
Settings2Icon,
ShieldCheckIcon,
XIcon,
} from "lucide-react"
import {
@@ -636,6 +632,36 @@ function NetworkMonitorSheet({
return <NetworkMonitorSheetContent key={monitor.system} open={open} onOpenChange={onOpenChange} monitor={monitor} />
}
const certExpiryTextColors = { ok: "", warning: "text-yellow-600 dark:text-yellow-500", critical: "text-red-500" }
function CertExpiry({ cert }: { cert: MonitorCertInfo }) {
const daysLeft = getCertDaysLeft(cert)
const expires = formatShortDate(new Date(cert.expires).toISOString())
const level = getCertExpiryLevel(daysLeft)
return (
<>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<ShieldCheckIcon className={cn("size-3.5 text-muted-foreground -me-1", certExpiryTextColors[level])} />
<span className={certExpiryTextColors[level]}>
{daysLeft < 0 ? (
<Trans>Certificate expired {expires}</Trans>
) : (
<Trans>
Certificate expires {expires}
</Trans>
)}
</span>
{cert.issuer && (
<>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<LandmarkIcon className="size-3.5 text-muted-foreground -me-0.5" />
<span>{cert.issuer}</span>
</>
)}
</>
)
}
function NetworkMonitorSheetContent({
open,
onOpenChange,
@@ -683,7 +709,7 @@ function NetworkMonitorSheetContent({
{system?.name ?? ""}
</Link>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<ArrowLeftRightIcon className="size-3.5 text-muted-foreground" />
<ArrowLeftRightIcon className="size-3.5 text-muted-foreground -me-0.5" />
{monitor.protocol.toUpperCase()}
{monitor.protocol === "tcp" && monitor.port > 0 && (
<>
@@ -692,6 +718,7 @@ function NetworkMonitorSheetContent({
<span>{monitor.port}</span>
</>
)}
{monitor.certInfo?.expires ? <CertExpiry cert={monitor.certInfo} /> : null}
</SheetDescription>
</SheetHeader>
<div className="grid gap-4">

View File

@@ -1,4 +1,4 @@
import type { MonitorStats, NetworkMonitorRecord, RawMonitorStatsRecord } from "@/types"
import type { MonitorCertInfo, MonitorStats, NetworkMonitorRecord, RawMonitorStatsRecord } from "@/types"
/** Derive chart metrics from the counts and response sum stored at every retention tier. */
export function getMonitorStats(record: RawMonitorStatsRecord): MonitorStats {
@@ -15,3 +15,15 @@ export function getMonitorTarget(monitor: Pick<NetworkMonitorRecord, "target" |
const host = monitor.target.includes(":") && !monitor.target.startsWith("[") ? `[${monitor.target}]` : monitor.target
return `${host}:${monitor.port}`
}
/** Whole days until the certificate expires; negative once expired. */
export function getCertDaysLeft(cert: Pick<MonitorCertInfo, "expires">, now = Date.now()) {
return Math.floor((cert.expires - now) / 86_400_000)
}
/** Expiry severity used for certificate colors. */
export function getCertExpiryLevel(daysLeft: number): "ok" | "warning" | "critical" {
if (daysLeft < 7) return "critical"
if (daysLeft < 14) return "warning"
return "ok"
}

View File

@@ -74,7 +74,7 @@ async function fetchMonitorStats(
}
const NETWORK_MONITOR_FIELDS =
"id,system,target,protocol,port,interval,res,resMin1h,resMax1h,resAvg1h,loss1h,enabled,updated"
"id,system,target,protocol,port,interval,res,resMin1h,resMax1h,resAvg1h,loss1h,enabled,certInfo,updated"
interface UseNetworkMonitorsProps {
systemId?: string

View File

@@ -652,9 +652,17 @@ export interface NetworkMonitorRecord {
loss1h: number
interval: number
enabled: boolean
/** Latest TLS certificate details, reported for HTTPS targets. */
certInfo?: MonitorCertInfo | null
updated: string
}
/** Leaf TLS certificate details reported by the agent. Timestamps are Unix milliseconds. */
export interface MonitorCertInfo {
expires: number
issuer?: string
}
/** Response times in microseconds and packet loss percentage (0-100). */
export interface MonitorStats {
res_avg: number