SPB Git forge

spb/websensor

Public
33commits 1branches 0releases
3.4 MBsize
maindefault branch
10 days agolast push
TypeScript 55.4% Python 43.2% SQL 1.2%
17.5 KB · 311 lines tsx
Raw Blame History
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&apos;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