"use client"; import { Check, Plus, RefreshCw, Trash2 } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { EventRow } from "@/components/event-row"; import { FieldChangeInline } from "@/components/field-changes"; import { Chip, Empty, HealthPill, Panel, SkeletonRows, Table, Td } from "@/components/ui"; import { clientApiBase, type ChangeRow, type EventItem, type Monitor } from "@/lib/api"; import { CLASS_LABELS, fmtBytes, hostOf, relTime, untilTime, utcDateTime } from "@/lib/format"; import { ownerToken } from "@/lib/owner"; interface ApiError { status: number; error: string; detail?: string; limit?: number; } /** Owner-scoped request that keeps the API's error shape (`{error, detail?}`) instead of flattening it. */ async function request(path: string, init: RequestInit = {}): Promise { const res = await fetch(`${clientApiBase()}${path}`, { ...init, headers: { "content-type": "application/json", accept: "application/json", "x-websensor-owner": ownerToken(), ...(init.headers ?? {}) } }); if (!res.ok) { const body = (await res.json().catch(() => ({}))) as Partial & { issues?: { path?: (string | number)[]; message?: string }[] }; const detail = body.detail ?? body.issues?.map((i) => `${(i.path ?? []).join(".")}: ${i.message ?? ""}`).join("; "); throw Object.assign(new Error(body.error ?? `HTTP ${res.status}`), { status: res.status, error: body.error ?? `http_${res.status}`, detail, limit: body.limit } satisfies ApiError); } return (await res.json()) as T; } const ERROR_HELP: Record = { url_rejected: "Private, loopback, metadata and internal addresses are refused by the SSRF policy.", scheme_not_allowed: "Only http:// and https:// URLs can be monitored.", fetch_failed: "The page could not be fetched. Check the address, TLS certificate and that the host answers public requests.", http_error: "The page answered with an error status. Monitors need a 2xx page to take a baseline.", unparseable: "The response could not be normalized as HTML/text.", thin_content: "Almost no server-rendered text was found (client-side app?). Try a more specific URL or a CSS selector.", monitor_limit_reached: "Monitor limit reached for this browser. Delete one to add another.", not_found: "Monitor not found — it may have been deleted.", }; interface Form { url: string; name: string; frequency: "hourly" | "daily"; sensitivity: "low" | "normal" | "high"; selector: string; keywords: string; } const EMPTY: Form = { url: "", name: "", frequency: "hourly", sensitivity: "normal", selector: "", keywords: "" }; interface CreateResult { id: string; url: string; tier: string; status: string; test?: { http_status: number; content_type?: string | null; bytes?: number | null; title?: string | null; mode?: string; extraction_confidence?: number | null }; } export function Monitors() { const [items, setItems] = useState(null); const [limit, setLimit] = useState(5); const [form, setForm] = useState
(EMPTY); const [err, setErr] = useState(null); const [busy, setBusy] = useState(false); const [created, setCreated] = useState(null); const [selected, setSelected] = useState(null); /** Keyed by monitor id so switching monitors shows a loading state without a synchronous reset. */ const [loaded, setLoaded] = useState<{ id: string; events: EventItem[]; changes: ChangeRow[]; error: string | null } | null>(null); const detail = loaded && loaded.id === selected ? loaded : null; const load = useCallback( () => request<{ items: Monitor[]; limit: number }>("/api/v1/monitors") .then((r) => { setItems(r.items); setLimit(r.limit); setSelected((cur) => cur ?? r.items[0]?.id ?? null); }) .catch((e: ApiError) => { setErr(e); setItems([]); }), [], ); useEffect(() => { void load(); const t = setInterval(() => void load(), 30_000); return () => clearInterval(t); }, [load]); useEffect(() => { if (!selected) return; const id = selected; let cancelled = false; request<{ events: EventItem[]; changes: ChangeRow[] }>(`/api/v1/monitors/${encodeURIComponent(id)}/events?limit=50`) .then((r) => { if (!cancelled) setLoaded({ id, events: r.events, changes: r.changes, error: null }); }) .catch((e: ApiError) => { if (!cancelled) setLoaded({ id, events: [], changes: [], error: `${e.error}${e.detail ? ` — ${e.detail}` : ""}` }); }); return () => { cancelled = true; }; }, [selected, items]); const create = async (): Promise => { setErr(null); setCreated(null); const url = form.url.trim(); if (!/^https?:\/\//i.test(url)) { setErr({ status: 0, error: "invalid_url", detail: "Enter a full address starting with http:// or https://." }); return; } const keywords = form.keywords.split(/[,\n]/).map((k) => k.trim()).filter(Boolean).slice(0, 20); setBusy(true); try { const r = await request("/api/v1/monitors", { method: "POST", body: JSON.stringify({ url, name: form.name.trim() || undefined, frequency: form.frequency, sensitivity: form.sensitivity, selector: form.selector.trim() || undefined, keywords: keywords.length ? keywords : undefined }) }); setCreated(r); setForm(EMPTY); await load(); setSelected(r.id); } catch (e) { setErr(e as ApiError); } finally { setBusy(false); } }; const remove = async (m: Monitor): Promise => { setItems((prev) => prev?.filter((x) => x.id !== m.id) ?? prev); if (selected === m.id) setSelected(null); try { await request(`/api/v1/monitors/${encodeURIComponent(m.id)}`, { method: "DELETE" }); } catch (e) { setErr(e as ApiError); } await load(); }; const active = items?.filter((m) => m.enabled).length ?? 0; const current = items?.find((m) => m.id === selected) ?? null; const input = "h-8 rounded-md border border-line bg-panel px-2 text-[12.5px]"; return (
Your monitors {items?.length ?? "…"}} dense> {items === null ? ( ) : items.length ? ( {items.map((m) => { const sens = typeof m.config.sensitivity === "string" ? m.config.sensitivity : "normal"; const sel = m.id === selected; return ( ); })}
{m.url}
{m.status}
{m.tier === "D" ? "daily" : "hourly"} {sens}
next {untilTime(m.next_check_at)}
{m.last_check_at ? {relTime(m.last_check_at)} : "pending"} = 400 ? "text-danger" : ""}>{m.last_status ?? "—"} {m.raw_changes ?? 0} / {m.meaningful_changes ?? 0}
) : ( No monitors yet — add a public URL on the left. The first check takes a baseline; changes are reported from the second check on. )} {items?.some((m) => m.last_error) && (
    {items.filter((m) => m.last_error).map((m) => (
  • {m.name}: {m.last_error}
  • ))}
)}
Events {current && · {current.name}}} dense> {!current ? ( Select a monitor to see its events and raw changes. ) : detail === null ? ( ) : detail.events.length ? ( detail.events.map((e) => ) ) : ( {detail.error ?? (current.total_runs ? "No meaningful change detected yet." : "Waiting for the first check to take a baseline.")} )} {current && detail && detail.changes.length > 0 && ( Raw changes {detail.changes.length} · before noise filtering} dense> {detail.changes.map((c) => ( ))}
{relTime(c.detected_at)} {c.kind} {c.change_class ? {CLASS_LABELS[c.change_class] ?? c.change_class} : —} {Math.round(Number(c.signal))} {c.field_changes?.length ? : —} {c.event_id ? event : filtered}
)}
); }