diff --git a/decnet_web/src/App.tsx b/decnet_web/src/App.tsx index 4d9db2c7..c32f268b 100644 --- a/decnet_web/src/App.tsx +++ b/decnet_web/src/App.tsx @@ -23,6 +23,7 @@ const Identities = lazy(() => import('./components/Identities')); const IdentityDetail = lazy(() => import('./components/IdentityDetail')); const Campaigns = lazy(() => import('./components/Campaigns')); const CampaignDetail = lazy(() => import('./components/CampaignDetail')); +const Orchestrator = lazy(() => import('./components/Orchestrator')); const Config = lazy(() => import('./components/Config')); const Bounty = lazy(() => import('./components/Bounty')); const Credentials = lazy(() => import('./components/Credentials')); @@ -121,6 +122,7 @@ const AuthedShell: React.FC = ({ onLogout, onSearch, searchQue } /> } /> } /> + } /> } /> } /> } /> diff --git a/decnet_web/src/components/Layout.tsx b/decnet_web/src/components/Layout.tsx index 0984bcd6..39066b83 100644 --- a/decnet_web/src/components/Layout.tsx +++ b/decnet_web/src/components/Layout.tsx @@ -3,7 +3,7 @@ import { NavLink, useLocation } from 'react-router-dom'; import { Menu, X, Search, Activity, LayoutDashboard, Terminal, Settings, LogOut, Server, Archive, Package, Network, ChevronDown, ChevronRight, HardDrive, - ShieldAlert, Bell, Webhook, Lock, Crosshair, Fingerprint, + ShieldAlert, Bell, Webhook, Lock, Crosshair, Fingerprint, Zap, Cpu, } from '../icons'; import { prefetchRoute } from '../routePrefetch'; import './Layout.css'; @@ -33,6 +33,7 @@ const ROUTE_LABELS: Record = { '/attackers': 'ATTACKERS', '/identities': 'IDENTITIES', '/campaigns': 'CAMPAIGNS', + '/orchestrator': 'ORCHESTRATOR', '/config': 'CONFIG', '/swarm-updates': 'REMOTE UPDATES', '/swarm/hosts': 'SWARM HOSTS', @@ -133,6 +134,9 @@ const Layout: React.FC = ({ } label="Identities" open={sidebarOpen} indent /> } label="Campaigns" open={sidebarOpen} indent /> + } open={sidebarOpen}> + } label="Orchestrator" open={sidebarOpen} indent /> + } open={sidebarOpen}> } label="SWARM Hosts" open={sidebarOpen} indent /> } label="Remote Updates" open={sidebarOpen} indent /> diff --git a/decnet_web/src/components/Orchestrator.tsx b/decnet_web/src/components/Orchestrator.tsx new file mode 100644 index 00000000..bb8a77a0 --- /dev/null +++ b/decnet_web/src/components/Orchestrator.tsx @@ -0,0 +1,238 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { + ChevronLeft, ChevronRight, Filter, Cpu, AlertTriangle, +} from '../icons'; +import api from '../utils/api'; +import EmptyState from './EmptyState/EmptyState'; +import { useOrchestratorStream, type OrchestratorStreamEvent } from './useOrchestratorStream'; +import './Dashboard.css'; + +interface OrchestratorEntry { + uuid: string; + ts: string; + kind: 'traffic' | 'file' | string; + protocol: string; + action: string; + src_decky_uuid: string | null; + dst_decky_uuid: string; + success: boolean; + payload: string; +} + +type KindFilter = 'all' | 'traffic' | 'file'; +type StreamStatus = 'connecting' | 'live' | 'error'; + +const ROW_CAP = 500; +const HOUR_MS = 60 * 60 * 1000; + +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 limit = 50; + const pausedRef = useRef(paused); + useEffect(() => { pausedRef.current = paused; }, [paused]); + + 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 === 'snapshot') return; + if (ev.name !== 'traffic' && ev.name !== 'file') return; + const p = ev.payload as Partial; + const row: OrchestratorEntry = { + 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 failuresLastHour = useMemo(() => { + const cutoff = Date.now() - HOUR_MS; + return [...streamRows, ...rows].filter( + (r) => !r.success && new Date(r.ts).getTime() >= cutoff, + ).length; + }, [streamRows, rows]); + + const statusPill = ( + + {status === 'live' ? '● LIVE' : status === 'connecting' ? '● CONNECTING' : '● OFFLINE'} + + ); + + return ( +
+
+
+
+ +

ORCHESTRATOR

+ {statusPill} + {failuresLastHour > 0 && ( + + {failuresLastHour} FAILURES / 1h + + )} +
+ + {total.toLocaleString()} EVENTS · LIFE-INJECTION ACTIVITY + +
+
+ +
+ + + + + +
+ +
+
+
+ + {visible.length.toLocaleString()} EVENTS SHOWN +
+
+
+ Page {page} of {totalPages} + + +
+
+
+ +
+ + + + + + + + + + + + + {visible.length > 0 ? visible.map((r) => ( + + + + + + + + + )) : ( + + + + )} + +
TSKINDACTIONSRC → DSTOKPAYLOAD
{timeAgo(r.ts)} + + {r.kind.toUpperCase()} + + + {r.action} + + {r.src_decky_uuid ? `${r.src_decky_uuid.slice(0, 8)}…` : '—'} + {' → '} + {r.dst_decky_uuid ? `${r.dst_decky_uuid.slice(0, 8)}…` : '—'} + {r.success ? '✓' : '✗'} + {r.payload} +
+ +
+
+
+
+ ); +}; + +export default Orchestrator; diff --git a/decnet_web/src/components/useOrchestratorStream.ts b/decnet_web/src/components/useOrchestratorStream.ts new file mode 100644 index 00000000..c58a6ea1 --- /dev/null +++ b/decnet_web/src/components/useOrchestratorStream.ts @@ -0,0 +1,88 @@ +/** + * Orchestrator event stream — opens an SSE connection to + * `/orchestrator/events/stream` and dispatches typed events to the + * caller. Mirror of `useCampaignStream`. + */ +import { useEffect, useRef } from 'react'; + +export type OrchestratorStreamEventName = 'snapshot' | 'traffic' | 'file'; + +export interface OrchestratorStreamEvent { + name: OrchestratorStreamEventName | string; + topic?: string; + type?: string; + ts?: string; + payload: Record; +} + +export interface UseOrchestratorStreamOptions { + enabled: boolean; + onEvent: (event: OrchestratorStreamEvent) => void; + onStatus?: (status: 'connecting' | 'live' | 'error') => void; +} + +const NAMED_EVENTS: OrchestratorStreamEventName[] = ['snapshot', 'traffic', 'file']; + +export function useOrchestratorStream({ + enabled, + onEvent, + onStatus, +}: UseOrchestratorStreamOptions): void { + const esRef = useRef(null); + const reconnectRef = useRef | null>(null); + const onEventRef = useRef(onEvent); + const onStatusRef = useRef(onStatus); + useEffect(() => { onEventRef.current = onEvent; }, [onEvent]); + useEffect(() => { onStatusRef.current = onStatus; }, [onStatus]); + + useEffect(() => { + if (!enabled) return; + + const connect = () => { + if (esRef.current) esRef.current.close(); + onStatusRef.current?.('connecting'); + const token = localStorage.getItem('token') ?? ''; + const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1'; + const url = `${baseUrl}/orchestrator/events/stream?token=${encodeURIComponent(token)}`; + + const es = new EventSource(url); + esRef.current = es; + + es.onopen = () => onStatusRef.current?.('live'); + + const dispatch = (name: string) => (event: MessageEvent) => { + try { + const parsed = JSON.parse(event.data) as Partial; + onEventRef.current({ + name, + topic: parsed.topic, + type: parsed.type, + ts: parsed.ts, + payload: (parsed.payload ?? {}) as Record, + }); + } catch (err) { + console.error('useOrchestratorStream: parse failed', err); + } + }; + + for (const name of NAMED_EVENTS) { + es.addEventListener(name, dispatch(name) as EventListener); + } + + es.onerror = () => { + es.close(); + esRef.current = null; + onStatusRef.current?.('error'); + reconnectRef.current = setTimeout(connect, 3000); + }; + }; + + connect(); + + return () => { + if (reconnectRef.current) clearTimeout(reconnectRef.current); + if (esRef.current) esRef.current.close(); + esRef.current = null; + }; + }, [enabled]); +} diff --git a/decnet_web/src/routePrefetch.ts b/decnet_web/src/routePrefetch.ts index 3e7cb388..ea733c74 100644 --- a/decnet_web/src/routePrefetch.ts +++ b/decnet_web/src/routePrefetch.ts @@ -22,6 +22,7 @@ const loaders: Record = { '/config': () => import('./components/Config'), '/swarm-updates': () => import('./components/RemoteUpdates'), '/swarm/hosts': () => import('./components/SwarmHosts'), + '/orchestrator': () => import('./components/Orchestrator'), }; const fired = new Set();