diff --git a/dashboard/app/api/demo/inject/route.js b/dashboard/app/api/demo/inject/route.js new file mode 100644 index 0000000..e8dfc0c --- /dev/null +++ b/dashboard/app/api/demo/inject/route.js @@ -0,0 +1,215 @@ +import { NextResponse } from 'next/server'; +import { randomUUID } from 'node:crypto'; +import { db } from '../../../../lib/firebase-admin.js'; + +const INJECT_TIMEOUT_MS = 15_000; +const EVENT_BUS_TIMEOUT_MS = 5_000; + +const configuredDisruptionAgentUrl = + process.env.DISRUPTION_AGENT_URL || + process.env.NEXT_PUBLIC_DISRUPTION_AGENT_URL || + ''; + +const DISRUPTION_AGENT_URL = + configuredDisruptionAgentUrl || (process.env.NODE_ENV === 'development' ? 'http://localhost:3001' : ''); + +const SCENARIOS = { + pacific_storm: { + label: 'Super Typhoon Mawar', + type: 'WEATHER', + severity: 8, + location: 'Western Pacific Shipping Corridor', + epicenterLat: 28.2, + epicenterLng: 143.4, + confidence: 0.86, + affectedZones: ['Western Pacific', 'North Pacific Route'], + description: + 'Super Typhoon approaching Western Pacific, Category 5. Maximum sustained winds 185 km/h. Direct path over major trans-Pacific shipping corridors. 12 vessels currently in projected storm path between Japan and Los Angeles. Port of Yokohama issuing storm warnings.', + }, + suez_closure: { + label: 'Suez Canal Emergency', + type: 'GEOPOLITICAL', + severity: 9, + location: 'Suez Canal / Red Sea', + epicenterLat: 29.9668, + epicenterLng: 32.5498, + confidence: 0.88, + affectedZones: ['Red Sea', 'Gulf of Aden', 'Suez Canal'], + description: + 'The Suez Canal Authority has announced an emergency closure. Houthi missile attacks on Red Sea vessels. Forty-three vessels held. $12B daily trade affected. Minimum 21-day closure expected. All Asia-Europe shipments via southern route ordered to divert via Cape of Good Hope.', + }, + port_strike: { + label: 'Mumbai JNPT Strike', + type: 'STRIKE', + severity: 7, + location: 'North Sea Port Cluster', + epicenterLat: 51.9225, + epicenterLng: 4.4792, + confidence: 0.83, + affectedZones: ['Rotterdam', 'Hamburg', 'Antwerp'], + description: + 'International Transport Workers Federation confirms indefinite strike action at Port of Rotterdam, Hamburg, and Antwerp. All container terminal operations suspended. 80+ vessels at anchor awaiting berth. Estimated 2-week minimum disruption to Europe-bound cargo.', + }, +}; + +function isTimeoutError(error) { + const name = String(error?.name || '').toLowerCase(); + const message = String(error?.message || '').toLowerCase(); + return name.includes('timeout') || name.includes('abort') || message.includes('timeout') || message.includes('aborted'); +} + +async function parseUpstreamBody(response) { + const contentType = String(response.headers.get('content-type') || '').toLowerCase(); + if (contentType.includes('application/json')) { + return response.json().catch(() => null); + } + + const text = await response.text().catch(() => ''); + if (!text) return null; + return { message: text.slice(0, 500) }; +} + +function buildSyntheticDisruption(scenarioMeta) { + return { + id: `disruption-${randomUUID()}`, + type: scenarioMeta.type, + severity: scenarioMeta.severity, + location: scenarioMeta.location, + epicenterLat: scenarioMeta.epicenterLat, + epicenterLng: scenarioMeta.epicenterLng, + confidence: scenarioMeta.confidence, + affectedZones: scenarioMeta.affectedZones, + rawDescription: scenarioMeta.description, + detectedAt: new Date().toISOString(), + source: 'dashboard-demo-fallback', + unverified: false, + corroboratingSources: 1, + }; +} + +async function publishSyntheticDisruption(disruptionEvent, traceId) { + const eventBusUrl = process.env.EVENT_BUS_URL || 'http://localhost:4000'; + const response = await fetch(`${eventBusUrl}/publish`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + topic: 'disruption-events', + payload: { + agentId: 'monitor', + traceId, + timestamp: new Date().toISOString(), + payload: disruptionEvent, + }, + }), + signal: AbortSignal.timeout(EVENT_BUS_TIMEOUT_MS), + }); + + if (!response.ok) { + const msg = await response.text().catch(() => ''); + throw new Error(`Event bus publish failed [${response.status}]${msg ? `: ${msg}` : ''}`); + } +} + +async function injectSyntheticDisruption(scenario, scenarioMeta, reason = 'upstream unavailable') { + const traceId = randomUUID(); + const disruptionEvent = buildSyntheticDisruption(scenarioMeta); + const results = await Promise.allSettled([ + db.collection('disruptions').doc(disruptionEvent.id).set(disruptionEvent, { merge: true }), + publishSyntheticDisruption(disruptionEvent, traceId), + ]); + + const persisted = results[0].status === 'fulfilled'; + const published = results[1].status === 'fulfilled'; + + if (!persisted && !published) { + const persistErr = results[0].reason?.message || 'persist failed'; + const publishErr = results[1].reason?.message || 'publish failed'; + throw new Error(`Synthetic injection failed: ${persistErr}; ${publishErr}`); + } + + return NextResponse.json( + { + ok: true, + synthetic: true, + scenario, + label: scenarioMeta.label, + traceId, + disruptionId: disruptionEvent.id, + persisted, + published, + warning: reason, + }, + { status: 202 } + ); +} + +export async function POST(req) { + try { + const { scenario } = await req.json(); + const scenarioKey = String(scenario || '').trim().toLowerCase(); + const scenarioMeta = SCENARIOS[scenarioKey]; + + if (!scenarioMeta) { + return NextResponse.json( + { error: `Unknown scenario. Available: ${Object.keys(SCENARIOS).join(', ')}` }, + { status: 400 } + ); + } + + if (!DISRUPTION_AGENT_URL) { + return injectSyntheticDisruption( + scenarioKey, + scenarioMeta, + 'DISRUPTION_AGENT_URL is not configured; synthetic fallback used' + ); + } + + const headers = { 'Content-Type': 'application/json' }; + if (process.env.INTERNAL_TOKEN) { + headers.Authorization = `Bearer ${process.env.INTERNAL_TOKEN}`; + } + + try { + const upstream = await fetch(`${DISRUPTION_AGENT_URL}/events`, { + method: 'POST', + headers, + body: JSON.stringify({ description: scenarioMeta.description }), + signal: AbortSignal.timeout(INJECT_TIMEOUT_MS), + }); + + const result = await parseUpstreamBody(upstream); + if (!upstream.ok) { + return injectSyntheticDisruption( + scenarioKey, + scenarioMeta, + `Disruption agent returned ${upstream.status}; synthetic fallback used` + ); + } + + return NextResponse.json({ + ok: true, + synthetic: false, + disruptionId: result?.data?.id || null, + traceId: result?.traceId || null, + scenario: scenarioKey, + label: scenarioMeta.label, + published: result?.published ?? false, + }); + } catch (err) { + if (isTimeoutError(err)) { + return injectSyntheticDisruption( + scenarioKey, + scenarioMeta, + `Disruption agent timed out after ${Math.floor(INJECT_TIMEOUT_MS / 1000)} seconds; synthetic fallback used` + ); + } + return injectSyntheticDisruption( + scenarioKey, + scenarioMeta, + `Disruption agent unavailable (${err.message}); synthetic fallback used` + ); + } + } catch (err) { + return NextResponse.json({ error: err.message || 'Inject failed' }, { status: 500 }); + } +} diff --git a/dashboard/app/api/webhooks/disruption/route.js b/dashboard/app/api/webhooks/disruption/route.js index 43ab6d8..f57a0f8 100644 --- a/dashboard/app/api/webhooks/disruption/route.js +++ b/dashboard/app/api/webhooks/disruption/route.js @@ -1,13 +1,131 @@ import { NextResponse } from 'next/server'; +import { randomUUID } from 'node:crypto'; import { db } from '../../../../lib/firebase-admin.js'; // server-side admin import import { verifyInternalToken } from '../../_internal-auth.js'; +const INJECT_TIMEOUT_MS = 15_000; +const EVENT_BUS_TIMEOUT_MS = 5_000; + const SCENARIO_MAP = { - suez_closure: 'The Suez Canal Authority has announced an emergency closure. Houthi missile attacks on Red Sea vessels. Forty-three vessels held. $12B daily trade affected. Minimum 21-day closure expected. All Asia-Europe shipments via southern route ordered to divert via Cape of Good Hope.', - pacific_storm: 'Super Typhoon approaching Western Pacific, Category 5. Maximum sustained winds 185 km/h. Direct path over major trans-Pacific shipping corridors. 12 vessels currently in projected storm path between Japan and Los Angeles. Port of Yokohama issuing storm warnings.', - port_strike: 'International Transport Workers Federation confirms indefinite strike action at Port of Rotterdam, Hamburg, and Antwerp. All container terminal operations suspended. 80+ vessels at anchor awaiting berth. Estimated 2-week minimum disruption to Europe-bound cargo.', + suez_closure: { + description: 'The Suez Canal Authority has announced an emergency closure. Houthi missile attacks on Red Sea vessels. Forty-three vessels held. $12B daily trade affected. Minimum 21-day closure expected. All Asia-Europe shipments via southern route ordered to divert via Cape of Good Hope.', + type: 'GEOPOLITICAL', + severity: 9, + location: 'Suez Canal / Red Sea', + epicenterLat: 29.9668, + epicenterLng: 32.5498, + affectedZones: ['Red Sea', 'Gulf of Aden', 'Suez Canal'], + confidence: 0.88, + }, + pacific_storm: { + description: 'Super Typhoon approaching Western Pacific, Category 5. Maximum sustained winds 185 km/h. Direct path over major trans-Pacific shipping corridors. 12 vessels currently in projected storm path between Japan and Los Angeles. Port of Yokohama issuing storm warnings.', + type: 'WEATHER', + severity: 8, + location: 'Western Pacific Shipping Corridor', + epicenterLat: 28.2, + epicenterLng: 143.4, + affectedZones: ['Western Pacific', 'North Pacific Route'], + confidence: 0.86, + }, + port_strike: { + description: 'International Transport Workers Federation confirms indefinite strike action at Port of Rotterdam, Hamburg, and Antwerp. All container terminal operations suspended. 80+ vessels at anchor awaiting berth. Estimated 2-week minimum disruption to Europe-bound cargo.', + type: 'STRIKE', + severity: 7, + location: 'North Sea Port Cluster', + epicenterLat: 51.9225, + epicenterLng: 4.4792, + affectedZones: ['Rotterdam', 'Hamburg', 'Antwerp'], + confidence: 0.83, + }, }; +function isTimeoutError(error) { + const name = String(error?.name || '').toLowerCase(); + const message = String(error?.message || '').toLowerCase(); + return name.includes('timeout') || name.includes('abort') || message.includes('timeout') || message.includes('aborted'); +} + +async function parseUpstreamBody(response) { + const contentType = String(response.headers.get('content-type') || '').toLowerCase(); + if (contentType.includes('application/json')) { + return response.json().catch(() => null); + } + + const text = await response.text().catch(() => ''); + if (!text) return null; + return { message: text.slice(0, 500) }; +} + +function buildSyntheticDisruption(scenarioMeta) { + return { + id: `disruption-${randomUUID()}`, + type: scenarioMeta.type, + severity: scenarioMeta.severity, + location: scenarioMeta.location, + epicenterLat: scenarioMeta.epicenterLat, + epicenterLng: scenarioMeta.epicenterLng, + confidence: scenarioMeta.confidence, + affectedZones: scenarioMeta.affectedZones, + rawDescription: scenarioMeta.description, + detectedAt: new Date().toISOString(), + source: 'dashboard-webhook-fallback', + unverified: false, + corroboratingSources: 1, + }; +} + +async function publishSyntheticDisruption(disruptionEvent, traceId) { + const eventBusUrl = process.env.EVENT_BUS_URL || 'http://localhost:4000'; + const response = await fetch(`${eventBusUrl}/publish`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + topic: 'disruption-events', + payload: { + agentId: 'monitor', + traceId, + timestamp: new Date().toISOString(), + payload: disruptionEvent, + }, + }), + signal: AbortSignal.timeout(EVENT_BUS_TIMEOUT_MS), + }); + if (!response.ok) { + const msg = await response.text().catch(() => ''); + throw new Error(`Event bus publish failed [${response.status}]${msg ? `: ${msg}` : ''}`); + } +} + +async function injectSyntheticDisruption(scenario, scenarioMeta, reason) { + const traceId = randomUUID(); + const disruptionEvent = buildSyntheticDisruption(scenarioMeta); + const results = await Promise.allSettled([ + db.collection('disruptions').doc(disruptionEvent.id).set(disruptionEvent, { merge: true }), + publishSyntheticDisruption(disruptionEvent, traceId), + ]); + const persisted = results[0].status === 'fulfilled'; + const published = results[1].status === 'fulfilled'; + if (!persisted && !published) { + const persistErr = results[0].reason?.message || 'persist failed'; + const publishErr = results[1].reason?.message || 'publish failed'; + throw new Error(`Synthetic injection failed: ${persistErr}; ${publishErr}`); + } + + return NextResponse.json( + { + ok: true, + synthetic: true, + scenario, + traceId, + disruptionId: disruptionEvent.id, + persisted, + published, + warning: reason, + }, + { status: 202 } + ); +} + /** * POST /api/webhooks/disruption * Receives pushes from the event bus and writes them to Firestore. @@ -32,24 +150,53 @@ export async function POST(req) { } if (body.scenario) { - const description = SCENARIO_MAP[body.scenario]; - if (!description) { + const scenarioMeta = SCENARIO_MAP[body.scenario]; + if (!scenarioMeta) { return NextResponse.json({ error: `Unknown scenario: ${body.scenario}` }, { status: 400 }); } - const disruptionUrl = process.env.DISRUPTION_AGENT_URL || 'http://localhost:3001'; - const upstream = await fetch(`${disruptionUrl}/events`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(process.env.INTERNAL_TOKEN ? { Authorization: `Bearer ${process.env.INTERNAL_TOKEN}` } : {}), - }, - body: JSON.stringify({ description }), - signal: AbortSignal.timeout(15_000), - }); + try { + const disruptionUrl = process.env.DISRUPTION_AGENT_URL || 'http://localhost:3001'; + const upstream = await fetch(`${disruptionUrl}/events`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(process.env.INTERNAL_TOKEN ? { Authorization: `Bearer ${process.env.INTERNAL_TOKEN}` } : {}), + }, + body: JSON.stringify({ description: scenarioMeta.description }), + signal: AbortSignal.timeout(INJECT_TIMEOUT_MS), + }); - const result = await upstream.json().catch(() => ({})); - return NextResponse.json({ ok: upstream.ok, scenario: body.scenario, ...result }, { status: upstream.status }); + const upstreamBody = await parseUpstreamBody(upstream); + if (!upstream.ok) { + return injectSyntheticDisruption( + body.scenario, + scenarioMeta, + `Disruption agent returned ${upstream.status}; synthetic fallback used` + ); + } + return NextResponse.json( + { + ok: upstream.ok, + scenario: body.scenario, + ...(upstreamBody && typeof upstreamBody === 'object' ? upstreamBody : {}), + }, + { status: upstream.status } + ); + } catch (err) { + if (isTimeoutError(err)) { + return injectSyntheticDisruption( + body.scenario, + scenarioMeta, + `Disruption agent timed out after ${Math.floor(INJECT_TIMEOUT_MS / 1000)} seconds; synthetic fallback used` + ); + } + return injectSyntheticDisruption( + body.scenario, + scenarioMeta, + `Disruption agent unavailable (${err.message}); synthetic fallback used` + ); + } } const { agentId, traceId, timestamp, payload } = body; diff --git a/dashboard/app/components/NavBar.jsx b/dashboard/app/components/NavBar.jsx index 42959b6..dd704a6 100644 --- a/dashboard/app/components/NavBar.jsx +++ b/dashboard/app/components/NavBar.jsx @@ -2,8 +2,8 @@ import Link from 'next/link'; import { usePathname, useRouter } from 'next/navigation'; -import { useEffect } from 'react'; -import { Activity, BarChart3, Globe, Package, RotateCcw, Settings, Moon, Sun, Workflow, AlertCircle, Code2 } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { Activity, BarChart3, Globe, Package, RotateCcw, Settings, Moon, Sun, Workflow, AlertCircle, Code2, Zap } from 'lucide-react'; import { useAlertStore } from '../store/alertStore.js'; import { useTheme } from '../providers/ThemeProvider.jsx'; import { motion } from 'framer-motion'; @@ -12,6 +12,7 @@ const NAV_ITEMS = [ { href: '/', label: 'Globe', icon: Globe, section: 'live' }, { href: '/shipments', label: 'Shipments', icon: Package, section: 'live' }, { href: '/analytics', label: 'Analytics', icon: BarChart3, section: 'live' }, + { href: '/demo', label: 'Demo', icon: Zap, section: 'live' }, { href: '/replay', label: 'Replay', icon: RotateCcw, section: 'analysis' }, { href: '/visualize', label: 'Visualize', icon: Workflow, section: 'analysis' }, { href: '/developers', label: 'API', icon: Code2, section: 'analysis' }, @@ -19,16 +20,24 @@ const NAV_ITEMS = [ ]; function ThemeToggleButton({ theme, onToggle }) { + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const titleText = mounted ? `Switch to ${theme === 'dark' ? 'light' : 'dark'} mode` : 'Toggle theme'; + return ( ); } diff --git a/dashboard/app/demo/page.js b/dashboard/app/demo/page.js new file mode 100644 index 0000000..aa300bb --- /dev/null +++ b/dashboard/app/demo/page.js @@ -0,0 +1,1294 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + collection, + getDocs, + limit, + onSnapshot, + orderBy, + query, + where, +} from 'firebase/firestore'; +import { db, isFirebaseConfigured } from '../lib/firebase.js'; +import NavBar from '../components/NavBar.jsx'; + +const SCENARIOS = [ + { + id: 'pacific_storm', + label: 'Super Typhoon Mawar', + region: 'Western Pacific', + severity: 9, + icon: '🌀', + tag: 'CAT 5 STORM', + tagColor: 'var(--accent-red)', + preview: 'Shanghai → LA corridor at risk. Manila, Kaohsiung, HK vessel advisories active.', + }, + { + id: 'suez_closure', + label: 'Suez Canal Emergency', + region: 'Red Sea / Egypt', + severity: 10, + icon: '⚓', + tag: 'CANAL BLOCKED', + tagColor: 'var(--accent-amber)', + preview: "$12B daily trade halted. 43 vessels held. Lloyd's suspended war-risk coverage.", + }, + { + id: 'port_strike', + label: 'Mumbai JNPT Strike', + region: 'South Asia', + severity: 7, + icon: '🏗', + tag: 'PORT STRIKE', + tagColor: 'var(--accent-blue)', + preview: '4,800 dockworkers AWOL. 5M TEU/yr port offline. Mundra at 85% capacity.', + }, +]; + +const STAGES = [ + { id: 'idle', label: 'Ready', icon: '◎', color: 'var(--text-muted)' }, + { id: 'injected', label: 'Disruption Injected',icon: '⚡', color: 'var(--accent-amber)' }, + { id: 'monitoring',label: 'Monitor Agent', icon: '📡', color: 'var(--accent-blue)' }, + { id: 'impact', label: 'Impact Analysis', icon: '📊', color: 'var(--accent-red)' }, + { id: 'resolution',label: 'AI Resolution', icon: '🤖', color: 'var(--accent-cyan)' }, + { id: 'decision', label: 'Human Decision', icon: '⚖️', color: 'var(--accent-amber)' }, + { id: 'applied', label: 'Protocol Applied', icon: '✓', color: 'var(--accent-green)' }, + { id: 'report', label: 'Report Ready', icon: '📄', color: 'var(--accent-cyan)' }, +]; + +const STAGE_INDEX = Object.fromEntries(STAGES.map((s, i) => [s.id, i])); + +const OPTION_CFG = { + 1: { label: 'Recommended', accent: 'var(--accent-cyan)', border: 'rgba(34,211,238,0.35)' }, + 2: { label: 'Fastest Path', accent: 'var(--accent-blue)', border: 'rgba(59,130,246,0.35)' }, + 3: { label: 'Cost Efficient',accent: 'var(--accent-amber)', border: 'rgba(245,158,11,0.35)' }, +}; + +function fmt$(v) { return `$${Number(v || 0).toLocaleString()}`; } +function fmtD(v) { return v >= 0 ? `+${v}d` : `${v}d`; } +function fmtCO2(v){ return `${Math.round(Number(v || 0) / 1000)}t CO₂`; } + +function Pulse({ color = 'var(--accent-cyan)' }) { + return ( + + ); +} + +function StageNode({ stage, index, currentIndex }) { + const done = index < currentIndex; + const active = index === currentIndex; + const pending = index > currentIndex; + const color = active ? stage.color : done ? 'var(--accent-green)' : 'var(--text-muted)'; + + return ( +
+
+ {done ? 'âś“' : stage.icon} +
+ + {stage.label} + +
+ ); +} + +function PipelineBar({ currentStage }) { + const currentIndex = STAGE_INDEX[currentStage] ?? 0; + const pct = currentIndex > 0 ? ((currentIndex) / (STAGES.length - 1)) * 100 : 0; + + return ( +
+
+
+
+ {STAGES.map((stage, i) => ( +
+ +
+ ))} +
+
+ ); +} + +function LogLine({ line }) { + const colors = { + info: 'var(--accent-cyan)', + success: 'var(--accent-green)', + warn: 'var(--accent-amber)', + error: 'var(--accent-red)', + }; + return ( +
+ {line.ts} + [{line.type.toUpperCase()}] + {line.msg} +
+ ); +} + +function SeverityBar({ value }) { + const pct = (value / 10) * 100; + const color = value >= 8 ? 'var(--accent-red)' : value >= 5 ? 'var(--accent-amber)' : 'var(--accent-green)'; + return ( +
+
+
+
+ {value}/10 +
+ ); +} + +function Chip({ label, value, accent }) { + return ( +
+ + {label} + + {value} +
+ ); +} + +function OptionCardInline({ option, onApprove, isApproving, approvedRank }) { + const cfg = OPTION_CFG[option.rank] || OPTION_CFG[3]; + const disabled = isApproving || approvedRank !== null; + const selected = option.rank === approvedRank; + + return ( +
+
+ + {cfg.label} + + + Option {option.rank} + +
+ +
+
+ {option.title || option.name || `Resolution Strategy ${option.rank}`} +
+
+ {option.description || option.summary || '—'} +
+
+ +
+ + + +
+ + {option.confidence != null && ( +
+
+ AI Confidence +
+
+
+
+
+ {option.confidence}% +
+
+ )} + + {selected ? ( +
+ âś“ Protocol Deployed +
+ ) : ( + + )} +
+ ); +} + +export default function DemoPage() { + const [selectedScenario, setSelectedScenario] = useState(null); + const [stage, setStage] = useState('idle'); + const [disruptionId, setDisruptionId] = useState(null); + const [traceId, setTraceId] = useState(null); + const [disruption, setDisruption] = useState(null); + const [impactReport, setImpactReport] = useState(null); + const [resolution, setResolution] = useState(null); + const [options, setOptions] = useState([]); + const [approvedRank, setApprovedRank] = useState(null); + const [isApproving, setIsApproving] = useState(false); + const [isGeneratingReport, setIsGeneratingReport] = useState(false); + const [reportReady, setReportReady] = useState(false); + const [reportData, setReportData] = useState(null); + const [logs, setLogs] = useState([]); + const [launching, setLaunching] = useState(false); + const [error, setError] = useState(null); + const unsubsRef = useRef([]); + const logEndRef = useRef(null); + + const log = useCallback((msg, type = 'info') => { + const ts = new Date().toLocaleTimeString('en-US', { hour12: false }); + setLogs((prev) => [...prev.slice(-60), { ts, msg, type }]); + }, []); + + useEffect(() => { + logEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [logs]); + + useEffect(() => { + return () => unsubsRef.current.forEach((u) => u?.()); + }, []); + + const watchResolutionRef = useRef(null); + const watchImpactRef = useRef(null); + + const watchResolution = useCallback( + (dId) => { + if (!isFirebaseConfigured || !db) return; + log('Impact complete — AI resolution agent active…', 'info'); + + const q = query( + collection(db, 'resolutions'), + where('disruptionId', '==', dId), + orderBy('createdAt', 'desc'), + limit(1) + ); + + const unsub = onSnapshot(q, async (snap) => { + if (snap.empty) return; + + const doc = snap.docs[0]; + const res = { id: doc.id, ...doc.data() }; + + try { + const optSnap = await getDocs( + query(collection(db, 'resolutions', doc.id, 'options'), orderBy('rank')) + ); + const opts = optSnap.docs.map((d) => ({ id: d.id, ...d.data() })); + + if (opts.length > 0) { + setResolution(res); + setOptions(opts); + setStage('resolution'); + log(`✓ AI generated ${opts.length} resolution strategies`, 'success'); + setTimeout(() => setStage('decision'), 1200); + } + } catch (err) { + log(`Options fetch error: ${err.message}`, 'warn'); + } + }); + + unsubsRef.current.push(unsub); + }, + [log] + ); + + const watchImpact = useCallback( + (dId) => { + if (!isFirebaseConfigured || !db) return; + log('Monitor agent processing… watching impact_reports…', 'info'); + + const q = query( + collection(db, 'impact_reports'), + where('disruptionId', '==', dId), + orderBy('createdAt', 'desc'), + limit(1) + ); + + const unsub = onSnapshot(q, (snap) => { + if (!snap.empty) { + const data = snap.docs[0].data(); + setImpactReport(data); + setStage('impact'); + log( + `✓ Impact scored: $${Number(data.totalCargoAtRiskUSD || 0).toLocaleString()} at risk across ${(data.affectedShipments || []).length} shipments`, + 'success' + ); + if (watchResolutionRef.current) watchResolutionRef.current(dId); + } + }); + + unsubsRef.current.push(unsub); + }, + [log] + ); + + useEffect(() => { + watchResolutionRef.current = watchResolution; + watchImpactRef.current = watchImpact; + }, [watchResolution, watchImpact]); + + const watchDisruption = useCallback( + (dId) => { + if (!isFirebaseConfigured || !db) { + log('Firebase not configured — polling REST fallback', 'warn'); + return; + } + + log(`Listening for disruption ${dId} in Firestore…`, 'info'); + + const q = query( + collection(db, 'disruptions'), + where('__name__', '==', dId), + limit(1) + ); + + const unsub = onSnapshot(q, (snap) => { + if (!snap.empty) { + const data = { id: snap.docs[0].id, ...snap.docs[0].data() }; + setDisruption(data); + setStage('monitoring'); + log(`✓ Disruption confirmed: ${data.title || data.type || dId}`, 'success'); + if (watchImpactRef.current) watchImpactRef.current(dId); + } + }); + + unsubsRef.current.push(unsub); + }, + [log] + ); + + const handleLaunch = async () => { + if (!selectedScenario) return; + setError(null); + setLaunching(true); + setLogs([]); + setDisruption(null); + setImpactReport(null); + setResolution(null); + setOptions([]); + setApprovedRank(null); + setReportReady(false); + setReportData(null); + unsubsRef.current.forEach((u) => u?.()); + unsubsRef.current = []; + + log(`Injecting scenario: ${selectedScenario}…`, 'info'); + setStage('injected'); + + try { + const res = await fetch('/api/demo/inject', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ scenario: selectedScenario }), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || `HTTP ${res.status}`); + } + + const dId = data.disruptionId; + const tId = data.traceId; + setDisruptionId(dId); + setTraceId(tId); + + log(`✓ Disruption published — ID: ${dId}`, 'success'); + log(` TraceId: ${tId}`, 'info'); + log('Event bus fan-out complete. Agent pipeline starting…', 'info'); + + watchDisruption(dId); + } catch (err) { + setError(err.message); + setStage('idle'); + log(`✗ Injection failed: ${err.message}`, 'error'); + } finally { + setLaunching(false); + } + }; + + const handleApprove = async (rank) => { + if (!resolution || !options.length) return; + setIsApproving(true); + setStage('applied'); + + const selected = options.find((o) => o.rank === rank); + log(`Deploying protocol: Option ${rank} — ${selected?.title || ''}`, 'info'); + + try { + const res = await fetch('/api/execute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + resolutionId: resolution.id, + selectedOptionRank: rank, + disruptionId, + }), + }); + + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + log(`Execute warning (${res.status}): ${data.error || 'Non-fatal'}`, 'warn'); + } else { + log(`✓ Protocol ${rank} deployed — shipments rerouting`, 'success'); + } + } catch (err) { + log(`Execute error: ${err.message}`, 'warn'); + } + + setApprovedRank(rank); + setIsApproving(false); + log('Generating executive incident report…', 'info'); + + setIsGeneratingReport(true); + try { + const reportRes = await fetch('/api/generate-report', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + disruption, + resolution, + options, + impactReport, + }), + }); + const payload = await reportRes.json(); + if (payload.report) { + setReportData(payload.report); + setReportReady(true); + setStage('report'); + log('✓ Executive report ready — click Download to save PDF', 'success'); + } else { + log(`Report gen warning: ${payload.error || 'No report text'}`, 'warn'); + setStage('report'); + } + } catch (err) { + log(`Report error: ${err.message}`, 'warn'); + setStage('report'); + } finally { + setIsGeneratingReport(false); + } + }; + + const handleDownload = async () => { + if (!reportData) return; + try { + const { generateReportPdf } = await import('../lib/generateReportPdf.js'); + const doc = generateReportPdf({ reportText: reportData, disruption, traceId }); + doc.save(`opentrade-incident-${traceId || Date.now()}.pdf`); + log('✓ PDF downloaded', 'success'); + } catch (err) { + log(`PDF error: ${err.message}`, 'error'); + } + }; + + const handleReset = () => { + unsubsRef.current.forEach((u) => u?.()); + unsubsRef.current = []; + setStage('idle'); + setSelectedScenario(null); + setDisruptionId(null); + setTraceId(null); + setDisruption(null); + setImpactReport(null); + setResolution(null); + setOptions([]); + setApprovedRank(null); + setReportReady(false); + setReportData(null); + setLogs([]); + setError(null); + }; + + const currentStageIndex = STAGE_INDEX[stage] ?? 0; + const activeScenario = SCENARIOS.find((s) => s.id === selectedScenario); + + return ( + <> + + +
+ + +
+
+ +
+
+ + Live Pipeline Demo + + {stage !== 'idle' && stage !== 'injected' && } +
+

+ OpenTrade Command Demo +

+

+ End-to-end pipeline: Disruption Injection → AI resolution → Human Decision → Protocol Deployment → PDF Report +

+
+ + + + {error && ( +
+ ⚠ + Error: {error} — Check that all agents are running and env vars are set. +
+ )} + +
+ +
+ + {stage === 'idle' && ( +
+
+
+ Choose Scenario +
+
+ {SCENARIOS.map((sc) => ( + + ))} +
+
+ + +
+ )} + + {stage === 'injected' && ( +
+ +
+ Disruption published to Event Bus +
+
+ Waiting for Monitor Agent to confirm detection… +
+
+
+
+
+
+ )} + + {stage === 'monitoring' && disruption && ( +
+
+ +
+ Monitor Agent — Disruption Confirmed +
+
+
+ {disruption.title || activeScenario?.label || 'Disruption Detected'} +
+
+ {disruption.summary || disruption.description || '—'} +
+ {disruption.severity != null && ( +
+
+ Severity Score +
+ +
+ )} +
+ Running impact analysis across all active shipments… +
+
+
+
+
+
+ )} + + {stage === 'impact' && impactReport && ( +
+
+
+ +
+ Impact Analysis Complete +
+
+
+ + + +
+
+ Resolution agent generating strategic options… +
+
+
+
+
+
+
+ )} + + {(stage === 'resolution' || stage === 'decision' || stage === 'applied' || stage === 'report') && options.length > 0 && ( +
+
+ {stage === 'decision' && } + {stage === 'resolution' && } +
+ {stage === 'report' + ? '✓ Protocol Applied — Report Ready' + : stage === 'applied' + ? '⚡ Executing Protocol…' + : stage === 'decision' + ? 'Human Decision Required' + : 'AI Generated 3 Resolution Strategies'} +
+
+ + {impactReport && ( +
+ + +
+ )} + +
+ {options.map((opt) => ( + + ))} +
+ + {stage === 'decision' && approvedRank === null && ( +
+ ↑ Select a protocol to deploy. Keyboard shortcuts: press{' '} + + 1 + {' '} + + 2 + {' '} + + 3 + +
+ )} +
+ )} + + {stage === 'report' && ( +
+
+
+ âś“ Pipeline Complete +
+
+ Executive incident report generated. Download the PDF or run another scenario. +
+
+
+ {reportReady && ( + + )} + +
+
+ )} +
+ +
+ + {(disruptionId || stage !== 'idle') && ( +
+
+ Session +
+ {activeScenario && ( +
+
Scenario
+
+ {activeScenario.icon} {activeScenario.label} +
+
+ )} + {disruptionId && ( +
+
Disruption ID
+
+ {disruptionId} +
+
+ )} + {traceId && ( +
+
Trace ID
+
+ {traceId} +
+
+ )} +
+
Stage
+
+ {STAGES[currentStageIndex]?.icon} {STAGES[currentStageIndex]?.label} +
+
+
+ )} + +
+
+ + Agent Console + + {stage !== 'idle' && stage !== 'report' && } +
+
+ {logs.length === 0 ? ( +
+ Awaiting launch… +
+ ) : ( + logs.map((line, i) => ) + )} +
+
+
+ + {stage !== 'idle' && stage !== 'report' && ( + + )} +
+
+
+
+
+ + ); +} \ No newline at end of file diff --git a/dashboard/app/layout.js b/dashboard/app/layout.js index 97edca0..e86960f 100644 --- a/dashboard/app/layout.js +++ b/dashboard/app/layout.js @@ -39,26 +39,7 @@ export default function RootLayout({ children }) { return ( -