TypeScript 55.4%
Python 43.2%
SQL 1.2%
1"use client";23import { Check, Plus, RefreshCw, Trash2 } from "lucide-react";4import { useCallback, useEffect, useState } from "react";5import { EventRow } from "@/components/event-row";6import { FieldChangeInline } from "@/components/field-changes";7import { Chip, Empty, HealthPill, Panel, SkeletonRows, Table, Td } from "@/components/ui";8import { clientApiBase, type ChangeRow, type EventItem, type Monitor } from "@/lib/api";9import { CLASS_LABELS, fmtBytes, hostOf, relTime, untilTime, utcDateTime } from "@/lib/format";10import { ownerToken } from "@/lib/owner";1112interface ApiError {13 status: number;14 error: string;15 detail?: string;16 limit?: number;17}1819/** Owner-scoped request that keeps the API's error shape (`{error, detail?}`) instead of flattening it. */20async function request<T>(path: string, init: RequestInit = {}): Promise<T> {21 const res = await fetch(`${clientApiBase()}${path}`, { ...init, headers: { "content-type": "application/json", accept: "application/json", "x-websensor-owner": ownerToken(), ...(init.headers ?? {}) } });22 if (!res.ok) {23 const body = (await res.json().catch(() => ({}))) as Partial<ApiError> & { issues?: { path?: (string | number)[]; message?: string }[] };24 const detail = body.detail ?? body.issues?.map((i) => `${(i.path ?? []).join(".")}: ${i.message ?? ""}`).join("; ");25 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);26 }27 return (await res.json()) as T;28}2930const ERROR_HELP: Record<string, string> = {31 url_rejected: "Private, loopback, metadata and internal addresses are refused by the SSRF policy.",32 scheme_not_allowed: "Only http:// and https:// URLs can be monitored.",33 fetch_failed: "The page could not be fetched. Check the address, TLS certificate and that the host answers public requests.",34 http_error: "The page answered with an error status. Monitors need a 2xx page to take a baseline.",35 unparseable: "The response could not be normalized as HTML/text.",36 thin_content: "Almost no server-rendered text was found (client-side app?). Try a more specific URL or a CSS selector.",37 monitor_limit_reached: "Monitor limit reached for this browser. Delete one to add another.",38 not_found: "Monitor not found — it may have been deleted.",39};4041interface Form {42 url: string;43 name: string;44 frequency: "hourly" | "daily";45 sensitivity: "low" | "normal" | "high";46 selector: string;47 keywords: string;48}49const EMPTY: Form = { url: "", name: "", frequency: "hourly", sensitivity: "normal", selector: "", keywords: "" };5051interface CreateResult {52 id: string;53 url: string;54 tier: string;55 status: string;56 test?: { http_status: number; content_type?: string | null; bytes?: number | null; title?: string | null; mode?: string; extraction_confidence?: number | null };57}5859export function Monitors() {60 const [items, setItems] = useState<Monitor[] | null>(null);61 const [limit, setLimit] = useState(5);62 const [form, setForm] = useState<Form>(EMPTY);63 const [err, setErr] = useState<ApiError | null>(null);64 const [busy, setBusy] = useState(false);65 const [created, setCreated] = useState<CreateResult | null>(null);66 const [selected, setSelected] = useState<string | null>(null);67 /** Keyed by monitor id so switching monitors shows a loading state without a synchronous reset. */68 const [loaded, setLoaded] = useState<{ id: string; events: EventItem[]; changes: ChangeRow[]; error: string | null } | null>(null);69 const detail = loaded && loaded.id === selected ? loaded : null;7071 const load = useCallback(72 () =>73 request<{ items: Monitor[]; limit: number }>("/api/v1/monitors")74 .then((r) => {75 setItems(r.items);76 setLimit(r.limit);77 setSelected((cur) => cur ?? r.items[0]?.id ?? null);78 })79 .catch((e: ApiError) => {80 setErr(e);81 setItems([]);82 }),83 [],84 );85 useEffect(() => {86 void load();87 const t = setInterval(() => void load(), 30_000);88 return () => clearInterval(t);89 }, [load]);9091 useEffect(() => {92 if (!selected) return;93 const id = selected;94 let cancelled = false;95 request<{ events: EventItem[]; changes: ChangeRow[] }>(`/api/v1/monitors/${encodeURIComponent(id)}/events?limit=50`)96 .then((r) => {97 if (!cancelled) setLoaded({ id, events: r.events, changes: r.changes, error: null });98 })99 .catch((e: ApiError) => {100 if (!cancelled) setLoaded({ id, events: [], changes: [], error: `${e.error}${e.detail ? ` — ${e.detail}` : ""}` });101 });102 return () => {103 cancelled = true;104 };105 }, [selected, items]);106107 const create = async (): Promise<void> => {108 setErr(null);109 setCreated(null);110 const url = form.url.trim();111 if (!/^https?:\/\//i.test(url)) {112 setErr({ status: 0, error: "invalid_url", detail: "Enter a full address starting with http:// or https://." });113 return;114 }115 const keywords = form.keywords.split(/[,\n]/).map((k) => k.trim()).filter(Boolean).slice(0, 20);116 setBusy(true);117 try {118 const r = await request<CreateResult>("/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 }) });119 setCreated(r);120 setForm(EMPTY);121 await load();122 setSelected(r.id);123 } catch (e) {124 setErr(e as ApiError);125 } finally {126 setBusy(false);127 }128 };129130 const remove = async (m: Monitor): Promise<void> => {131 setItems((prev) => prev?.filter((x) => x.id !== m.id) ?? prev);132 if (selected === m.id) setSelected(null);133 try {134 await request(`/api/v1/monitors/${encodeURIComponent(m.id)}`, { method: "DELETE" });135 } catch (e) {136 setErr(e as ApiError);137 }138 await load();139 };140141 const active = items?.filter((m) => m.enabled).length ?? 0;142 const current = items?.find((m) => m.id === selected) ?? null;143 const input = "h-8 rounded-md border border-line bg-panel px-2 text-[12.5px]";144145 return (146 <div className="grid gap-4 lg:grid-cols-[380px_minmax(0,1fr)]">147 <aside className="flex min-w-0 flex-col gap-4">148 <Panel title={<span>New monitor <span className="normal-case tracking-normal text-fg-subtle">· {active} / {limit} used</span></span>}>149 <form150 className="flex flex-col gap-3 text-[12.5px]"151 onSubmit={(e) => {152 e.preventDefault();153 void create();154 }}155 >156 <label className="flex flex-col gap-1">157 <span className="label">URL</span>158 <input type="url" required value={form.url} onChange={(e) => setForm({ ...form, url: e.target.value })} placeholder="https://example.com/pricing" className={`${input} font-mono text-[12px]`} />159 </label>160 <label className="flex flex-col gap-1">161 <span className="label">Name <span className="normal-case tracking-normal">· optional</span></span>162 <input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder={form.url ? hostOf(form.url) : "Defaults to the host name"} maxLength={80} className={input} />163 </label>164 <div className="grid grid-cols-2 gap-3">165 <div>166 <div className="label mb-1">Frequency</div>167 <div className="flex gap-1" role="radiogroup" aria-label="Frequency">168 {(["hourly", "daily"] as const).map((f) => (169 <button key={f} type="button" role="radio" aria-checked={form.frequency === f} onClick={() => setForm({ ...form, frequency: f })} className={`h-8 flex-1 rounded-md border text-[12px] ${form.frequency === f ? "border-signal/50 bg-signal-soft text-signal" : "border-line bg-panel text-fg-muted hover:text-fg"}`}>170 {f}171 </button>172 ))}173 </div>174 </div>175 <div>176 <div className="label mb-1">Sensitivity</div>177 <div className="flex gap-1" role="radiogroup" aria-label="Sensitivity">178 {(["low", "normal", "high"] as const).map((s) => (179 <button key={s} type="button" role="radio" aria-checked={form.sensitivity === s} onClick={() => setForm({ ...form, sensitivity: s })} title={s === "low" ? "Only big changes" : s === "high" ? "Every meaningful change" : "Balanced"} className={`h-8 flex-1 rounded-md border text-[12px] ${form.sensitivity === s ? "border-signal/50 bg-signal-soft text-signal" : "border-line bg-panel text-fg-muted hover:text-fg"}`}>180 {s}181 </button>182 ))}183 </div>184 </div>185 </div>186 <label className="flex flex-col gap-1">187 <span className="label">CSS selector <span className="normal-case tracking-normal">· optional, narrows the watched region</span></span>188 <input value={form.selector} onChange={(e) => setForm({ ...form, selector: e.target.value })} placeholder="main .pricing-table" maxLength={200} className={`${input} font-mono text-[12px]`} />189 </label>190 <label className="flex flex-col gap-1">191 <span className="label">Keywords <span className="normal-case tracking-normal">· optional, comma-separated</span></span>192 <input value={form.keywords} onChange={(e) => setForm({ ...form, keywords: e.target.value })} placeholder="price, deprecated, discontinued" className={input} />193 </label>194 <button type="submit" disabled={busy || active >= limit} className="inline-flex h-8 items-center justify-center gap-1 rounded-md border border-line bg-panel-2 px-3 text-[12.5px] hover:border-line-strong disabled:opacity-60">195 {busy ? <RefreshCw className="size-3.5 animate-spin" /> : <Plus className="size-3.5" />} {busy ? "Fetching & testing…" : active >= limit ? "Limit reached" : "Create monitor"}196 </button>197 {err && (198 <div className="rounded-md border border-danger/40 bg-danger/10 px-2 py-1.5 text-[12px]">199 <div className="font-mono font-semibold text-danger">{err.error}{err.status ? ` · HTTP ${err.status}` : ""}</div>200 {err.detail && <div className="mt-0.5 break-words text-fg">{err.detail}</div>}201 {ERROR_HELP[err.error] && <div className="mt-0.5 text-fg-muted">{ERROR_HELP[err.error]}</div>}202 </div>203 )}204 {created && (205 <div className="rounded-md border border-ok/40 bg-ok/10 px-2 py-1.5 text-[12px]">206 <div className="flex items-center gap-1 font-semibold text-ok"><Check className="size-3.5" /> Monitor created · baseline test passed</div>207 {created.test && (208 <div className="mt-0.5 font-mono text-[11px] text-fg-muted tabular">209 HTTP {created.test.http_status} · {created.test.content_type ?? "—"} · {fmtBytes(created.test.bytes)} · {created.test.mode ?? "text"} · confidence {created.test.extraction_confidence !== null && created.test.extraction_confidence !== undefined ? Math.round(created.test.extraction_confidence * 100) : "—"}%210 {created.test.title && <div className="truncate text-fg">“{created.test.title}”</div>}211 </div>212 )}213 </div>214 )}215 </form>216 </Panel>217 <Panel title="Limits & rules">218 <ul className="list-disc space-y-1 pl-4 text-[12.5px] text-fg-muted">219 <li><span className="text-fg">{limit} monitors per browser</span>, scoped to this browser's anonymous owner token.</li>220 <li><span className="text-fg">Public URLs only.</span> Private networks, loopback and cloud-metadata addresses are rejected; redirects are checked hop by hop.</li>221 <li><span className="text-fg">Respects robots and rate limits.</span> Conditional GET, one request per interval, per-host concurrency caps, circuit breaker on failing hosts.</li>222 <li><span className="text-fg">No authentication bypass.</span> Pages behind logins, paywalls or bot walls are not fetched with credentials or headless browsers.</li>223 <li>Hourly = tier C, daily = tier D. The baseline is taken on the first engine run; the first event needs a second snapshot.</li>224 <li>Custom monitors never enter the public feed, rankings or clusters.</li>225 </ul>226 </Panel>227 </aside>228229 <div className="flex min-w-0 flex-col gap-4">230 <Panel title={<span>Your monitors <span className="font-mono text-fg-subtle">{items?.length ?? "…"}</span></span>} dense>231 {items === null ? (232 <SkeletonRows rows={3} />233 ) : items.length ? (234 <Table head={["Monitor", "Status", "Schedule", "Last check", "HTTP", "Changes", ""]}>235 {items.map((m) => {236 const sens = typeof m.config.sensitivity === "string" ? m.config.sensitivity : "normal";237 const sel = m.id === selected;238 return (239 <tr key={m.id} className={sel ? "bg-panel-2/60" : ""}>240 <Td>241 <button type="button" aria-pressed={sel} onClick={() => setSelected(m.id)} className="block max-w-[18rem] truncate text-left font-medium hover:underline">{m.name}</button>242 <a href={m.url} target="_blank" rel="noopener noreferrer nofollow" className="block max-w-[18rem] truncate font-mono text-[10.5px] text-fg-subtle hover:text-info" title={m.url}>{m.url}</a>243 </Td>244 <Td>245 <div className="flex flex-wrap gap-1">246 <HealthPill health={m.enabled ? m.health : "DISABLED"} />247 <Chip className="font-mono">{m.status}</Chip>248 </div>249 </Td>250 <Td>251 <div className="flex flex-wrap gap-1">252 <Chip>{m.tier === "D" ? "daily" : "hourly"}</Chip>253 <Chip tone={sens === "high" ? "high" : sens === "low" ? "default" : "info"}>{sens}</Chip>254 </div>255 <div className="mt-0.5 font-mono text-[10.5px] text-fg-subtle tabular">next {untilTime(m.next_check_at)}</div>256 </Td>257 <Td mono className="whitespace-nowrap text-fg-subtle">{m.last_check_at ? <span title={utcDateTime(m.last_check_at)}>{relTime(m.last_check_at)}</span> : "pending"}</Td>258 <Td mono className={m.last_status && m.last_status >= 400 ? "text-danger" : ""}>{m.last_status ?? "—"}</Td>259 <Td mono className="whitespace-nowrap" ><span title="raw / meaningful">{m.raw_changes ?? 0} / {m.meaningful_changes ?? 0}</span></Td>260 <Td>261 <button type="button" aria-label={`Delete monitor ${m.name}`} onClick={() => remove(m)} className="text-fg-subtle hover:text-danger"><Trash2 className="size-3.5" /></button>262 </Td>263 </tr>264 );265 })}266 </Table>267 ) : (268 <Empty>No monitors yet — add a public URL on the left. The first check takes a baseline; changes are reported from the second check on.</Empty>269 )}270 {items?.some((m) => m.last_error) && (271 <ul className="border-t border-line px-3 py-2 text-[11px]">272 {items.filter((m) => m.last_error).map((m) => (273 <li key={m.id} className="truncate font-mono text-danger" title={m.last_error ?? ""}>{m.name}: {m.last_error}</li>274 ))}275 </ul>276 )}277 </Panel>278279 <Panel title={<span>Events {current && <span className="normal-case tracking-normal text-fg-subtle">· {current.name}</span>}</span>} dense>280 {!current ? (281 <Empty>Select a monitor to see its events and raw changes.</Empty>282 ) : detail === null ? (283 <SkeletonRows rows={3} />284 ) : detail.events.length ? (285 detail.events.map((e) => <EventRow key={e.id} ev={e} showDate />)286 ) : (287 <Empty>{detail.error ?? (current.total_runs ? "No meaningful change detected yet." : "Waiting for the first check to take a baseline.")}</Empty>288 )}289 </Panel>290291 {current && detail && detail.changes.length > 0 && (292 <Panel title={<span>Raw changes <span className="font-mono text-fg-subtle">{detail.changes.length}</span> <span className="normal-case tracking-normal text-fg-subtle">· before noise filtering</span></span>} dense>293 <Table head={["Detected", "Kind", "Class", "Signal", "Fields", "Event"]}>294 {detail.changes.map((c) => (295 <tr key={c.id}>296 <Td mono className="whitespace-nowrap text-fg-subtle"><span title={utcDateTime(c.detected_at)}>{relTime(c.detected_at)}</span></Td>297 <Td mono>{c.kind}</Td>298 <Td>{c.change_class ? <Chip tone={c.meaningful ? "signal" : "default"}>{CLASS_LABELS[c.change_class] ?? c.change_class}</Chip> : <span className="text-fg-subtle">—</span>}</Td>299 <Td mono>{Math.round(Number(c.signal))}</Td>300 <Td>{c.field_changes?.length ? <FieldChangeInline items={c.field_changes} max={2} /> : <span className="text-fg-subtle">—</span>}</Td>301 <Td>{c.event_id ? <Chip tone="ok">event</Chip> : <span className="text-[11px] text-fg-subtle">filtered</span>}</Td>302 </tr>303 ))}304 </Table>305 </Panel>306 )}307 </div>308 </div>309 );310}311