diff --git a/kora_cli/web_server.py b/kora_cli/web_server.py index a373bd8ad469..710953ebb44a 100644 --- a/kora_cli/web_server.py +++ b/kora_cli/web_server.py @@ -4789,6 +4789,105 @@ async def list_mcp_clients(): } +# --------------------------------------------------------------------------- +# Webhook events lens (KR-WEBHOOK-EVENTS-PANEL) +# --------------------------------------------------------------------------- +# +# Operator-facing observability for public-port traffic on +# /api/webhooks/* (Slack events, email inbound, future panels). +# Anticipates Phase 2 Features 3 + 5; once the daemon deploys to +# Fly, Joshua needs to see "what's hitting the public port" without +# `flyctl logs`. +# +# v1 stub: 4 representative events per bucket §3 verbatim (verified +# slack message, verified slack url_verification, dead-letter email +# bad signature, slack rate-limited). CC#3 will wire real per-event +# recording via either chain-event emission or a substrate +# webhook_events table — blocked on substrate-team coord ask for +# the dead-letter ledger shape. Until then the stub:true flag keeps +# the FE banner visible. +# +# SECURITY: source_ip values are OCTET-MASKED in the response +# (e.g. "54.203.x.x" not "54.203.99.142") — operator gets +# geolocation hint without full PII exposure. CC#3 will enforce +# the same mask when real data flips in. The §4 test regex-pins +# the mask format so any future drift that emits a full IP gets +# caught at the endpoint layer (3-layer security contract pattern +# from KR-MCP-3 #106: backend shape + TS interface + test regex). + + +@app.get("/api/webhooks/events/recent") +async def list_recent_webhook_events(): + """Return recent public-webhook events for the operator-facing lens. + + v1 stub — pinned shape so CC#3's per-event recording (chain-event + emission OR substrate webhook_events table) can swap the body + without touching the FE. + + Per-event fields: + id — opaque event id + endpoint — e.g. "/api/webhooks/slack/events" + received_at — ISO-8601 timestamp + status — verified | dead_letter | rate_limited | handler_error + source_ip — OCTET-MASKED ("54.203.x.x" never "54.203.99.142") + event_type — handler-side classification (e.g. "message", + "url_verification", "hmac_invalid"); null when + rate-limited (request never reached the handler) + details — handler-shape-specific dict (slack_team_id, + reason, etc.) — future redaction pass for PII + lands when real data wires in (out of scope here) + """ + return { + "events": [ + { + "id": "stub-1", + "endpoint": "/api/webhooks/slack/events", + "received_at": "2026-05-22T17:55:13Z", + "status": "verified", + "source_ip": "54.203.x.x", + "event_type": "message", + "details": { + "slack_team_id": "T_STUB", + "channel_id": "C_STUB", + }, + }, + { + "id": "stub-2", + "endpoint": "/api/webhooks/slack/events", + "received_at": "2026-05-22T17:52:01Z", + "status": "verified", + "source_ip": "54.203.x.x", + "event_type": "url_verification", + "details": {"challenge_echoed": True}, + }, + { + "id": "stub-3", + "endpoint": "/api/webhooks/email/inbound", + "received_at": "2026-05-22T17:48:22Z", + "status": "dead_letter", + "source_ip": "203.0.113.x", + "event_type": "hmac_invalid", + "details": { + "reason": "signature_mismatch", + "header_present": True, + }, + }, + { + "id": "stub-4", + "endpoint": "/api/webhooks/slack/events", + "received_at": "2026-05-22T17:45:09Z", + "status": "rate_limited", + "source_ip": "198.51.100.x", + "event_type": None, + "details": {"rate_limit_window": "60/minute"}, + }, + ], + "stub": True, + "generated_at": "2026-05-22T18:00:00Z", + "total_recent_24h": 4, + } + + # --------------------------------------------------------------------------- # Profile management endpoints (minimal — list/create/rename/delete + SOUL.md) # --------------------------------------------------------------------------- diff --git a/tests/kora_cli/test_web_server_webhook_events.py b/tests/kora_cli/test_web_server_webhook_events.py new file mode 100644 index 000000000000..e616e2c9e1a8 --- /dev/null +++ b/tests/kora_cli/test_web_server_webhook_events.py @@ -0,0 +1,226 @@ +"""Tests for the KR-WEBHOOK-EVENTS-PANEL stub endpoint. + +Bucket §4 scenarios: + 1. GET /api/webhooks/events/recent returns 200 + 2. Top-level shape (events + stub:true + generated_at + total_recent_24h) + 3. All 4 expected stub events present + 4. Each event has the required keys + valid status enum + 5. SECURITY: source_ip is OCTET-MASKED (e.g. "54.203.x.x" never + "54.203.99.142"); regex-pin asserts the mask format + 6. Cron-regression sanity +""" + +import re + +import pytest + + +_VALID_STATUS = {"verified", "dead_letter", "rate_limited", "handler_error"} + +# Octet-masked IPv4: at least one octet replaced with "x". Real shapes +# the bucket §3 stub uses are "54.203.x.x" (last 2) and "203.0.113.x" +# (last 1). Pin: starts with 1-3 digits, contains at least one +# literal "x" octet, no contiguous 4-digit sequences. +_MASKED_IPV4_PIN = re.compile(r"^(?:\d{1,3}\.){1,3}(?:\d{1,3}|x)(?:\.(?:\d{1,3}|x))*$") +_FULL_IPV4_LEAK = re.compile(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b") + + +@pytest.fixture(autouse=True) +def _isolate_config(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr("kora_cli.config.get_kora_home", lambda: tmp_path) + monkeypatch.setattr( + "kora_cli.config.get_config_path", lambda: tmp_path / "config.yaml" + ) + monkeypatch.setattr( + "kora_cli.config.get_env_path", lambda: tmp_path / ".env" + ) + return tmp_path + + +# ---- 1. 200 ----------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_endpoint_returns_200(_isolate_config): + from kora_cli import web_server + + result = await web_server.list_recent_webhook_events() + assert isinstance(result, dict) + + +# ---- 2. Top-level shape ---------------------------------------------- + + +@pytest.mark.asyncio +async def test_response_shape_has_required_keys(_isolate_config): + from kora_cli import web_server + + result = await web_server.list_recent_webhook_events() + assert set(result.keys()) == { + "events", + "stub", + "generated_at", + "total_recent_24h", + } + assert isinstance(result["events"], list) + assert isinstance(result["generated_at"], str) + assert isinstance(result["total_recent_24h"], int) + assert result["stub"] is True + + +# ---- 3. Expected stub events ---------------------------------------- + + +@pytest.mark.asyncio +async def test_stub_returns_four_representative_events(_isolate_config): + """Pin the bucket §3 canonical 4-event stub list. CC#3's per-event + recording will replace the body but stub shape needs to stay + stable so the FE that ships off this PR keeps rendering.""" + from kora_cli import web_server + + result = await web_server.list_recent_webhook_events() + assert len(result["events"]) == 4 + ids = {e["id"] for e in result["events"]} + assert ids == {"stub-1", "stub-2", "stub-3", "stub-4"} + + +@pytest.mark.asyncio +async def test_stub_covers_three_status_values(_isolate_config): + """The 4 stub events deliberately span verified / dead_letter / + rate_limited so the FE's status-badge color rendering is + exercised by the panel's manual smoke. Pin so future stub edits + can't accidentally homogenize.""" + from kora_cli import web_server + + result = await web_server.list_recent_webhook_events() + statuses = {e["status"] for e in result["events"]} + assert statuses == {"verified", "dead_letter", "rate_limited"} + + +# ---- 4. Per-entry shape + enum -------------------------------------- + + +@pytest.mark.asyncio +async def test_each_event_has_required_keys_and_valid_status(_isolate_config): + from kora_cli import web_server + + result = await web_server.list_recent_webhook_events() + required = { + "id", + "endpoint", + "received_at", + "status", + "source_ip", + "event_type", + "details", + } + for event in result["events"]: + assert set(event.keys()) == required + assert event["status"] in _VALID_STATUS, ( + f"{event['id']}: status={event['status']!r} not in {_VALID_STATUS}" + ) + assert isinstance(event["endpoint"], str) + assert event["endpoint"].startswith("/api/webhooks/") + # event_type is null for rate_limited (request never reached + # the handler) — that's a documented contract, not a defect + if event["status"] == "rate_limited": + assert event["event_type"] is None + else: + assert isinstance(event["event_type"], str) + assert isinstance(event["details"], dict) + + +# ---- 5. SECURITY: source_ip octet-mask enforcement ----------------- + + +@pytest.mark.asyncio +async def test_source_ip_is_octet_masked_per_security_contract(_isolate_config): + """Bucket hard-constraint: source_ip values are OCTET-MASKED on + the wire (e.g. "54.203.x.x" never "54.203.99.142") — operator + gets geolocation hint without full PII exposure. Pin the mask + shape so a future stub or real-data drift can't silently leak + a full IP. 3-layer security pattern from KR-MCP-3 #106: + backend payload + TS interface + this test.""" + from kora_cli import web_server + + result = await web_server.list_recent_webhook_events() + for event in result["events"]: + ip = event["source_ip"] + assert isinstance(ip, str) and ip + # Mask shape: contains at least one literal "x" octet + assert "x" in ip, ( + f"{event['id']}: source_ip={ip!r} contains no 'x' octet — " + f"contract requires octet-masking for PII" + ) + # Format matches our pin regex + assert _MASKED_IPV4_PIN.match(ip), ( + f"{event['id']}: source_ip={ip!r} doesn't match expected " + f"octet-mask shape (e.g. '54.203.x.x' / '203.0.113.x')" + ) + + +@pytest.mark.asyncio +async def test_no_full_ipv4_address_leaks_anywhere_in_payload(_isolate_config): + """Belt+braces: walk the entire payload (top-level + nested dicts + + event details) and assert no full 4-octet IPv4 address appears + anywhere. Catches a future drift that adds a "real_source_ip" + diagnostic field, embeds a full IP in details, etc.""" + from kora_cli import web_server + import json as _json + + result = await web_server.list_recent_webhook_events() + blob = _json.dumps(result) + leaks = _FULL_IPV4_LEAK.findall(blob) + assert leaks == [], ( + f"payload contains full IPv4 address(es): {leaks} — " + f"source IPs must be octet-masked everywhere they appear " + f"(bucket §5 PII contract)" + ) + + +# ---- 6. Bucket §3 stub values pinned -------------------------------- + + +@pytest.mark.asyncio +async def test_dead_letter_event_carries_reason_in_details(_isolate_config): + """The stub dead-letter event surfaces a "reason" field in details + — operator-actionable when investigating a 401. Pin so the + contract stays even if the real implementation grows other + detail keys.""" + from kora_cli import web_server + + result = await web_server.list_recent_webhook_events() + dead_letter = next(e for e in result["events"] if e["status"] == "dead_letter") + assert "reason" in dead_letter["details"] + assert dead_letter["event_type"] == "hmac_invalid" + + +@pytest.mark.asyncio +async def test_endpoints_match_known_webhook_routes(_isolate_config): + """The stub events reference /api/webhooks/slack/events + + /api/webhooks/email/inbound — the two routes CC#3 ST3 (PR #104) + actually shipped. Pin to catch a future stub typo that + references a route that doesn't exist.""" + from kora_cli import web_server + + known_routes = { + "/api/webhooks/slack/events", + "/api/webhooks/email/inbound", + } + result = await web_server.list_recent_webhook_events() + endpoints = {e["endpoint"] for e in result["events"]} + assert endpoints <= known_routes, ( + f"stub references unknown route(s): {endpoints - known_routes}" + ) + + +# ---- 7. Cron-regression sanity ------------------------------------- + + +@pytest.mark.asyncio +async def test_cron_endpoint_still_works_with_webhook_events_registered(_isolate_config): + from kora_cli import web_server + + jobs = await web_server.list_cron_jobs(profile="all") + assert isinstance(jobs, list) diff --git a/web/src/App.tsx b/web/src/App.tsx index fb41a06b323d..a6e6ed6bd173 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -30,6 +30,7 @@ import { Globe, Heart, HeartPulse, + Inbox, KeyRound, LayoutDashboard, Menu, @@ -83,6 +84,7 @@ import OperationalStatePage from "@/pages/OperationalStatePage"; import HealthRollupPage from "@/pages/HealthRollupPage"; import HeartbeatPanel from "@/pages/HeartbeatPanel"; import MCPClientsPanel from "@/pages/MCPClientsPanel"; +import WebhookEventsPanel from "@/pages/WebhookEventsPanel"; import BootStatusPage from "@/pages/BootStatusPage"; import DRStatePage from "@/pages/DRStatePage"; import CostStatePage from "@/pages/CostStatePage"; @@ -138,6 +140,7 @@ const BUILTIN_ROUTES_CORE: Record = { "/health-rollup": HealthRollupPage, "/heartbeat": HeartbeatPanel, "/mcp-clients": MCPClientsPanel, + "/webhook-events": WebhookEventsPanel, "/boot-status": BootStatusPage, "/dr-state": DRStatePage, "/cost-state": CostStatePage, @@ -200,6 +203,12 @@ const BUILTIN_NAV_REST: NavItem[] = [ label: "Heartbeat", icon: Heart, }, + { + path: "/webhook-events", + labelKey: "webhookEvents", + label: "Webhook Events", + icon: Inbox, + }, { path: "/mcp-clients", labelKey: "mcpClients", diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 7ef47b657ec0..709c4caa3f17 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -122,6 +122,8 @@ export const api = { fetchJSON("/api/heartbeat/services"), getMCPClients: () => fetchJSON("/api/mcp/clients/list"), + getRecentWebhookEvents: () => + fetchJSON("/api/webhooks/events/recent"), getSessions: (limit = 20, offset = 0) => fetchJSON(`/api/sessions?limit=${limit}&offset=${offset}`), getSessionMessages: (id: string) => @@ -1459,3 +1461,32 @@ export interface MCPClientsListResponse { stub: boolean; generated_at: string; } + +// Webhook events lens (KR-WEBHOOK-EVENTS-PANEL). +// SECURITY CONTRACT: source_ip is OCTET-MASKED on the wire +// (e.g. "54.203.x.x", never "54.203.99.142"). The TS type is just +// `string` — the backend enforces the mask shape and a backend +// regex test asserts it. FE renders source_ip verbatim from the +// wire; never reconstructs or de-masks. +export type WebhookEventStatus = + | "verified" + | "dead_letter" + | "rate_limited" + | "handler_error"; + +export interface WebhookEvent { + id: string; + endpoint: string; + received_at: string; + status: WebhookEventStatus; + source_ip: string; // octet-masked per backend contract + event_type: string | null; + details: Record; +} + +export interface WebhookEventsResponse { + events: WebhookEvent[]; + stub: boolean; + generated_at: string; + total_recent_24h: number; +} diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index df6298d93187..88c510837beb 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -12,6 +12,7 @@ import { Cable, Heart, HeartPulse, + Inbox, Hourglass, Info, OctagonAlert, @@ -49,6 +50,8 @@ import type { MCPClientsListResponse, OperationalStateResponse, RunbooksManifest, + WebhookEventsResponse, + WebhookEventStatus, } from "@/lib/api"; type LoadStatus = @@ -74,6 +77,8 @@ interface DashboardData { heartbeat: LoadStatus; // KR-MCP-3 — installed external MCP clients (stub) mcpClients: LoadStatus; + // KR-WEBHOOK-EVENTS-PANEL — public-port traffic lens (stub) + webhookEvents: LoadStatus; } const INITIAL_DATA: DashboardData = { @@ -90,6 +95,7 @@ const INITIAL_DATA: DashboardData = { runbooks: { state: "loading" }, heartbeat: { state: "loading" }, mcpClients: { state: "loading" }, + webhookEvents: { state: "loading" }, }; const HEALTH_TONE: Record = { @@ -588,6 +594,55 @@ function MCPClientsCardBody({ data }: { data: MCPClientsListResponse }) { ); } +function WebhookEventsCardBody({ data }: { data: WebhookEventsResponse }) { + // Counts mirror the panel's stats strip; dashboard surface is more + // compressed but the headline + signal pills match what the + // operator sees in the full panel. + const counts: Record = { + verified: 0, + dead_letter: 0, + rate_limited: 0, + handler_error: 0, + }; + for (const e of data.events) counts[e.status]++; + // Bucket §3(c) operator-attention contract: dashboard card border + // goes destructive when dead_letter > 5 in 24h. Headline tone + // tracks the same trigger so the card visually screams from the + // dashboard glance. + const deadLetterAlert = counts.dead_letter > 5; + const headlineClass = deadLetterAlert + ? "text-destructive" + : "text-foreground"; + return ( +
+
+ {data.total_recent_24h} + + event{data.total_recent_24h === 1 ? "" : "s"} / 24h + +
+
+ {counts.verified > 0 && ( + {counts.verified} verified + )} + {counts.dead_letter > 0 && ( + + {counts.dead_letter} dead-letter + + )} + {counts.rate_limited > 0 && ( + {counts.rate_limited} rate-limited + )} + {counts.handler_error > 0 && ( + + {counts.handler_error} handler-error + + )} +
+
+ ); +} + // ── Hero ───────────────────────────────────────────────────────────────── interface HealthHeroProps { @@ -730,6 +785,8 @@ export default function DashboardPage() { loadOne("heartbeat", () => api.getHeartbeatServices()), // KR-MCP-3 loadOne("mcpClients", () => api.getMCPClients()), + // KR-WEBHOOK-EVENTS-PANEL + loadOne("webhookEvents", () => api.getRecentWebhookEvents()), ]); if (isManual) { setRefreshing(false); @@ -761,6 +818,7 @@ export default function DashboardPage() { data.runbooks, data.heartbeat, data.mcpClients, + data.webhookEvents, ]; const anyStubbed = ALL_SOURCES.some((s) => isStubbed(s)); @@ -984,6 +1042,23 @@ export default function DashboardPage() { )} + + + void loadOne("webhookEvents", () => + api.getRecentWebhookEvents(), + ) + } + > + {data.webhookEvents.state === "ready" && ( + + )} + {/* ── Bottom strip: links to other (non-admin-panel) pages ─────── */} diff --git a/web/src/pages/WebhookEventsPanel.tsx b/web/src/pages/WebhookEventsPanel.tsx new file mode 100644 index 000000000000..428eb7105b2b --- /dev/null +++ b/web/src/pages/WebhookEventsPanel.tsx @@ -0,0 +1,386 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + AlertOctagon, + AlertTriangle, + CheckCircle2, + ChevronDown, + ChevronRight, + Clock, + Globe, + HelpCircle, + Inbox, + RefreshCw, + ShieldX, +} from "lucide-react"; +import { Badge } from "@nous-research/ui/ui/components/badge"; +import { Button } from "@nous-research/ui/ui/components/button"; +import { Spinner } from "@nous-research/ui/ui/components/spinner"; +import { H2 } from "@/components/NouiTypography"; +import { Card, CardContent } from "@/components/ui/card"; +import { Toast } from "@/components/Toast"; +import { useToast } from "@/hooks/useToast"; +import { api } from "@/lib/api"; +import type { + WebhookEvent, + WebhookEventStatus, + WebhookEventsResponse, +} from "@/lib/api"; + +type StatusFilter = "all" | WebhookEventStatus; + +const STATUS_TONE: Record = { + verified: "success", + dead_letter: "destructive", + rate_limited: "warning", + handler_error: "destructive", +}; + +const STATUS_LABEL: Record = { + verified: "verified", + dead_letter: "dead letter", + rate_limited: "rate limited", + handler_error: "handler error", +}; + +function StatusIcon({ status }: { status: WebhookEventStatus }) { + switch (status) { + case "verified": + return ; + case "dead_letter": + return ; + case "rate_limited": + return ; + case "handler_error": + return ; + } +} + +function shortEndpoint(endpoint: string): string { + // "/api/webhooks/slack/events" → "/slack/events" + // "/api/webhooks/email/inbound" → "/email/inbound" + return endpoint.replace(/^\/api\/webhooks/, ""); +} + +function formatTimestamp(iso: string): string { + if (!iso) return "—"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleString(); +} + +function formatRelative(iso: string): string { + if (!iso) return ""; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + const deltaMs = d.getTime() - Date.now(); + const absSec = Math.abs(deltaMs) / 1000; + if (absSec < 60) { + const n = Math.round(absSec); + return deltaMs < 0 ? `${n}s ago` : `in ${n}s`; + } + const absMin = absSec / 60; + if (absMin < 60) { + const n = Math.round(absMin); + return deltaMs < 0 ? `${n}m ago` : `in ${n}m`; + } + const absHr = absMin / 60; + if (absHr < 24) { + const n = Math.round(absHr); + return deltaMs < 0 ? `${n}h ago` : `in ${n}h`; + } + const absDay = absHr / 24; + const n = Math.round(absDay); + return deltaMs < 0 ? `${n}d ago` : `in ${n}d`; +} + +interface EventRowProps { + event: WebhookEvent; + expanded: boolean; + onToggle: () => void; +} + +function EventRow({ event, expanded, onToggle }: EventRowProps) { + const detailEntries = Object.entries(event.details); + return ( + + + + + {expanded && ( +
+
+ + id: {event.id} + + + endpoint: {event.endpoint} + + + received_at: {formatTimestamp(event.received_at)} + + + source_ip: {event.source_ip}{" "} + (octet-masked for PII) + +
+
+ Details + {detailEntries.length === 0 ? ( + none + ) : ( +
+                  {JSON.stringify(event.details, null, 2)}
+                
+ )} +
+
+ )} +
+
+ ); +} + +const FILTER_OPTIONS: Array<{ value: StatusFilter; label: string }> = [ + { value: "all", label: "All" }, + { value: "verified", label: "Verified" }, + { value: "dead_letter", label: "Dead Letter" }, + { value: "rate_limited", label: "Rate Limited" }, + { value: "handler_error", label: "Handler Error" }, +]; + +export default function WebhookEventsPanel() { + const [data, setData] = useState(null); + const [loadError, setLoadError] = useState(null); + const [refreshing, setRefreshing] = useState(false); + const [expandedIds, setExpandedIds] = useState>(new Set()); + const [filter, setFilter] = useState("all"); + const { toast, showToast } = useToast(); + + const loadEvents = useCallback( + (isManual: boolean) => { + if (isManual) setRefreshing(true); + setLoadError(null); + api + .getRecentWebhookEvents() + .then((resp) => setData(resp)) + .catch((e: unknown) => { + const msg = e instanceof Error ? e.message : String(e); + setLoadError(msg); + showToast(`Failed to load webhook events: ${msg}`, "error"); + }) + .finally(() => { + if (isManual) setRefreshing(false); + }); + }, + [showToast], + ); + + useEffect(() => { + loadEvents(false); + }, [loadEvents]); + + const toggleExpand = useCallback((id: string) => { + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + // FE-only filter — operator scopes the already-fetched list without + // re-hitting the backend. Real-time tail isn't the goal here. + const filteredEvents = useMemo(() => { + if (!data) return []; + if (filter === "all") return data.events; + return data.events.filter((e) => e.status === filter); + }, [data, filter]); + + // Aggregate counts for the stats strip. + const counts = useMemo(() => { + if (!data) return null; + const c: Record = { + verified: 0, + dead_letter: 0, + rate_limited: 0, + handler_error: 0, + }; + for (const e of data.events) c[e.status]++; + return c; + }, [data]); + + if (data === null && !loadError) { + return ( +
+ +
+ ); + } + + return ( +
+ + +
+
+

Recent Webhook Events

+

+ Public-port traffic on{" "} + /api/webhooks/* — verified, + dead-lettered, and rate-limited events. +

+
+ +
+ + {loadError && ( + + + +
+
Failed to load webhook events
+
{loadError}
+
+
+
+ )} + + {data?.stub && ( + + + +
+
+ STUB — per-event recording wires in via CC#3 follow-on +
+
+ Values shown are hardcoded sample data. CC#3 will add + per-event chain-event recording OR a substrate{" "} + webhook_events table (blocked on the + substrate-team coord ask for the dead-letter ledger + shape). +
+
+
+
+ )} + + {data && counts && ( + <> + {/* ── Stats strip ─────────────────────────────────────── */} + + + + + {data.total_recent_24h} event + {data.total_recent_24h === 1 ? "" : "s"} in last 24h + + + + {counts.verified} verified + + + + {counts.dead_letter} dead-lettered + + + + {counts.rate_limited} rate-limited + + {counts.handler_error > 0 && ( + + + {counts.handler_error} handler error + + )} + + Source IPs octet-masked for PII. + + + + + {/* ── Filter pills ────────────────────────────────────── */} +
+ {FILTER_OPTIONS.map((opt) => { + const isActive = filter === opt.value; + return ( + + ); + })} +
+ + {/* ── Events list (timeline, newest first) ────────────── */} + {filteredEvents.length === 0 ? ( + + + + {data.events.length === 0 + ? "No webhook events yet. Public webhook plane is on port 9118; verify daemon is running." + : `No events matching filter "${filter}".`} + + + ) : ( +
+ {filteredEvents.map((event) => ( + toggleExpand(event.id)} + /> + ))} +
+ )} + + )} +
+ ); +}