import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { ChevronLeft, ChevronRight, Filter, Cpu, AlertTriangle, Pause, Play, } from '../icons'; import api from '../utils/api'; import EmptyState from './EmptyState/EmptyState'; import OrchestratorInspector from './OrchestratorInspector'; import { useOrchestratorStream, type OrchestratorStreamEvent } from './useOrchestratorStream'; import './Orchestrator.css'; interface OrchestratorEntry { uuid: string; ts: string; kind: 'traffic' | 'file' | 'email' | string; protocol: string; action: string; src_decky_uuid: string | null; dst_decky_uuid: string; success: boolean; payload: string; // Email-only extras — populated when `kind === 'email'`, undefined // for traffic/file rows. The renderer keys off `kind` to decide // whether to read these. subject?: string; sender_email?: string; recipient_email?: string; language?: string; thread_id?: string; mail_decky_uuid?: string; message_id?: string; in_reply_to?: string | null; } type KindFilter = 'all' | 'traffic' | 'file' | 'email'; type StreamStatus = 'connecting' | 'live' | 'error'; const ROW_CAP = 500; const FRESH_MS = 5_000; const timeAgo = (dateStr: string | null): string => { if (!dateStr) return '—'; const diff = Date.now() - new Date(dateStr).getTime(); const secs = Math.floor(diff / 1000); if (secs < 60) return `${secs}s ago`; const mins = Math.floor(secs / 60); if (mins < 60) return `${mins}m ago`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `${hrs}h ago`; return `${Math.floor(hrs / 24)}d ago`; }; const Orchestrator: React.FC = () => { const [searchParams, setSearchParams] = useSearchParams(); const page = parseInt(searchParams.get('page') || '1'); const kindParam = (searchParams.get('kind') || 'all') as KindFilter; const [rows, setRows] = useState([]); const [streamRows, setStreamRows] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); const [status, setStatus] = useState('connecting'); const [paused, setPaused] = useState(false); const [now, setNow] = useState(Date.now()); const [selected, setSelected] = useState(null); const [failuresLastHour, setFailuresLastHour] = useState(0); const limit = 50; const pausedRef = useRef(paused); useEffect(() => { pausedRef.current = paused; }, [paused]); // Tick to refresh the "Xs ago" labels and fade the fresh-row tint. useEffect(() => { const t = setInterval(() => setNow(Date.now()), 5_000); return () => clearInterval(t); }, []); // Authoritative failure count from the DB — see DEBT-042. The // in-memory derivation it replaced was bounded by the SSE buffer + // one paginated page, so failures older than the local window were // silently excluded and the badge read low on busy fleets. useEffect(() => { let cancelled = false; const fetchStats = async () => { try { const res = await api.get( '/orchestrator/events/stats?since=1h&success=false', ); if (!cancelled) setFailuresLastHour(res.data?.count ?? 0); } catch { // Silent: the badge is a hint, missing data shouldn't blow up the page. } }; fetchStats(); const t = setInterval(fetchStats, 30_000); return () => { cancelled = true; clearInterval(t); }; }, []); const fetchEvents = async () => { setLoading(true); try { const offset = (page - 1) * limit; const kindQ = kindParam !== 'all' ? `&kind=${kindParam}` : ''; const res = await api.get( `/orchestrator/events?limit=${limit}&offset=${offset}${kindQ}`, ); setRows(res.data.data ?? []); setTotal(res.data.total ?? 0); } catch (err) { console.error('Failed to fetch orchestrator events', err); } finally { setLoading(false); } }; useEffect(() => { fetchEvents(); }, [page, kindParam]); useOrchestratorStream({ enabled: true, onStatus: setStatus, onEvent: (ev: OrchestratorStreamEvent) => { if (pausedRef.current) return; if (ev.name !== 'traffic' && ev.name !== 'file' && ev.name !== 'email') return; const p = ev.payload as Partial & { // Live email payloads come from worker._one_tick — see emailgen // worker.py for the bus payload shape. sender_email?: string; recipient_email?: string; subject?: string; language?: string; thread_id?: string; mail_decky_uuid?: string; message_id?: string; in_reply_to?: string | null; }; const isEmail = ev.name === 'email' || p.kind === 'email'; const row: OrchestratorEntry = isEmail ? { uuid: `live-${ev.ts ?? Date.now()}-${Math.random().toString(36).slice(2, 8)}`, ts: ev.ts ?? new Date().toISOString(), kind: 'email', protocol: 'smtp', action: p.subject ?? '', // Map sender/recipient onto src/dst so the existing inspector // shows them naturally — the API does the same on REST reads. src_decky_uuid: p.sender_email ?? null, dst_decky_uuid: p.recipient_email ?? '', success: Boolean(p.success), payload: typeof p.payload === 'string' ? p.payload : JSON.stringify(p.payload ?? {}), subject: p.subject, sender_email: p.sender_email, recipient_email: p.recipient_email, language: p.language, thread_id: p.thread_id, mail_decky_uuid: p.mail_decky_uuid, message_id: p.message_id, in_reply_to: p.in_reply_to ?? null, } : { uuid: `live-${ev.ts ?? Date.now()}-${Math.random().toString(36).slice(2, 8)}`, ts: ev.ts ?? new Date().toISOString(), kind: (p.kind ?? ev.name) as OrchestratorEntry['kind'], protocol: p.protocol ?? '?', action: p.action ?? '', src_decky_uuid: p.src_decky_uuid ?? null, dst_decky_uuid: p.dst_decky_uuid ?? '', success: Boolean(p.success), payload: typeof p.payload === 'string' ? p.payload : JSON.stringify(p.payload ?? {}), }; setStreamRows((prev) => [row, ...prev].slice(0, ROW_CAP)); }, }); const setPage = (p: number) => setSearchParams({ kind: kindParam, page: p.toString() }); const setKind = (k: KindFilter) => setSearchParams({ kind: k, page: '1' }); const totalPages = Math.max(1, Math.ceil(total / limit)); const visible = useMemo(() => { const merged = [...streamRows, ...rows]; if (kindParam === 'all') return merged; return merged.filter((r) => r.kind === kindParam); }, [streamRows, rows, kindParam]); const statusLabel = status === 'live' ? 'LIVE' : status === 'connecting' ? 'CONNECTING' : 'OFFLINE'; return (

ORCHESTRATOR

{statusLabel} {failuresLastHour > 0 && ( {failuresLastHour} FAILURES / 1H )}
{total.toLocaleString()} EVENTS · LIFE-INJECTION ACTIVITY
{(['all', 'traffic', 'file', 'email'] as KindFilter[]).map((k) => ( ))}
{visible.length.toLocaleString()} EVENTS SHOWN
Page {page} of {totalPages}
{visible.length > 0 ? visible.map((r) => { const fresh = now - new Date(r.ts).getTime() < FRESH_MS; const cls = !r.success ? 'fail' : fresh ? 'fresh' : ''; const kindCls = r.kind === 'traffic' || r.kind === 'file' || r.kind === 'email' ? r.kind : ''; const isEmail = r.kind === 'email'; // FileAction and EditAction both write kind="file"; the // discriminator lives in `action` ("file:create" vs // "file:edit"). Surface the difference visually without // widening the kind enum (which doubles as the bus // topic family). const isEdit = r.kind === 'file' && r.action?.startsWith('file:edit'); return ( setSelected(r)} > ); }) : ( )}
TS KIND ACTION SRC → DST OK PAYLOAD
{timeAgo(r.ts)} {r.kind} {isEdit && ( EDIT )} {isEmail && r.language && ( {r.language.toUpperCase()} )} {r.action} {/* Email rows show full sender / recipient addresses; UUID rows stay truncated. */} {isEmail ? (r.src_decky_uuid ?? '—') : (r.src_decky_uuid ? `${r.src_decky_uuid.slice(0, 8)}…` : '—')} {isEmail ? (r.dst_decky_uuid || '—') : (r.dst_decky_uuid ? `${r.dst_decky_uuid.slice(0, 8)}…` : '—')} {r.success ? '✓' : '✗'} {r.payload}
{selected && ( setSelected(null)} /> )}
); }; export default Orchestrator;