From a8c160bc050282c1ff07c1d7a090bae94eeeac4d Mon Sep 17 00:00:00 2001 From: Keith Smith Date: Sat, 23 May 2026 15:33:54 -0600 Subject: [PATCH] Show TLS certificate validity details Include healthy TLS certificate validity in website check messages and show latest website check results in the UI. --- frontend/src/api/client.ts | 3 +++ frontend/src/pages/WebsitesPage.tsx | 31 ++++++++++++++++++++++-- frontend/src/types/api.ts | 9 +++++++ worker/app/collectors/website.py | 37 +++++++++++++++++++++-------- 4 files changed, 68 insertions(+), 12 deletions(-) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index dcf99a9..61c1d1f 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,5 +1,6 @@ import type { Asset, + CheckResult, Incident, Monitor, MonitorUpdate, @@ -82,6 +83,8 @@ export const api = { request(`/monitors/${monitorId}`, token, { method: "DELETE", }), + monitorResults: (token: string, monitorId: number, limit = 1) => + request(`/monitors/${monitorId}/results?limit=${limit}`, token), incidents: (token: string) => request("/incidents", token), acknowledgeIncident: (token: string, incidentId: number) => request(`/incidents/${incidentId}/acknowledge`, token, { diff --git a/frontend/src/pages/WebsitesPage.tsx b/frontend/src/pages/WebsitesPage.tsx index ce8ee8d..668c169 100644 --- a/frontend/src/pages/WebsitesPage.tsx +++ b/frontend/src/pages/WebsitesPage.tsx @@ -1,9 +1,9 @@ -import { FormEvent, useState } from "react"; +import { FormEvent, useEffect, useState } from "react"; import { Edit3, Globe2, Plus, RefreshCw, Trash2, X } from "lucide-react"; import { api } from "../api/client"; import { Button } from "../components/Button"; -import type { Monitor } from "../types/api"; +import type { CheckResult, Monitor } from "../types/api"; interface WebsitesPageProps { token: string; @@ -27,6 +27,32 @@ export function WebsitesPage({ token, monitors, onCreated }: WebsitesPageProps) const [submitting, setSubmitting] = useState(false); const [deletingId, setDeletingId] = useState(null); const [error, setError] = useState(null); + const [latestResults, setLatestResults] = useState>({}); + + useEffect(() => { + let cancelled = false; + async function loadLatestResults() { + const resultEntries = await Promise.all( + websites.map(async (monitor) => { + const results = await api.monitorResults(token, monitor.id, 1); + return [monitor.id, results[0]] as const; + }) + ); + if (!cancelled) { + setLatestResults(Object.fromEntries(resultEntries.filter(([, result]) => Boolean(result)))); + } + } + if (websites.length) { + loadLatestResults().catch(() => { + if (!cancelled) setLatestResults({}); + }); + } else { + setLatestResults({}); + } + return () => { + cancelled = true; + }; + }, [token, monitors]); async function handleSubmit(event: FormEvent) { event.preventDefault(); @@ -211,6 +237,7 @@ export function WebsitesPage({ token, monitors, onCreated }: WebsitesPageProps)
{monitor.name}
{monitor.target}
+ {latestResults[monitor.id]?.message ?
{latestResults[monitor.id].message}
: null} {monitor.config?.check_tls_expiry ?
TLS warning at {String(monitor.config.tls_warning_days ?? 30)} days
: null}
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 174f551..b4585d9 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -33,6 +33,15 @@ export interface MonitorUpdate { interval_seconds?: number; } +export interface CheckResult { + id: number; + monitor_id: number; + status: string; + response_time_ms?: number | null; + message?: string | null; + observed_at: string; +} + export interface Incident { id: number; asset_id?: number | null; diff --git a/worker/app/collectors/website.py b/worker/app/collectors/website.py index bfbab32..59bcaa7 100644 --- a/worker/app/collectors/website.py +++ b/worker/app/collectors/website.py @@ -28,13 +28,22 @@ class WebsiteCheckResult: message: str +@dataclass(frozen=True) +class TlsExpiryResult: + status: str + response_time_ms: int + message: str + + async def run_website_check(config: WebsiteCheckConfig) -> WebsiteCheckResult: started = perf_counter() + tls_message: str | None = None if config.check_tls_expiry: tls_result = await check_tls_expiry(config.url, config.tls_warning_days, config.timeout_seconds) - if tls_result is not None: + if tls_result.status != "up": return tls_result + tls_message = tls_result.message try: async with httpx.AsyncClient(follow_redirects=True, timeout=config.timeout_seconds) as client: @@ -61,38 +70,46 @@ async def run_website_check(config: WebsiteCheckConfig) -> WebsiteCheckResult: response_time_ms=response_time_ms, message="Unexpected text was present", ) - return WebsiteCheckResult(status="up", response_time_ms=response_time_ms, message="Website check passed") + message = "Website check passed" + if tls_message: + message = f"{message}; {tls_message}" + return WebsiteCheckResult(status="up", response_time_ms=response_time_ms, message=message) -async def check_tls_expiry(url: str, warning_days: int, timeout_seconds: float) -> WebsiteCheckResult | None: +async def check_tls_expiry(url: str, warning_days: int, timeout_seconds: float) -> TlsExpiryResult: parsed_url = urlparse(url) if parsed_url.scheme != "https": - return None + return TlsExpiryResult(status="up", response_time_ms=0, message="TLS expiry check skipped for non-HTTPS target") if not parsed_url.hostname: - return WebsiteCheckResult(status="down", response_time_ms=None, message="TLS check could not determine the hostname") + return TlsExpiryResult(status="down", response_time_ms=0, message="TLS check could not determine the hostname") started = perf_counter() try: expires_at = await _get_certificate_expiry(parsed_url.hostname, parsed_url.port or 443, timeout_seconds) except (OSError, ssl.SSLError, ValueError) as exc: - return WebsiteCheckResult(status="down", response_time_ms=None, message=f"TLS certificate check failed: {exc}") + return TlsExpiryResult(status="down", response_time_ms=int((perf_counter() - started) * 1000), message=f"TLS certificate check failed: {exc}") response_time_ms = int((perf_counter() - started) * 1000) now = datetime.now(UTC) days_remaining = (expires_at - now).total_seconds() / 86400 + days_remaining_text = str(max(0, int(days_remaining))) if days_remaining < 0: - return WebsiteCheckResult( + return TlsExpiryResult( status="down", response_time_ms=response_time_ms, message=f"TLS certificate expired on {expires_at.date().isoformat()}", ) if days_remaining <= warning_days: - return WebsiteCheckResult( + return TlsExpiryResult( status="warning", response_time_ms=response_time_ms, - message=f"TLS certificate expires in {int(days_remaining)} days on {expires_at.date().isoformat()}", + message=f"TLS certificate expires in {days_remaining_text} days on {expires_at.date().isoformat()}", ) - return None + return TlsExpiryResult( + status="up", + response_time_ms=response_time_ms, + message=f"TLS certificate valid for {days_remaining_text} days until {expires_at.date().isoformat()}", + ) async def _get_certificate_expiry(hostname: str, port: int, timeout_seconds: float) -> datetime: