Skip to content

Frontend — Control Room Web App

Routes

Route Contents
/ ControlRoomPage — the live control room (see component tree below)
/history HistoryPage — searchable incident archive + cross-incident analytics, backed by GET /api/analytics/summary

Component tree

<ControlRoomPage>
 ├── <LiveQoEMap />            — region-colored map of rebuffer / error rate
 ├── <AgentActivityFeed />     — streaming log of sentinel/detective/producer/responder/wrap events
 ├── <IncidentTimeline />      — chronological view of one incident, built from AGENT_EVENT rows
 ├── <ApprovalModal />         — appears on responder_action_pending; a *queue* of pending approvals,
 │                               one modal at a time with a "N pending" badge, since concurrent
 │                               incidents can each generate their own high-risk approval request
 └── <GrafanaPanelEmbed />     — renders get_panel_image output + generate_deeplink "open in Grafana"

The header also carries an Inject 3 concurrent anomalies button (alongside the single-incident Inject demo anomaly) that fires three different metric/region scenarios in parallel via Promise.all, specifically to exercise the queued-approval and per-incident agent-status paths -- see low-level-design.md. Agent status badges show ×N when an agent has more than one incident active.

Approval queue (sketch)

// frontend/app/page.tsx (abridged)
const [pendingApprovals, setPendingApprovals] = useState<PendingApproval[]>([]);

// on a responder_action_pending WS event:
setPendingApprovals((prev) =>
  prev.some((p) => p.incidentId === entry.incidentId) ? prev : [...prev, entry]
);

// ApprovalModal shows queue[0] and a "1 of N pending" badge when N > 1;
// onResolved removes that incident from the queue so the next one surfaces.

WebSocket hook (sketch)

// frontend/lib/useControlRoomSocket.ts
import { useEffect, useState } from "react";

export type AgentEvent = {
  type: string;
  incident_id: string;
  agent: string;
  timestamp: string;
  payload: Record<string, unknown>;
};

export function useControlRoomSocket(url: string) {
  const [events, setEvents] = useState<AgentEvent[]>([]);

  useEffect(() => {
    const ws = new WebSocket(url);
    ws.onmessage = (msg) => setEvents((prev) => [...prev, JSON.parse(msg.data)]);
    const heartbeat = setInterval(() => ws.readyState === 1 && ws.send("ping"), 15000);
    return () => {
      clearInterval(heartbeat);
      ws.close();
    };
  }, [url]);

  return events;
}

The frontend consumes the AgentEventEnvelope wire format documented in backend.md, which mirrors the /api/incidents/* REST responses so the UI can be reconstructed on page load without replaying the WebSocket stream from the start.

History & analytics page

/history fetches GET /api/incidents (client-side filtered by title/status) and GET /api/analytics/summary, rendering:

  • Stat tiles: total incidents, MTTR, resolved count, active count.
  • Two breach-frequency bar lists (by metric, by region), built from AnalyticsSummary.breaches_by_metric / breaches_by_region.
  • A searchable/filterable incident list where clicking a row lazily fetches and expands that incident's postmortem (GET /api/incidents/{id}/postmortem).