TypeScript 55.4%
Python 43.2%
SQL 1.2%
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