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.2 KB · 331 lines tsx
Raw Blame History
1"use client";23import Link from "next/link";4import { usePathname } from "next/navigation";5import { Bookmark, BookmarkCheck, ExternalLink, Maximize2, X } from "lucide-react";6import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";7import type { EventDetail, EventItem } from "@/lib/api";8import { fmtMs, fmtOffset, fmtScore, relTime, typeLabel, utcDateTime, CLASS_LABELS } from "@/lib/format";9import { ownerFetch, publicFetch } from "@/lib/owner";10import { FieldChanges } from "./field-changes";11import { Badge, Bar, Chip, EvidenceTag, Flag, Score, Skeleton, StateBadge, TypeChip } from "./ui";1213/**14 * Intelligence panel (spec §33, §78): a right drawer on desktop, a full-screen sheet on mobile.15 * Opens from any feed row without navigation; the permanent page stays one click away.16 */17interface DrawerCtx {18  open: (slug: string, seed?: EventItem) => void;19  close: () => void;20}21const Ctx = createContext<DrawerCtx>({ open: () => {}, close: () => {} });22export const useEventDrawer = (): DrawerCtx => useContext(Ctx);2324export function EventDrawerProvider({ children }: { children: ReactNode }) {25  // The drawer state is keyed by pathname: a route change opens a fresh (closed) drawer.26  const pathname = usePathname();27  const [state, setState] = useState<{ path: string; slug: string | null; seed: EventItem | null; detail: EventDetail | null; loading: boolean }>({ path: pathname, slug: null, seed: null, detail: null, loading: false });28  const current = state.path === pathname ? state : { path: pathname, slug: null, seed: null, detail: null, loading: false };29  const slug = current.slug;30  const seed = current.seed;31  const detail = current.detail;32  const loading = current.loading;33  const open = useCallback((s: string, e?: EventItem) => setState({ path: pathname, slug: s, seed: e ?? null, detail: null, loading: true }), [pathname]);34  const close = useCallback(() => setState({ path: pathname, slug: null, seed: null, detail: null, loading: false }), [pathname]);35  useEffect(() => {36    if (!slug) return;37    let cancelled = false;38    publicFetch<EventDetail>(`/api/v1/events/${encodeURIComponent(slug)}`)39      .then((d) => {40        if (!cancelled) setState((st) => (st.slug === slug ? { ...st, detail: d, loading: false } : st));41      })42      .catch(() => {43        if (!cancelled) setState((st) => (st.slug === slug ? { ...st, loading: false } : st));44      });45    return () => {46      cancelled = true;47    };48  }, [slug]);49  useEffect(() => {50    if (!slug) return;51    const onKey = (e: KeyboardEvent): void => {52      if (e.key === "Escape") close();53    };54    document.addEventListener("keydown", onKey);55    const prev = document.body.style.overflow;56    if (window.innerWidth < 1024) document.body.style.overflow = "hidden";57    return () => {58      document.removeEventListener("keydown", onKey);59      document.body.style.overflow = prev;60    };61  }, [slug, close]);62  const value = useMemo(() => ({ open, close }), [open, close]);63  return (64    <Ctx.Provider value={value}>65      {children}66      {slug && <Drawer slug={slug} seed={seed} detail={detail} loading={loading} onClose={close} />}67    </Ctx.Provider>68  );69}7071function Drawer({ slug, seed, detail, loading, onClose }: { slug: string; seed: EventItem | null; detail: EventDetail | null; loading: boolean; onClose: () => void }) {72  const ev = detail?.event ?? seed;73  const ref = useRef<HTMLDivElement>(null);74  useEffect(() => {75    ref.current?.focus();76  }, [slug]);77  return (78    <div className="fixed inset-0 z-50 flex justify-end" role="dialog" aria-modal="true" aria-label={ev?.title ?? "Event"}>79      <button type="button" aria-label="Close" onClick={onClose} className="scrim absolute inset-0 hidden cursor-default lg:block" />80      <div ref={ref} tabIndex={-1} className="relative flex h-full w-full flex-col bg-bg shadow-2xl outline-none animate-sheet-up lg:w-[520px] lg:animate-slide-in lg:border-l lg:border-line xl:w-[600px]">81        <header className="flex items-center gap-2 border-b border-line px-3 py-2">82          <span className="label">Intelligence panel</span>83          <span className="ml-auto flex items-center gap-1">84            {ev && <BookmarkButton eventId={ev.id} />}85            {ev && (86              <Link href={`/event/${ev.slug}`} className="inline-flex size-7 items-center justify-center rounded-md border border-line text-fg-muted hover:text-fg" title="Open full page" aria-label="Open full page">87                <Maximize2 className="size-3.5" />88              </Link>89            )}90            <button type="button" onClick={onClose} className="inline-flex size-7 items-center justify-center rounded-md border border-line text-fg-muted hover:text-fg" aria-label="Close">91              <X className="size-4" />92            </button>93          </span>94        </header>95        <div className="min-h-0 flex-1 overflow-y-auto px-3 py-3 pb-24 lg:pb-6">96          {!ev ? (97            <DrawerSkeleton />98          ) : (99            <>100              <div className="flex flex-wrap items-center gap-1.5">101                <Link href={`/source/${ev.source?.id ?? ev.source_id}`} className="font-mono text-[11px] font-semibold uppercase tracking-wide text-fg-muted hover:text-fg">{ev.source?.name}</Link>102                {ev.country && <Flag code={ev.country} className="text-[12px]" />}103                <StateBadge state={ev.cluster?.state} />104                {ev.silent_change && <Badge kind="silent" />}105                {ev.first_party === false ? <Badge kind="external" /> : <Badge kind="first-party" />}106                <EvidenceTag label={ev.evidence_label} />107              </div>108              <h2 className="mt-1.5 text-[17px] font-semibold leading-snug">{ev.title}</h2>109              <div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11.5px] text-fg-subtle">110                <TypeChip type={ev.event_type} />111                <span>detected {relTime(ev.detected_at)}</span>112                <span>·</span>113                <span className="font-mono">{utcDateTime(ev.detected_at)}</span>114              </div>115              <p className="mt-3 text-[13.5px] leading-relaxed">{ev.summary}</p>116117              {/* WHAT CHANGED */}118              {(ev.field_changes?.length ?? 0) > 0 && (119                <section className="mt-4">120                  <div className="label mb-1.5">What changed</div>121                  <FieldChanges items={ev.field_changes} max={8} />122                </section>123              )}124              {ev.why_it_matters && (125                <section className="mt-4">126                  <div className="label mb-1">Why it matters <span className="normal-case tracking-normal text-fg-subtle">· analysis</span></div>127                  <p className="text-[13px] leading-relaxed text-fg-muted">{ev.why_it_matters}</p>128                </section>129              )}130131              {/* SCORES */}132              <section className="mt-4 rounded-md border border-line bg-panel p-3">133                <div className="flex items-center gap-3">134                  <Score value={ev.signal_score ?? ev.importance} size="lg" kind="signal" />135                  <div className="min-w-0">136                    <div className="label">WebSensor signal score</div>137                    <div className="text-[12px] text-fg-muted">{(ev.signal_score ?? ev.importance) >= 80 ? "attention now" : (ev.signal_score ?? ev.importance) >= 60 ? "worth a look" : "informational"}</div>138                  </div>139                </div>140                <div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 text-[12px]">141                  <Mini label="Importance" v={ev.importance} />142                  <Mini label="Confidence" v={ev.confidence} tone="info" />143                  <Mini label="Novelty" v={ev.novelty} />144                  <Mini label="Impact" v={ev.impact_score ?? 0} tone="high" />145                  <Mini label="Velocity" v={ev.velocity_score ?? 0} tone="silent" />146                  <Mini label="Anomaly" v={ev.anomaly_score ?? 0} tone="mid" />147                </div>148                {(ev.score_reasons?.length ?? 0) > 0 && (149                  <ul className="mt-3 space-y-0.5 text-[12px]">150                    {ev.score_reasons!.slice(0, 7).map((r, i) => (151                      <li key={i} className="flex gap-2">152                        <span className={`font-mono ${r.sign === "+" ? "text-signal" : "text-danger"}`}>{r.sign}</span>153                        <span className="text-fg-muted">{r.text}</span>154                        {r.points !== undefined && <span className="ml-auto font-mono text-fg-subtle tabular">{r.points > 0 ? "+" : ""}{r.points}</span>}155                      </li>156                    ))}157                  </ul>158                )}159                {ev.change_class && <div className="mt-2 text-[11px] text-fg-subtle">Semantic class: <span className="text-fg-muted">{CLASS_LABELS[ev.change_class] ?? ev.change_class}</span></div>}160              </section>161162              {/* CLUSTER / PROPAGATION */}163              {ev.cluster && ev.cluster.event_count > 1 && (164                <section className="mt-4 rounded-md border border-line bg-panel p-3">165                  <div className="flex items-center justify-between">166                    <div className="label">Event cluster</div>167                    <Link href={`/cluster/${ev.cluster.slug ?? ev.cluster.id}`} className="text-[11.5px] text-info hover:underline">propagation timeline →</Link>168                  </div>169                  <div className="mt-2 grid grid-cols-3 gap-2 text-center">170                    <Cell v={ev.cluster.event_count} l="signals" />171                    <Cell v={ev.cluster.first_party_count ?? 0} l="first-party" />172                    <Cell v={ev.cluster.external_count ?? 0} l="external" />173                  </div>174                  {ev.cluster.lead_time_ms !== null && ev.cluster.lead_time_ms !== undefined && ev.cluster.lead_time_ms > 0 && (175                    <p className="mt-2 text-[12px] text-fg-muted">176                      <span className="font-semibold text-signal">WebSensor lead time</span> {fmtOffset(ev.cluster.lead_time_ms).replace("+", "")} before the first external report.177                    </p>178                  )}179                </section>180              )}181182              {/* EVIDENCE */}183              <section className="mt-4">184                <div className="label mb-1.5">Evidence</div>185                <div className="rounded-md border border-line bg-panel p-3 text-[12.5px]">186                  <a href={ev.url} target="_blank" rel="noopener noreferrer nofollow" className="inline-flex max-w-full items-center gap-1 break-all font-mono text-[12px] text-info hover:underline">187                    {ev.url} <ExternalLink className="size-3 shrink-0" />188                  </a>189                  {detail ? (190                    <dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-[12px]">191                      {detail.snapshots.map((s) => (192                        <FragmentRow key={s.id} label={s.id === ev.old_snapshot_id ? "Before" : "After"} value={`${utcDateTime(s.captured_at)} · HTTP ${s.http_status ?? "—"} · ${s.canonical_hash?.slice(0, 12) ?? ""}`} />193                      ))}194                      <FragmentRow label="Observed from" value={ev.observed_from ? utcDateTime(ev.observed_from) : "—"} />195                      <FragmentRow label="Published" value={ev.published_at ? utcDateTime(ev.published_at) : "—"} />196                      <FragmentRow label="Processing" value={fmtMs(ev.processing_latency_ms)} />197                    </dl>198                  ) : (199                    <Skeleton className="mt-2 h-10 w-full" />200                  )}201                  <div className="mt-2 flex flex-wrap gap-2 text-[11.5px]">202                    <Link href={`/event/${ev.slug}#diff`} className="text-info hover:underline">Open diff</Link>203                    {ev.old_snapshot_id && ev.new_snapshot_id && <Link href={`/compare?a=${ev.old_snapshot_id}&b=${ev.new_snapshot_id}`} className="text-info hover:underline">Compare snapshots</Link>}204                    <Link href={`/sensor/${ev.sensor_id}`} className="text-info hover:underline">Sensor</Link>205                    <Link href={`/url?u=${encodeURIComponent(ev.url)}`} className="text-info hover:underline">URL history</Link>206                  </div>207                </div>208              </section>209210              {/* HISTORICAL CONTEXT */}211              {detail?.history && detail.history.length > 0 && (212                <section className="mt-4">213                  <div className="label mb-1.5">Previous changes on this page</div>214                  <ul className="divide-y divide-line rounded-md border border-line bg-panel">215                    {detail.history.map((h) => (216                      <li key={h.id} className="flex items-center gap-2 px-3 py-1.5 text-[12.5px]">217                        <span className="w-[5.5rem] shrink-0 font-mono text-[11px] text-fg-subtle tabular">{relTime(h.detected_at)}</span>218                        <Link href={`/event/${h.slug}`} className="min-w-0 flex-1 truncate hover:underline">{h.title}</Link>219                        <Score value={h.importance} size="sm" />220                      </li>221                    ))}222                  </ul>223                </section>224              )}225226              {/* RELATED */}227              {detail?.related && detail.related.length > 0 && (228                <section className="mt-4">229                  <div className="label mb-1.5">Related signals</div>230                  <ul className="divide-y divide-line rounded-md border border-line bg-panel">231                    {detail.related.slice(0, 6).map((r) => (232                      <li key={r.id} className="flex items-center gap-2 px-3 py-1.5 text-[12.5px]">233                        <span className="w-[5.5rem] shrink-0 truncate font-mono text-[10.5px] uppercase text-fg-subtle">{r.source?.name}</span>234                        <Link href={`/event/${r.slug}`} className="min-w-0 flex-1 truncate hover:underline">{r.title}</Link>235                        <Score value={r.signal_score ?? r.importance} size="sm" kind="signal" />236                      </li>237                    ))}238                  </ul>239                </section>240              )}241              {ev.entities?.length > 0 && (242                <div className="mt-4 flex flex-wrap gap-1">243                  {ev.entities.map((x) => (244                    <Chip key={x.id} href={`/entity/${x.id}`} tone={x.role === "subject" ? "signal" : "default"}>{x.name}</Chip>245                  ))}246                </div>247              )}248              <p className="mt-4 text-[11px] text-fg-subtle">{typeLabel(ev.event_type)} · {loading ? "loading details…" : "raw evidence is immutable; analysis is labelled and versioned."}</p>249            </>250          )}251        </div>252      </div>253    </div>254  );255}256257function Mini({ label, v, tone }: { label: string; v: number; tone?: "signal" | "hot" | "high" | "mid" | "silent" | "info" }) {258  return (259    <div>260      <div className="flex items-baseline justify-between">261        <span className="text-fg-subtle">{label}</span>262        <span className="font-mono tabular">{fmtScore(v)}</span>263      </div>264      <Bar value={v} tone={tone ?? (v >= 80 ? "hot" : v >= 60 ? "high" : "signal")} />265    </div>266  );267}268function Cell({ v, l }: { v: number; l: string }) {269  return (270    <div className="rounded border border-line bg-panel-2 py-1.5">271      <div className="font-mono text-base font-semibold tabular">{v}</div>272      <div className="label !text-[9.5px]">{l}</div>273    </div>274  );275}276function FragmentRow({ label, value }: { label: string; value: string }) {277  return (278    <>279      <dt className="text-fg-subtle">{label}</dt>280      <dd className="truncate font-mono text-fg-muted tabular">{value}</dd>281    </>282  );283}284function DrawerSkeleton() {285  return (286    <div className="flex flex-col gap-3">287      <Skeleton className="h-3 w-32" />288      <Skeleton className="h-5 w-11/12" />289      <Skeleton className="h-5 w-3/4" />290      <Skeleton className="h-16 w-full" />291      <Skeleton className="h-28 w-full" />292    </div>293  );294}295296export function BookmarkButton({ eventId, label = false }: { eventId: string; label?: boolean }) {297  const [state, setState] = useState<"idle" | "on" | "busy">("idle");298  useEffect(() => {299    let c = false;300    ownerFetch<{ ids: string[] }>("/api/v1/bookmarks/ids")301      .then((r) => {302        if (!c && r.ids.includes(eventId)) setState("on");303      })304      .catch(() => undefined);305    return () => {306      c = true;307    };308  }, [eventId]);309  const toggle = async (): Promise<void> => {310    const was = state;311    setState("busy");312    try {313      if (was === "on") {314        await ownerFetch(`/api/v1/bookmarks/${encodeURIComponent(eventId)}`, { method: "DELETE" });315        setState("idle");316      } else {317        await ownerFetch("/api/v1/bookmarks", { method: "POST", body: JSON.stringify({ event_id: eventId }) });318        setState("on");319      }320    } catch {321      setState(was);322    }323  };324  return (325    <button type="button" onClick={toggle} disabled={state === "busy"} aria-pressed={state === "on"} title={state === "on" ? "Remove bookmark" : "Bookmark"} className={`inline-flex h-7 items-center gap-1 rounded-md border border-line px-1.5 text-[12px] ${state === "on" ? "text-signal" : "text-fg-muted hover:text-fg"}`}>326      {state === "on" ? <BookmarkCheck className="size-3.5" /> : <Bookmark className="size-3.5" />}327      {label && (state === "on" ? "Saved" : "Save")}328    </button>329  );330}331