"use client"; import { Bell, BellOff, BellRing, Check, Trash2, Webhook } from "lucide-react"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; import { COUNTRIES, EVENT_TYPES, eventGroupOf } from "@websensor/core/client"; import { LiveDot } from "@/components/live-feed"; import { useMounted } from "@/components/theme"; import { Chip, Empty, Flag, Panel, Score, SkeletonRows } from "@/components/ui"; import type { Alert, AlertRule, LiveEvent, Notification } from "@/lib/api"; import { CHANNEL_KEYS, GROUP_LABELS, relTime, typeLabel, utcDateTime } from "@/lib/format"; import { ownerFetch } from "@/lib/owner"; import { useLive } from "@/lib/use-live"; function matches(rule: AlertRule, e: LiveEvent): boolean { if (rule.importance_min !== undefined && e.importance < rule.importance_min) return false; if (rule.signal_min !== undefined && (e.signal ?? e.importance) < rule.signal_min) return false; if (rule.silent_only && !e.silent) return false; if (rule.first_party_only && e.firstParty === false) return false; if (rule.confirmed_only && e.evidence !== "CONFIRMED" && e.clusterState !== "confirmed") return false; if (rule.event_types?.length && !rule.event_types.includes(e.type)) return false; if (rule.groups?.length && !rule.groups.includes(e.group ?? eventGroupOf(e.type))) return false; if (rule.categories?.length && !rule.categories.some((c) => e.categories.includes(c))) return false; if (rule.countries?.length && !(e.country && rule.countries.includes(e.country.toUpperCase()))) return false; if (rule.entities?.length && !e.entities.some((x) => rule.entities!.includes(x.id))) return false; if (rule.sources?.length && !rule.sources.includes(e.source?.id)) return false; if (rule.keywords?.length) { const hay = `${e.title} ${e.summary}`.toLowerCase(); if (!rule.keywords.some((k) => hay.includes(k.toLowerCase()))) return false; } return true; } interface Form { name: string; importance_min: number; signal_min: number; silent_only: boolean; first_party_only: boolean; confirmed_only: boolean; event_types: string[]; groups: string[]; categories: string[]; countries: string[]; entities: string; sources: string; keywords: string; channel: "web" | "webhook"; webhook_url: string; webhook_secret: string; } const 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: "" }; const PRESETS: { label: string; form: Partial
}[] = [ { label: "OpenAI pricing", form: { name: "OpenAI pricing", entities: "org_openai", event_types: ["pricing_change"], signal_min: 0 } }, { label: "Anthropic model release", form: { name: "Anthropic model release", entities: "org_anthropic", event_types: ["model_release"], signal_min: 0 } }, { label: "Cloudflare outage", form: { name: "Cloudflare outage", entities: "org_cloudflare", groups: ["reliability"], signal_min: 0 } }, { label: "Critical CISA/KEV", form: { name: "Critical CISA / KEV", sources: "cisa", groups: ["security"], signal_min: 70, first_party_only: true } }, { label: "Tesla pricing", form: { name: "Tesla pricing", entities: "org_tesla", event_types: ["pricing_change"], signal_min: 0 } }, { label: "NVIDIA ≥ 80", form: { name: "NVIDIA ≥ 80", entities: "org_nvidia", signal_min: 80 } }, ]; const COUNTRY_CODES = Object.keys(COUNTRIES); function toggle(list: T[], v: T): T[] { return list.includes(v) ? list.filter((x) => x !== v) : [...list, v]; } function ChipToggle({ on, onClick, children, tone = "signal", title }: { on: boolean; onClick: () => void; children: React.ReactNode; tone?: "signal" | "silent" | "info" | "ok"; title?: string }) { 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"; return ( ); } function ruleChips(rule: AlertRule) { const out: React.ReactNode[] = []; if (rule.signal_min) out.push(signal ≥ {rule.signal_min}); if (rule.importance_min) out.push(importance ≥ {rule.importance_min}); if (rule.silent_only) out.push(silent only); if (rule.first_party_only) out.push(first-party); if (rule.confirmed_only) out.push(confirmed); rule.groups?.forEach((g) => out.push({GROUP_LABELS[g] ?? g})); rule.event_types?.forEach((t) => out.push({typeLabel(t)})); rule.categories?.forEach((c) => out.push({c})); rule.countries?.forEach((c) => out.push( {c})); rule.entities?.forEach((t) => out.push({t.replace(/^(org|prd|ent)_/, "")})); rule.sources?.forEach((t) => out.push({t})); rule.keywords?.forEach((t) => out.push(“{t}”)); if (!out.length) out.push(every event); return out; } export function Alerts() { const sp = useSearchParams(); const prefillEntity = sp.get("entity") ?? ""; const [alerts, setAlerts] = useState(null); const [notifs, setNotifs] = useState<{ items: Notification[]; unread: number } | null>(null); const [fired, setFired] = useState<{ alert: Alert; event: LiveEvent; at: number }[]>([]); const mounted = useMounted(); const [permOverride, setPerm] = useState(null); const perm: NotificationPermission | "unsupported" = !mounted ? "default" : permOverride ?? (typeof Notification === "undefined" ? "unsupported" : Notification.permission); const [form, setForm] = useState({ ...EMPTY, entities: prefillEntity, name: prefillEntity ? `${prefillEntity.replace(/^(org|prd|ent)_/, "")} alerts` : "" }); const [err, setErr] = useState(null); const [busy, setBusy] = useState(false); const [created, setCreated] = useState(null); const [tick, setTick] = useState(0); const reload = (): void => setTick((t) => t + 1); useEffect(() => { let cancelled = false; ownerFetch<{ items: Alert[] }>("/api/v1/alerts") .then((r) => { if (!cancelled) setAlerts(r.items); }) .catch((e: Error) => { if (cancelled) return; setErr(e.message); setAlerts([]); }); const pull = (): void => { ownerFetch<{ items: Notification[]; unread: number }>("/api/v1/notifications?limit=50") .then((r) => { if (!cancelled) setNotifs(r); }) .catch(() => { if (!cancelled) setNotifs({ items: [], unread: 0 }); }); }; pull(); const t = setInterval(pull, 30_000); return () => { cancelled = true; clearInterval(t); }; }, [tick]); const status = useLive(["events:global"], (e) => { for (const a of alerts ?? []) { if (!a.enabled || !matches(a.rule, e)) continue; setFired((prev) => [{ alert: a, event: e, at: Date.now() }, ...prev].slice(0, 50)); if (typeof Notification !== "undefined" && Notification.permission === "granted") { try { const n = new Notification(`${e.source?.name ?? "WebSensor"} · ${Math.round(e.signal ?? e.importance)}`, { body: e.title, tag: e.id, icon: "/icon.svg" }); n.onclick = () => window.open(`/event/${e.slug}`, "_blank"); } catch { // notifications unavailable } } } }); const split = (s: string): string[] | undefined => { const arr = s.split(/[,\n]/).map((x) => x.trim()).filter(Boolean); return arr.length ? arr : undefined; }; const create = async (): Promise => { setErr(null); setCreated(null); const rule: AlertRule = { importance_min: form.importance_min > 0 ? form.importance_min : undefined, signal_min: form.signal_min > 0 ? form.signal_min : undefined, event_types: form.event_types.length ? form.event_types : undefined, groups: form.groups.length ? form.groups : undefined, categories: form.categories.length ? form.categories : undefined, countries: form.countries.length ? form.countries : undefined, entities: split(form.entities), sources: split(form.sources), keywords: split(form.keywords), silent_only: form.silent_only || undefined, first_party_only: form.first_party_only || undefined, confirmed_only: form.confirmed_only || undefined, }; if (form.channel === "webhook" && !/^https:\/\//i.test(form.webhook_url.trim())) { setErr("Webhook URL must start with https:// and be publicly reachable."); return; } setBusy(true); try { 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() } : {}) } : {} }; const r = await ownerFetch<{ id: string; name: string }>("/api/v1/alerts", { method: "POST", body: JSON.stringify(body) }); setForm(EMPTY); setCreated(r.name); reload(); } catch (e) { setErr((e as Error).message); } finally { setBusy(false); } }; const setEnabled = async (a: Alert, enabled: boolean): Promise => { setAlerts((prev) => prev?.map((x) => (x.id === a.id ? { ...x, enabled } : x)) ?? prev); try { await ownerFetch(`/api/v1/alerts/${a.id}`, { method: "PATCH", body: JSON.stringify({ enabled }) }); } catch (e) { setErr((e as Error).message); reload(); } }; const remove = async (a: Alert): Promise => { setAlerts((prev) => prev?.filter((x) => x.id !== a.id) ?? prev); try { await ownerFetch(`/api/v1/alerts/${a.id}`, { method: "DELETE" }); } catch (e) { setErr((e as Error).message); reload(); } }; const markAllRead = async (): Promise => { try { await ownerFetch("/api/v1/notifications/read", { method: "POST", body: JSON.stringify({}) }); reload(); } catch (e) { setErr((e as Error).message); } }; const applyPreset = (p: Partial): void => setForm({ ...EMPTY, ...p }); 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]); return (
Rules {alerts?.length ?? "…"}} dense> {alerts === null ? ( ) : alerts.length ? (
    {alerts.map((a) => (
  • {a.name} {a.channel === "webhook" ? <> webhook : "web"} {!a.enabled && paused}
    {ruleChips(a.rule)}
    fired 24 h {a.fired_24h ?? 0} total {a.fired_count ?? 0} last {a.last_fired_at ? relTime(a.last_fired_at) : "never"} {a.channel === "webhook" && a.channel_config?.url && {a.channel_config.url}}
  • ))}
) : ( No rules yet — pick a preset or define conditions on the left. )}
Notifications {notifs && notifs.unread > 0 && {notifs.unread} unread}} action={notifs && notifs.unread > 0 ? : undefined} dense > {notifs === null ? ( ) : notifs.items.length ? (
    {notifs.items.map((n) => (
  • {n.event.title}
    rule “{n.alert_name}” · {n.event.source.name} · {n.channel} · {n.status} {!n.read_at && · new}
  • ))}
) : ( No notifications yet. Rules are evaluated by the engine on every new event; deliveries are logged here. )}
Fired in this session } dense> {fired.length ? (
    {fired.map((f, i) => (
  • {f.event.title}
    rule “{f.alert.name}” · {f.event.source?.name} · {relTime(new Date(f.at))}
  • ))}
) : ( Matching events will appear here while this page is open. )}
); }