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%
27.7 KB · 478 lines tsx
Raw Blame History
1"use client";23import { Bell, BellOff, BellRing, Check, Trash2, Webhook } from "lucide-react";4import Link from "next/link";5import { useSearchParams } from "next/navigation";6import { useEffect, useMemo, useState } from "react";7import { COUNTRIES, EVENT_TYPES, eventGroupOf } from "@websensor/core/client";8import { LiveDot } from "@/components/live-feed";9import { useMounted } from "@/components/theme";10import { Chip, Empty, Flag, Panel, Score, SkeletonRows } from "@/components/ui";11import type { Alert, AlertRule, LiveEvent, Notification } from "@/lib/api";12import { CHANNEL_KEYS, GROUP_LABELS, relTime, typeLabel, utcDateTime } from "@/lib/format";13import { ownerFetch } from "@/lib/owner";14import { useLive } from "@/lib/use-live";1516function matches(rule: AlertRule, e: LiveEvent): boolean {17  if (rule.importance_min !== undefined && e.importance < rule.importance_min) return false;18  if (rule.signal_min !== undefined && (e.signal ?? e.importance) < rule.signal_min) return false;19  if (rule.silent_only && !e.silent) return false;20  if (rule.first_party_only && e.firstParty === false) return false;21  if (rule.confirmed_only && e.evidence !== "CONFIRMED" && e.clusterState !== "confirmed") return false;22  if (rule.event_types?.length && !rule.event_types.includes(e.type)) return false;23  if (rule.groups?.length && !rule.groups.includes(e.group ?? eventGroupOf(e.type))) return false;24  if (rule.categories?.length && !rule.categories.some((c) => e.categories.includes(c))) return false;25  if (rule.countries?.length && !(e.country && rule.countries.includes(e.country.toUpperCase()))) return false;26  if (rule.entities?.length && !e.entities.some((x) => rule.entities!.includes(x.id))) return false;27  if (rule.sources?.length && !rule.sources.includes(e.source?.id)) return false;28  if (rule.keywords?.length) {29    const hay = `${e.title} ${e.summary}`.toLowerCase();30    if (!rule.keywords.some((k) => hay.includes(k.toLowerCase()))) return false;31  }32  return true;33}3435interface Form {36  name: string;37  importance_min: number;38  signal_min: number;39  silent_only: boolean;40  first_party_only: boolean;41  confirmed_only: boolean;42  event_types: string[];43  groups: string[];44  categories: string[];45  countries: string[];46  entities: string;47  sources: string;48  keywords: string;49  channel: "web" | "webhook";50  webhook_url: string;51  webhook_secret: string;52}5354const EMPTY: Form = { name: "", importance_min: 0, signal_min: 60, silent_only: false, first_party_only: false, confirmed_only: false, event_types: [], groups: [], categories: [], countries: [], entities: "", sources: "", keywords: "", channel: "web", webhook_url: "", webhook_secret: "" };5556const PRESETS: { label: string; form: Partial<Form> }[] = [57  { label: "OpenAI pricing", form: { name: "OpenAI pricing", entities: "org_openai", event_types: ["pricing_change"], signal_min: 0 } },58  { label: "Anthropic model release", form: { name: "Anthropic model release", entities: "org_anthropic", event_types: ["model_release"], signal_min: 0 } },59  { label: "Cloudflare outage", form: { name: "Cloudflare outage", entities: "org_cloudflare", groups: ["reliability"], signal_min: 0 } },60  { label: "Critical CISA/KEV", form: { name: "Critical CISA / KEV", sources: "cisa", groups: ["security"], signal_min: 70, first_party_only: true } },61  { label: "Tesla pricing", form: { name: "Tesla pricing", entities: "org_tesla", event_types: ["pricing_change"], signal_min: 0 } },62  { label: "NVIDIA ≥ 80", form: { name: "NVIDIA ≥ 80", entities: "org_nvidia", signal_min: 80 } },63];6465const COUNTRY_CODES = Object.keys(COUNTRIES);6667function toggle<T>(list: T[], v: T): T[] {68  return list.includes(v) ? list.filter((x) => x !== v) : [...list, v];69}7071function ChipToggle({ on, onClick, children, tone = "signal", title }: { on: boolean; onClick: () => void; children: React.ReactNode; tone?: "signal" | "silent" | "info" | "ok"; title?: string }) {72  const onCls = tone === "silent" ? "border-silent/50 bg-silent-soft text-silent" : tone === "info" ? "border-info/50 bg-info/10 text-info" : tone === "ok" ? "border-ok/50 bg-ok/10 text-ok" : "border-signal/50 bg-signal-soft text-signal";73  return (74    <button type="button" aria-pressed={on} title={title} onClick={onClick} className={`inline-flex items-center gap-1 rounded-sm border px-1.5 py-px text-[10.5px] leading-4 ${on ? onCls : "border-line text-fg-muted hover:text-fg"}`}>75      {children}76    </button>77  );78}7980function ruleChips(rule: AlertRule) {81  const out: React.ReactNode[] = [];82  if (rule.signal_min) out.push(<Chip key="sig" tone="signal">signal ≥ {rule.signal_min}</Chip>);83  if (rule.importance_min) out.push(<Chip key="imp">importance ≥ {rule.importance_min}</Chip>);84  if (rule.silent_only) out.push(<Chip key="silent" tone="silent">silent only</Chip>);85  if (rule.first_party_only) out.push(<Chip key="fp" tone="signal">first-party</Chip>);86  if (rule.confirmed_only) out.push(<Chip key="cf" tone="ok">confirmed</Chip>);87  rule.groups?.forEach((g) => out.push(<Chip key={`g-${g}`} tone="info">{GROUP_LABELS[g] ?? g}</Chip>));88  rule.event_types?.forEach((t) => out.push(<Chip key={`t-${t}`}>{typeLabel(t)}</Chip>));89  rule.categories?.forEach((c) => out.push(<Chip key={`c-${c}`}>{c}</Chip>));90  rule.countries?.forEach((c) => out.push(<Chip key={`co-${c}`}><Flag code={c} /> {c}</Chip>));91  rule.entities?.forEach((t) => out.push(<Chip key={`e-${t}`} tone="signal" href={`/entity/${t}`}>{t.replace(/^(org|prd|ent)_/, "")}</Chip>));92  rule.sources?.forEach((t) => out.push(<Chip key={`s-${t}`} tone="info" href={`/source/${t}`}>{t}</Chip>));93  rule.keywords?.forEach((t) => out.push(<Chip key={`k-${t}`}>“{t}”</Chip>));94  if (!out.length) out.push(<Chip key="all">every event</Chip>);95  return out;96}9798export function Alerts() {99  const sp = useSearchParams();100  const prefillEntity = sp.get("entity") ?? "";101  const [alerts, setAlerts] = useState<Alert[] | null>(null);102  const [notifs, setNotifs] = useState<{ items: Notification[]; unread: number } | null>(null);103  const [fired, setFired] = useState<{ alert: Alert; event: LiveEvent; at: number }[]>([]);104  const mounted = useMounted();105  const [permOverride, setPerm] = useState<NotificationPermission | null>(null);106  const perm: NotificationPermission | "unsupported" = !mounted ? "default" : permOverride ?? (typeof Notification === "undefined" ? "unsupported" : Notification.permission);107  const [form, setForm] = useState<Form>({ ...EMPTY, entities: prefillEntity, name: prefillEntity ? `${prefillEntity.replace(/^(org|prd|ent)_/, "")} alerts` : "" });108  const [err, setErr] = useState<string | null>(null);109  const [busy, setBusy] = useState(false);110  const [created, setCreated] = useState<string | null>(null);111  const [tick, setTick] = useState(0);112  const reload = (): void => setTick((t) => t + 1);113114  useEffect(() => {115    let cancelled = false;116    ownerFetch<{ items: Alert[] }>("/api/v1/alerts")117      .then((r) => {118        if (!cancelled) setAlerts(r.items);119      })120      .catch((e: Error) => {121        if (cancelled) return;122        setErr(e.message);123        setAlerts([]);124      });125    const pull = (): void => {126      ownerFetch<{ items: Notification[]; unread: number }>("/api/v1/notifications?limit=50")127        .then((r) => {128          if (!cancelled) setNotifs(r);129        })130        .catch(() => {131          if (!cancelled) setNotifs({ items: [], unread: 0 });132        });133    };134    pull();135    const t = setInterval(pull, 30_000);136    return () => {137      cancelled = true;138      clearInterval(t);139    };140  }, [tick]);141142  const status = useLive(["events:global"], (e) => {143    for (const a of alerts ?? []) {144      if (!a.enabled || !matches(a.rule, e)) continue;145      setFired((prev) => [{ alert: a, event: e, at: Date.now() }, ...prev].slice(0, 50));146      if (typeof Notification !== "undefined" && Notification.permission === "granted") {147        try {148          const n = new Notification(`${e.source?.name ?? "WebSensor"} · ${Math.round(e.signal ?? e.importance)}`, { body: e.title, tag: e.id, icon: "/icon.svg" });149          n.onclick = () => window.open(`/event/${e.slug}`, "_blank");150        } catch {151          // notifications unavailable152        }153      }154    }155  });156157  const split = (s: string): string[] | undefined => {158    const arr = s.split(/[,\n]/).map((x) => x.trim()).filter(Boolean);159    return arr.length ? arr : undefined;160  };161162  const create = async (): Promise<void> => {163    setErr(null);164    setCreated(null);165    const rule: AlertRule = {166      importance_min: form.importance_min > 0 ? form.importance_min : undefined,167      signal_min: form.signal_min > 0 ? form.signal_min : undefined,168      event_types: form.event_types.length ? form.event_types : undefined,169      groups: form.groups.length ? form.groups : undefined,170      categories: form.categories.length ? form.categories : undefined,171      countries: form.countries.length ? form.countries : undefined,172      entities: split(form.entities),173      sources: split(form.sources),174      keywords: split(form.keywords),175      silent_only: form.silent_only || undefined,176      first_party_only: form.first_party_only || undefined,177      confirmed_only: form.confirmed_only || undefined,178    };179    if (form.channel === "webhook" && !/^https:\/\//i.test(form.webhook_url.trim())) {180      setErr("Webhook URL must start with https:// and be publicly reachable.");181      return;182    }183    setBusy(true);184    try {185      const body = { name: form.name.trim() || "Alert", rule, channel: form.channel, channel_config: form.channel === "webhook" ? { url: form.webhook_url.trim(), ...(form.webhook_secret.trim() ? { secret: form.webhook_secret.trim() } : {}) } : {} };186      const r = await ownerFetch<{ id: string; name: string }>("/api/v1/alerts", { method: "POST", body: JSON.stringify(body) });187      setForm(EMPTY);188      setCreated(r.name);189      reload();190    } catch (e) {191      setErr((e as Error).message);192    } finally {193      setBusy(false);194    }195  };196197  const setEnabled = async (a: Alert, enabled: boolean): Promise<void> => {198    setAlerts((prev) => prev?.map((x) => (x.id === a.id ? { ...x, enabled } : x)) ?? prev);199    try {200      await ownerFetch(`/api/v1/alerts/${a.id}`, { method: "PATCH", body: JSON.stringify({ enabled }) });201    } catch (e) {202      setErr((e as Error).message);203      reload();204    }205  };206  const remove = async (a: Alert): Promise<void> => {207    setAlerts((prev) => prev?.filter((x) => x.id !== a.id) ?? prev);208    try {209      await ownerFetch(`/api/v1/alerts/${a.id}`, { method: "DELETE" });210    } catch (e) {211      setErr((e as Error).message);212      reload();213    }214  };215  const markAllRead = async (): Promise<void> => {216    try {217      await ownerFetch("/api/v1/notifications/read", { method: "POST", body: JSON.stringify({}) });218      reload();219    } catch (e) {220      setErr((e as Error).message);221    }222  };223224  const applyPreset = (p: Partial<Form>): void => setForm({ ...EMPTY, ...p });225  const activeFilters = useMemo(() => [form.signal_min > 0, form.importance_min > 0, form.silent_only, form.first_party_only, form.confirmed_only, form.event_types.length > 0, form.groups.length > 0, form.categories.length > 0, form.countries.length > 0, Boolean(form.entities.trim()), Boolean(form.sources.trim()), Boolean(form.keywords.trim())].filter(Boolean).length, [form]);226227  return (228    <div className="grid gap-4 lg:grid-cols-[400px_minmax(0,1fr)]">229      <aside className="flex min-w-0 flex-col gap-4">230        <Panel title={<span>New rule <span className="normal-case tracking-normal text-fg-subtle">· {activeFilters} condition{activeFilters === 1 ? "" : "s"}</span></span>}>231          <form232            className="flex flex-col gap-3 text-[12.5px]"233            onSubmit={(e) => {234              e.preventDefault();235              void create();236            }}237          >238            <div>239              <div className="label mb-1">Presets</div>240              <div className="flex flex-wrap gap-1">241                {PRESETS.map((p) => (242                  <button key={p.label} type="button" onClick={() => applyPreset(p.form)} className="rounded-sm border border-line px-1.5 py-px text-[10.5px] leading-4 text-fg-muted hover:border-line-strong hover:text-fg">243                    {p.label}244                  </button>245                ))}246              </div>247            </div>248249            <label className="flex flex-col gap-1">250              <span className="label">Name</span>251              <input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="Rule name" maxLength={80} className="h-8 rounded-md border border-line bg-panel px-2 text-[12.5px]" />252            </label>253254            <div className="grid grid-cols-2 gap-3">255              <label className="flex flex-col gap-1">256                <span className="flex items-center justify-between"><span className="label">Signal ≥</span><span className="font-mono text-[12px] tabular">{form.signal_min || "any"}</span></span>257                <input type="range" min={0} max={100} step={5} value={form.signal_min} onChange={(e) => setForm({ ...form, signal_min: Number(e.target.value) })} className="w-full accent-[var(--signal)]" aria-label="Minimum signal score" />258              </label>259              <label className="flex flex-col gap-1">260                <span className="flex items-center justify-between"><span className="label">Importance ≥</span><span className="font-mono text-[12px] tabular">{form.importance_min || "any"}</span></span>261                <input type="range" min={0} max={100} step={5} value={form.importance_min} onChange={(e) => setForm({ ...form, importance_min: Number(e.target.value) })} className="w-full accent-[var(--signal)]" aria-label="Minimum importance" />262              </label>263            </div>264265            <div className="flex flex-wrap gap-x-4 gap-y-1">266              <label className="flex items-center gap-1.5">267                <input type="checkbox" checked={form.silent_only} onChange={(e) => setForm({ ...form, silent_only: e.target.checked })} className="accent-[var(--silent)]" />268                <span className="text-silent">Silent only</span>269              </label>270              <label className="flex items-center gap-1.5">271                <input type="checkbox" checked={form.first_party_only} onChange={(e) => setForm({ ...form, first_party_only: e.target.checked })} className="accent-[var(--signal)]" />272                <span>First-party only</span>273              </label>274              <label className="flex items-center gap-1.5">275                <input type="checkbox" checked={form.confirmed_only} onChange={(e) => setForm({ ...form, confirmed_only: e.target.checked })} className="accent-[var(--ok)]" />276                <span>Confirmed only</span>277              </label>278            </div>279280            <div>281              <div className="label mb-1">Groups</div>282              <div className="flex flex-wrap gap-1">283                {Object.entries(GROUP_LABELS).map(([k, v]) => (284                  <ChipToggle key={k} on={form.groups.includes(k)} tone="info" onClick={() => setForm({ ...form, groups: toggle(form.groups, k) })}>{v}</ChipToggle>285                ))}286              </div>287            </div>288289            <div>290              <div className="mb-1 flex items-center justify-between">291                <span className="label">Event types</span>292                {form.event_types.length > 0 && <button type="button" onClick={() => setForm({ ...form, event_types: [] })} className="text-[10.5px] text-fg-subtle hover:text-fg">clear {form.event_types.length}</button>}293              </div>294              <div className="flex max-h-28 flex-wrap gap-1 overflow-auto rounded-md border border-line p-1.5">295                {Object.entries(EVENT_TYPES).map(([k, v]) => (296                  <ChipToggle key={k} on={form.event_types.includes(k)} onClick={() => setForm({ ...form, event_types: toggle(form.event_types, k) })} title={`${k} · severity ${v.severity}`}>{v.label}</ChipToggle>297                ))}298              </div>299            </div>300301            <div>302              <div className="label mb-1">Categories</div>303              <div className="flex flex-wrap gap-1">304                {CHANNEL_KEYS.map((c) => (305                  <ChipToggle key={c} on={form.categories.includes(c)} tone="ok" onClick={() => setForm({ ...form, categories: toggle(form.categories, c) })}>{c}</ChipToggle>306                ))}307              </div>308            </div>309310            <div>311              <div className="mb-1 flex items-center justify-between">312                <span className="label">Countries</span>313                {form.countries.length > 0 && <button type="button" onClick={() => setForm({ ...form, countries: [] })} className="text-[10.5px] text-fg-subtle hover:text-fg">clear {form.countries.length}</button>}314              </div>315              <div className="flex max-h-20 flex-wrap gap-1 overflow-auto rounded-md border border-line p-1.5">316                {COUNTRY_CODES.map((c) => (317                  <ChipToggle key={c} on={form.countries.includes(c)} onClick={() => setForm({ ...form, countries: toggle(form.countries, c) })} title={COUNTRIES[c]?.name}>318                    <span className="font-mono">{c}</span>319                  </ChipToggle>320                ))}321              </div>322            </div>323324            <label className="flex flex-col gap-1">325              <span className="label">Entities <span className="normal-case tracking-normal">· ids, comma-separated</span></span>326              <input value={form.entities} onChange={(e) => setForm({ ...form, entities: e.target.value })} placeholder="org_openai, prd_openai_openai-api" className="h-8 rounded-md border border-line bg-panel px-2 font-mono text-[12px]" />327            </label>328            <label className="flex flex-col gap-1">329              <span className="label">Sources <span className="normal-case tracking-normal">· ids</span></span>330              <input value={form.sources} onChange={(e) => setForm({ ...form, sources: e.target.value })} placeholder="openai, cisa" className="h-8 rounded-md border border-line bg-panel px-2 font-mono text-[12px]" />331            </label>332            <label className="flex flex-col gap-1">333              <span className="label">Keywords <span className="normal-case tracking-normal">· in title or summary</span></span>334              <input value={form.keywords} onChange={(e) => setForm({ ...form, keywords: e.target.value })} placeholder="pricing, CVE, outage" className="h-8 rounded-md border border-line bg-panel px-2 text-[12.5px]" />335            </label>336337            <fieldset className="rounded-md border border-line p-2">338              <legend className="label px-1">Delivery channel</legend>339              <div className="flex flex-wrap gap-x-4 gap-y-1">340                <label className="flex items-center gap-1.5">341                  <input type="radio" name="channel" checked={form.channel === "web"} onChange={() => setForm({ ...form, channel: "web" })} className="accent-[var(--signal)]" />342                  <Bell className="size-3.5 text-fg-muted" /> Web (this browser)343                </label>344                <label className="flex items-center gap-1.5">345                  <input type="radio" name="channel" checked={form.channel === "webhook"} onChange={() => setForm({ ...form, channel: "webhook" })} className="accent-[var(--signal)]" />346                  <Webhook className="size-3.5 text-fg-muted" /> Webhook347                </label>348              </div>349              {form.channel === "webhook" && (350                <div className="mt-2 flex flex-col gap-1.5">351                  <input type="url" value={form.webhook_url} onChange={(e) => setForm({ ...form, webhook_url: e.target.value })} placeholder="https://example.com/hooks/websensor" required className="h-8 rounded-md border border-line bg-panel px-2 font-mono text-[12px]" aria-label="Webhook URL" />352                  <input value={form.webhook_secret} onChange={(e) => setForm({ ...form, webhook_secret: e.target.value })} placeholder="Shared secret (optional)" className="h-8 rounded-md border border-line bg-panel px-2 font-mono text-[12px]" aria-label="Webhook secret" autoComplete="off" />353                  <p className="text-[11px] leading-relaxed text-fg-subtle">354                    POST JSON per matching event. With a secret, each request carries <code className="text-fg-muted">X-WebSensor-Signature: sha256=…</code> — an HMAC-SHA256 of the raw body you can verify. HTTPS only; private, loopback and cloud-metadata addresses are rejected.355                  </p>356                </div>357              )}358            </fieldset>359360            <button type="submit" disabled={busy} 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">361              <Bell className="size-3.5" /> {busy ? "Creating…" : "Create rule"}362            </button>363            {err && <p className="text-[11px] text-danger">{err}</p>}364            {created && <p className="flex items-center gap-1 text-[11px] text-signal"><Check className="size-3" /> Rule “{created}” created.</p>}365          </form>366        </Panel>367368        <Panel title="Delivery status">369          <div className="flex flex-col gap-2 text-[12.5px]">370            <div className="flex items-center justify-between">371              <span>Web (this browser)</span>372              <Chip tone="ok">active</Chip>373            </div>374            <div className="flex items-center justify-between">375              <span>Webhook (HTTPS, signed)</span>376              <Chip tone="ok">active</Chip>377            </div>378            <div className="flex items-center justify-between">379              <span>Browser notifications</span>380              {perm === "granted" ? <Chip tone="ok">granted</Chip> : perm === "unsupported" ? <Chip>unsupported</Chip> : perm === "denied" ? <Chip tone="danger">denied</Chip> : <button type="button" onClick={() => Notification.requestPermission().then(setPerm)} className="h-7 rounded-md border border-line bg-panel-2 px-2 text-[12px] hover:border-line-strong">Enable</button>}381            </div>382            {["Email", "Push", "Slack", "Discord", "Telegram"].map((c) => (383              <div key={c} className="flex items-center justify-between text-fg-muted">384                <span>{c}</span>385                <Chip>planned</Chip>386              </div>387            ))}388          </div>389        </Panel>390      </aside>391392      <div className="flex min-w-0 flex-col gap-4">393        <Panel title={<span>Rules <span className="font-mono text-fg-subtle">{alerts?.length ?? "…"}</span></span>} dense>394          {alerts === null ? (395            <SkeletonRows rows={3} />396          ) : alerts.length ? (397            <ul className="divide-y divide-line">398              {alerts.map((a) => (399                <li key={a.id} className={`grid grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-3 px-3 py-2 text-[13px] ${a.enabled ? "" : "opacity-60"}`}>400                  <button type="button" role="switch" aria-checked={a.enabled} aria-label={a.enabled ? "Disable rule" : "Enable rule"} title={a.enabled ? "Disable" : "Enable"} onClick={() => setEnabled(a, !a.enabled)} className={`mt-0.5 inline-flex size-6 items-center justify-center rounded-md border ${a.enabled ? "border-signal/40 bg-signal-soft text-signal" : "border-line text-fg-subtle"}`}>401                    {a.enabled ? <BellRing className="size-3.5" /> : <BellOff className="size-3.5" />}402                  </button>403                  <div className="min-w-0">404                    <div className="flex flex-wrap items-center gap-2">405                      <span className="font-medium">{a.name}</span>406                      <Chip tone={a.channel === "webhook" ? "info" : "default"} className="font-mono">{a.channel === "webhook" ? <><Webhook className="size-3" /> webhook</> : "web"}</Chip>407                      {!a.enabled && <Chip>paused</Chip>}408                    </div>409                    <div className="mt-1 flex flex-wrap gap-1">{ruleChips(a.rule)}</div>410                    <div className="mt-1 flex flex-wrap gap-x-3 font-mono text-[11px] text-fg-subtle tabular">411                      <span>fired 24 h <span className="text-fg-muted">{a.fired_24h ?? 0}</span></span>412                      <span>total <span className="text-fg-muted">{a.fired_count ?? 0}</span></span>413                      <span>last {a.last_fired_at ? relTime(a.last_fired_at) : "never"}</span>414                      {a.channel === "webhook" && a.channel_config?.url && <span className="truncate">{a.channel_config.url}</span>}415                    </div>416                  </div>417                  <button type="button" aria-label={`Delete rule ${a.name}`} onClick={() => remove(a)} className="mt-0.5 text-fg-subtle hover:text-danger"><Trash2 className="size-3.5" /></button>418                </li>419              ))}420            </ul>421          ) : (422            <Empty>No rules yet — pick a preset or define conditions on the left.</Empty>423          )}424        </Panel>425426        <Panel427          title={<span>Notifications {notifs && notifs.unread > 0 && <span className="ml-1 rounded-sm border border-signal/40 bg-signal-soft px-1 font-mono text-[10px] text-signal">{notifs.unread} unread</span>}</span>}428          action={notifs && notifs.unread > 0 ? <button type="button" onClick={markAllRead} className="text-[11px] text-fg-subtle hover:text-fg">mark all read</button> : undefined}429          dense430        >431          {notifs === null ? (432            <SkeletonRows rows={3} />433          ) : notifs.items.length ? (434            <ul className="divide-y divide-line">435              {notifs.items.map((n) => (436                <li key={n.id} className={`grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 px-3 py-1.5 text-[12.5px] ${n.read_at ? "" : "bg-panel-2/60"}`}>437                  <Score value={n.event.signal_score ?? n.event.importance} size="sm" kind="signal" />438                  <div className="min-w-0">439                    <Link href={`/event/${n.event.slug}`} className="block truncate font-medium hover:underline">{n.event.title}</Link>440                    <div className="flex flex-wrap gap-x-2 text-[11px] text-fg-subtle">441                      <span>rule “{n.alert_name}”</span>442                      <span>· {n.event.source.name}</span>443                      <span>· <span className="font-mono">{n.channel}</span></span>444                      <span className={n.status === "failed" || n.status === "error" ? "text-danger" : n.status === "delivered" || n.status === "sent" ? "text-ok" : ""}>· {n.status}</span>445                      {!n.read_at && <span className="text-signal">· new</span>}446                    </div>447                  </div>448                  <time dateTime={n.created_at} title={utcDateTime(n.created_at)} className="whitespace-nowrap font-mono text-[11px] text-fg-subtle tabular">{relTime(n.created_at)}</time>449                </li>450              ))}451            </ul>452          ) : (453            <Empty>No notifications yet. Rules are evaluated by the engine on every new event; deliveries are logged here.</Empty>454          )}455        </Panel>456457        <Panel title={<span className="flex items-center gap-3">Fired in this session <LiveDot status={status} /></span>} dense>458          {fired.length ? (459            <ul className="divide-y divide-line">460              {fired.map((f, i) => (461                <li key={`${f.event.id}-${i}`} className="flex items-center gap-3 px-3 py-2 text-[13px] animate-fade-in">462                  <Score value={f.event.signal ?? f.event.importance} size="sm" kind="signal" />463                  <div className="min-w-0 flex-1">464                    <Link href={`/event/${f.event.slug}`} className="block truncate font-medium hover:underline">{f.event.title}</Link>465                    <div className="text-[11px] text-fg-subtle">rule “{f.alert.name}” · {f.event.source?.name} · {relTime(new Date(f.at))}</div>466                  </div>467                </li>468              ))}469            </ul>470          ) : (471            <Empty>Matching events will appear here while this page is open.</Empty>472          )}473        </Panel>474      </div>475    </div>476  );477}478