From 8717f8fd15231e4a79f84f75428d7c7c7bf2cad3 Mon Sep 17 00:00:00 2001 From: Saman Pandey Date: Tue, 28 Apr 2026 18:28:59 +0530 Subject: [PATCH 01/19] feat: add demo page --- dashboard/app/api/demo/inject/route.js | 73 ++ dashboard/app/components/NavBar.jsx | 3 +- dashboard/app/demo/page.js | 1291 ++++++++++++++++++++++++ 3 files changed, 1366 insertions(+), 1 deletion(-) create mode 100644 dashboard/app/api/demo/inject/route.js create mode 100644 dashboard/app/demo/page.js diff --git a/dashboard/app/api/demo/inject/route.js b/dashboard/app/api/demo/inject/route.js new file mode 100644 index 0000000..53da985 --- /dev/null +++ b/dashboard/app/api/demo/inject/route.js @@ -0,0 +1,73 @@ +import { NextResponse } from 'next/server'; + +const DISRUPTION_AGENT_URL = + process.env.DISRUPTION_AGENT_URL || + process.env.NEXT_PUBLIC_DISRUPTION_AGENT_URL || + 'http://localhost:3001'; + +const SCENARIOS = { + pacific_storm: { + label: 'Super Typhoon Mawar', + description: + 'Super Typhoon Mawar has intensified to Category 5 with sustained winds of 265 km/h and is currently tracking northwest across the Western Pacific Ocean at coordinates 15.2°N 128.5°E. The typhoon is expected to impact shipping lanes between the Philippines and Japan, affecting the primary Shanghai to Los Angeles trade route. Storm surge warnings have been issued for Taiwan Strait, Philippines Sea, and South China Sea. Port authorities in Manila, Kaohsiung, and Hong Kong have issued vessel advisories.', + }, + suez_closure: { + label: 'Suez Canal Emergency', + description: + "The Suez Canal Authority has announced an emergency closure of the Suez Canal effective 0000 UTC following a series of Houthi missile attacks on vessels in the Red Sea. The Egyptian government has declared a maritime emergency zone covering the Red Sea (15°N to 30°N) and Gulf of Aden. Forty-three vessels currently in the canal are being held pending security assessment. Lloyd's of London has suspended war risk coverage for the corridor. An estimated $12 billion in daily trade is affected. The closure is expected to last a minimum of 21 days.", + }, + port_strike: { + label: 'Mumbai JNPT Strike', + description: + "Workers at Jawaharlal Nehru Port Trust (JNPT) in Mumbai, India have initiated an indefinite strike effective immediately at 06:00 IST. The dockworkers union MSWU representing 4,800 workers is demanding a 40% wage increase following failed negotiations. JNPT handles over 5 million TEUs annually and is India's largest container port. All loading and unloading operations are suspended. Alternative ports Mundra and Nhava Sheva are already operating at 85% capacity.", + }, +}; + +export async function POST(req) { + try { + const { scenario } = await req.json(); + + if (!scenario || !SCENARIOS[scenario]) { + return NextResponse.json( + { error: `Unknown scenario. Available: ${Object.keys(SCENARIOS).join(', ')}` }, + { status: 400 } + ); + } + + const { description } = SCENARIOS[scenario]; + + const headers = { 'Content-Type': 'application/json' }; + if (process.env.INTERNAL_TOKEN) { + headers.Authorization = `Bearer ${process.env.INTERNAL_TOKEN}`; + } + + const upstream = await fetch(`${DISRUPTION_AGENT_URL}/events`, { + method: 'POST', + headers, + body: JSON.stringify({ description }), + }); + + const result = await upstream.json().catch(() => ({ error: 'Invalid response from disruption agent' })); + + if (!upstream.ok) { + return NextResponse.json( + { error: result.error || `Disruption agent returned ${upstream.status}` }, + { status: upstream.status } + ); + } + + return NextResponse.json({ + ok: true, + disruptionId: result.data?.id, + traceId: result.traceId, + scenario, + label: SCENARIOS[scenario].label, + published: result.published ?? false, + }); + } catch (err) { + return NextResponse.json( + { error: err.message || 'Inject failed — is the disruption agent running?' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/dashboard/app/components/NavBar.jsx b/dashboard/app/components/NavBar.jsx index 42959b6..cd1af55 100644 --- a/dashboard/app/components/NavBar.jsx +++ b/dashboard/app/components/NavBar.jsx @@ -3,7 +3,7 @@ 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 { 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' }, diff --git a/dashboard/app/demo/page.js b/dashboard/app/demo/page.js new file mode 100644 index 0000000..c8f552d --- /dev/null +++ b/dashboard/app/demo/page.js @@ -0,0 +1,1291 @@ +'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 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'); + watchImpact(dId); + } + }); + + 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' + ); + watchResolution(dId); + } + }); + + unsubsRef.current.push(unsub); + }, + [log] + ); + + 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 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 From e5ce5bd78ef470d23b934521e27787275bc55e51 Mon Sep 17 00:00:00 2001 From: Saman Pandey Date: Tue, 28 Apr 2026 18:47:42 +0530 Subject: [PATCH 02/19] fix: fixed all the build errors --- dashboard/app/demo/page.js | 100 ++++++++++++++++++++----------------- 1 file changed, 53 insertions(+), 47 deletions(-) diff --git a/dashboard/app/demo/page.js b/dashboard/app/demo/page.js index c8f552d..2c2ac0e 100644 --- a/dashboard/app/demo/page.js +++ b/dashboard/app/demo/page.js @@ -10,8 +10,8 @@ import { query, where, } from 'firebase/firestore'; -import { db, isFirebaseConfigured } from '../../lib/firebase.js'; -import NavBar from '../../components/NavBar.jsx'; +import { db, isFirebaseConfigured } from '../lib/firebase.js'; +import NavBar from '../components/NavBar.jsx'; const SCENARIOS = [ { @@ -403,28 +403,42 @@ export default function DemoPage() { return () => unsubsRef.current.forEach((u) => u?.()); }, []); - const watchDisruption = useCallback( - (dId) => { - if (!isFirebaseConfigured || !db) { - log('Firebase not configured — polling REST fallback', 'warn'); - return; - } + const watchResolutionRef = useRef(null); + const watchImpactRef = useRef(null); - log(`Listening for disruption ${dId} in Firestore…`, 'info'); + const watchResolution = useCallback( + (dId) => { + if (!isFirebaseConfigured || !db) return; + log('Impact complete — AI resolution agent active…', 'info'); const q = query( - collection(db, 'disruptions'), - where('__name__', '==', dId), + collection(db, 'resolutions'), + where('disruptionId', '==', dId), + orderBy('createdAt', 'desc'), 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'); - watchImpact(dId); + 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'); } }); @@ -454,7 +468,7 @@ export default function DemoPage() { `✓ Impact scored: $${Number(data.totalCargoAtRiskUSD || 0).toLocaleString()} at risk across ${(data.affectedShipments || []).length} shipments`, 'success' ); - watchResolution(dId); + if (watchResolutionRef.current) watchResolutionRef.current(dId); } }); @@ -463,39 +477,28 @@ export default function DemoPage() { [log] ); - const watchResolution = useCallback( + const watchDisruption = useCallback( (dId) => { - if (!isFirebaseConfigured || !db) return; - log('Impact complete — AI resolution agent active…', 'info'); + 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, 'resolutions'), - where('disruptionId', '==', dId), - orderBy('createdAt', 'desc'), + collection(db, 'disruptions'), + where('__name__', '==', dId), 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'); + 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); } }); @@ -504,6 +507,9 @@ export default function DemoPage() { [log] ); + watchResolutionRef.current = watchResolution; + watchImpactRef.current = watchImpact; + const handleLaunch = async () => { if (!selectedScenario) return; setError(null); @@ -621,7 +627,7 @@ export default function DemoPage() { const handleDownload = async () => { if (!reportData) return; try { - const { generateReportPdf } = await import('../../lib/generateReportPdf.js'); + 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'); From 8bfc089df96cc8b9c93b540715fe07c7c8d53097 Mon Sep 17 00:00:00 2001 From: Saman Pandey Date: Tue, 28 Apr 2026 18:54:37 +0530 Subject: [PATCH 03/19] Fix: lint errors in demo fixed --- dashboard/app/demo/page.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/dashboard/app/demo/page.js b/dashboard/app/demo/page.js index 2c2ac0e..eb1ce66 100644 --- a/dashboard/app/demo/page.js +++ b/dashboard/app/demo/page.js @@ -406,6 +406,11 @@ export default function DemoPage() { const watchResolutionRef = useRef(null); const watchImpactRef = useRef(null); + useEffect(() => { + watchResolutionRef.current = watchResolution; + watchImpactRef.current = watchImpact; + }, [watchResolution, watchImpact]); + const watchResolution = useCallback( (dId) => { if (!isFirebaseConfigured || !db) return; @@ -507,9 +512,6 @@ export default function DemoPage() { [log] ); - watchResolutionRef.current = watchResolution; - watchImpactRef.current = watchImpact; - const handleLaunch = async () => { if (!selectedScenario) return; setError(null); From 19dfbded2af051257d2124f7ced0856c8a3aef85 Mon Sep 17 00:00:00 2001 From: Saman Pandey Date: Tue, 28 Apr 2026 19:04:58 +0530 Subject: [PATCH 04/19] fix: lint errors and page load issues --- dashboard/app/demo/page.js | 10 +- package-lock.json | 194 +++++++++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 5 deletions(-) diff --git a/dashboard/app/demo/page.js b/dashboard/app/demo/page.js index eb1ce66..1b303f4 100644 --- a/dashboard/app/demo/page.js +++ b/dashboard/app/demo/page.js @@ -406,11 +406,6 @@ export default function DemoPage() { const watchResolutionRef = useRef(null); const watchImpactRef = useRef(null); - useEffect(() => { - watchResolutionRef.current = watchResolution; - watchImpactRef.current = watchImpact; - }, [watchResolution, watchImpact]); - const watchResolution = useCallback( (dId) => { if (!isFirebaseConfigured || !db) return; @@ -482,6 +477,11 @@ export default function DemoPage() { [log] ); + useEffect(() => { + watchResolutionRef.current = watchResolution; + watchImpactRef.current = watchImpact; + }, [watchResolution, watchImpact]); + const watchDisruption = useCallback( (dId) => { if (!isFirebaseConfigured || !db) { diff --git a/package-lock.json b/package-lock.json index cae095e..18169c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "firebase": "^10.12.0", "firebase-admin": "^12.0.0", "framer-motion": "^12.38.0", + "jspdf": "^4.2.1", "leaflet": "^1.9.4", "lucide-react": "^1.8.0", "next": "16.2.2", @@ -6318,6 +6319,12 @@ "@types/node": "*" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, "node_modules/@types/pg": { "version": "8.15.5", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.5.tgz", @@ -6338,6 +6345,13 @@ "@types/pg": "*" } }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -7681,6 +7695,16 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -7898,6 +7922,26 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -8223,6 +8267,18 @@ "webpack": "^5.1.0" } }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/crc-32": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", @@ -8250,6 +8306,16 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -9485,6 +9551,17 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fast-querystring": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", @@ -9630,6 +9707,12 @@ "node": ">=0.8.0" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -10397,6 +10480,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/http_ece": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", @@ -10560,6 +10657,12 @@ "node": ">=12" } }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/ipaddr.js": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", @@ -11274,6 +11377,23 @@ "node": ">=10" } }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -13498,6 +13618,13 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/pg-int8": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", @@ -13811,6 +13938,16 @@ "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", "license": "ISC" }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/rbush": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz", @@ -14017,6 +14154,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -14229,6 +14373,16 @@ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "license": "MIT" }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -14824,6 +14978,16 @@ "dev": true, "license": "MIT" }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -15135,6 +15299,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/tailwind-merge": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", @@ -15262,6 +15436,16 @@ } } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/thread-stream": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", @@ -15819,6 +16003,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", From f86a9aa0ff043208dff5b27860179bad86e3f040 Mon Sep 17 00:00:00 2001 From: Saman Pandey Date: Tue, 28 Apr 2026 19:16:31 +0530 Subject: [PATCH 05/19] fix: demo page layout --- dashboard/app/demo/page.js | 125 ++++++++++++++++++------------------- 1 file changed, 60 insertions(+), 65 deletions(-) diff --git a/dashboard/app/demo/page.js b/dashboard/app/demo/page.js index 1b303f4..a01ea54 100644 --- a/dashboard/app/demo/page.js +++ b/dashboard/app/demo/page.js @@ -683,78 +683,72 @@ export default function DemoPage() { } `} - +
+ -
-
+
+
-
-
- +
+ + Live Pipeline Demo + + {stage !== 'idle' && stage !== 'injected' && } +
+

- Live Pipeline Demo - - {stage !== 'idle' && stage !== 'injected' && } + OpenTrade Command Demo +

+

+ End-to-end pipeline: disruption injection → AI resolution → human decision → protocol deployment → PDF report +

-

- 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. -
- )} + -
+ {error && ( +
+ + Error: {error} — Check that all agents are running and env vars are set. +
+ )} + +
-
+
{stage === 'idle' && (
@@ -1165,7 +1159,7 @@ export default function DemoPage() { )}
-
+
{(disruptionId || stage !== 'idle') && (
)}
+
-
-
+
+
); } \ No newline at end of file From 2ee6cff00b2d12f7bd60af226b2ed3698276c0d8 Mon Sep 17 00:00:00 2001 From: Saman Pandey Date: Tue, 28 Apr 2026 19:22:35 +0530 Subject: [PATCH 06/19] fix: added supabase dependency to news-intel --- news-intel/package-lock.json | 132 +++++++++++++++++++++++++++++++++++ news-intel/package.json | 1 + 2 files changed, 133 insertions(+) diff --git a/news-intel/package-lock.json b/news-intel/package-lock.json index 15a2f63..c49193a 100644 --- a/news-intel/package-lock.json +++ b/news-intel/package-lock.json @@ -11,6 +11,7 @@ "@fastify/cors": "^11.2.0", "@opentelemetry/auto-instrumentations-node": "^0.73.0", "@opentelemetry/sdk-node": "^0.215.0", + "@supabase/supabase-js": "^2.45.0", "cheerio": "^1.0.0", "csv-parse": "^5.5.5", "dotenv": "^16.4.5", @@ -1630,6 +1631,92 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "license": "BSD-3-Clause" }, + "node_modules/@supabase/auth-js": { + "version": "2.105.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.105.1.tgz", + "integrity": "sha512-zc4s8Xg4truwE1Q4Q8M8oUVDARMd05pKh73NyQsMbYU1HDdDN2iiKzena/yu+yJze3WrD4c092FdckPiK1rLQw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.105.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.105.1.tgz", + "integrity": "sha512-dTk1e7oE51VGc1lS2S0J0NLo0Wp4JYChj74ArJKbIWgoWuFwO0wcJYjeyOV3AAEpKst8/LQWUZOUKO1tRXBrpA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.1.tgz", + "integrity": "sha512-hWGJkDAfWUNY8k0C080u3sGNFd2ncl9erhKgP7hnGkgJWEfT5Pd/SXal4QmWXBECVlZrannMAc9sBaaRyWpiUA==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.105.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.105.1.tgz", + "integrity": "sha512-6SbtsoWC55xfsm7gbfLqvF+yIwTQEbjt+jFGf4klDpwSnUy17Hv5x0Dq52oqwTQlw6Ta0h1D5gTP0/pApqNojA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.105.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.105.1.tgz", + "integrity": "sha512-3X3cUEl5cJ4lRQHr1hXHx0b98OaL97RRO2vrRZ98FD91JV/MquZHhrGJSv/+IkOnjF6E2e0RUOxE8P3Zi035ow==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "^0.4.1", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.105.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.105.1.tgz", + "integrity": "sha512-owfdCNH5ikXXDusjzsgU6LavEBqGUoueOnL/9XIucld70/WJ/rbqp89K//c9QPICDNuegsmpoeasydDAiucLKQ==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.105.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.105.1.tgz", + "integrity": "sha512-4gn6HmsAkCCVU7p8JmgKGhHJ5Btod4ZzSp8qKZf4JHaTxbhaIK86/usHzeLxWv7EJJDhBmILDmJOSOf9iF4CLA==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.105.1", + "@supabase/functions-js": "2.105.1", + "@supabase/postgrest-js": "2.105.1", + "@supabase/realtime-js": "2.105.1", + "@supabase/storage-js": "2.105.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@types/aws-lambda": { "version": "8.10.161", "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.161.tgz", @@ -1719,6 +1806,15 @@ "@types/node": "*" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", @@ -2404,6 +2500,15 @@ "node": ">= 14" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -3027,6 +3132,12 @@ "node": ">=12" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/undici": { "version": "7.25.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", @@ -3103,6 +3214,27 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/news-intel/package.json b/news-intel/package.json index 3a13892..3d83dac 100644 --- a/news-intel/package.json +++ b/news-intel/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "@fastify/cors": "^11.2.0", + "@supabase/supabase-js": "^2.45.0", "@opentelemetry/auto-instrumentations-node": "^0.73.0", "@opentelemetry/sdk-node": "^0.215.0", "cheerio": "^1.0.0", From ab5b7fe4701672aaa830148b5291c2ead0c7a9b8 Mon Sep 17 00:00:00 2001 From: Saman Pandey Date: Tue, 28 Apr 2026 19:35:22 +0530 Subject: [PATCH 07/19] fix: all issues on page demo --- dashboard/app/api/demo/inject/route.js | 27 +++++++++++++++++++++++--- dashboard/app/demo/page.js | 2 +- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/dashboard/app/api/demo/inject/route.js b/dashboard/app/api/demo/inject/route.js index 53da985..eae0f40 100644 --- a/dashboard/app/api/demo/inject/route.js +++ b/dashboard/app/api/demo/inject/route.js @@ -1,9 +1,12 @@ import { NextResponse } from 'next/server'; +const configuredDisruptionAgentUrl = + process.env.DISRUPTION_AGENT_URL || + process.env.NEXT_PUBLIC_DISRUPTION_AGENT_URL || + ''; + const DISRUPTION_AGENT_URL = - process.env.DISRUPTION_AGENT_URL || - process.env.NEXT_PUBLIC_DISRUPTION_AGENT_URL || - 'http://localhost:3001'; + configuredDisruptionAgentUrl || (process.env.NODE_ENV === 'development' ? 'http://localhost:3001' : ''); const SCENARIOS = { pacific_storm: { @@ -36,6 +39,16 @@ export async function POST(req) { const { description } = SCENARIOS[scenario]; + if (!DISRUPTION_AGENT_URL) { + return NextResponse.json( + { + error: + 'DISRUPTION_AGENT_URL is not configured for this dashboard deployment. Set DISRUPTION_AGENT_URL or NEXT_PUBLIC_DISRUPTION_AGENT_URL to the running disruption agent URL.', + }, + { status: 503 } + ); + } + const headers = { 'Content-Type': 'application/json' }; if (process.env.INTERNAL_TOKEN) { headers.Authorization = `Bearer ${process.env.INTERNAL_TOKEN}`; @@ -45,6 +58,7 @@ export async function POST(req) { method: 'POST', headers, body: JSON.stringify({ description }), + signal: AbortSignal.timeout(15000), }); const result = await upstream.json().catch(() => ({ error: 'Invalid response from disruption agent' })); @@ -65,6 +79,13 @@ export async function POST(req) { published: result.published ?? false, }); } catch (err) { + if (err?.name === 'TimeoutError' || err?.name === 'AbortError') { + return NextResponse.json( + { error: 'Disruption agent request timed out after 15 seconds' }, + { status: 504 } + ); + } + return NextResponse.json( { error: err.message || 'Inject failed — is the disruption agent running?' }, { status: 500 } diff --git a/dashboard/app/demo/page.js b/dashboard/app/demo/page.js index a01ea54..aa300bb 100644 --- a/dashboard/app/demo/page.js +++ b/dashboard/app/demo/page.js @@ -720,7 +720,7 @@ export default function DemoPage() { OpenTrade Command Demo

- End-to-end pipeline: disruption injection → AI resolution → human decision → protocol deployment → PDF report + End-to-end pipeline: Disruption Injection → AI resolution → Human Decision → Protocol Deployment → PDF Report

From 75f94f40888a57bd4857866ca816fe5e06e9fb67 Mon Sep 17 00:00:00 2001 From: Saman Pandey Date: Wed, 29 Apr 2026 01:03:55 +0530 Subject: [PATCH 08/19] fix: disruptions on demo page --- dashboard/app/api/demo/inject/route.js | 209 ++++- .../app/api/webhooks/disruption/route.js | 181 ++++- dashboard/app/components/NavBar.jsx | 14 +- dashboard/app/layout.js | 21 +- dashboard/next.config.mjs | 6 +- dashboard/public/theme-init.js | 12 + disruption/package-lock.json | 769 ++++++++++++++++++ 7 files changed, 1125 insertions(+), 87 deletions(-) create mode 100644 dashboard/public/theme-init.js diff --git a/dashboard/app/api/demo/inject/route.js b/dashboard/app/api/demo/inject/route.js index eae0f40..e8dfc0c 100644 --- a/dashboard/app/api/demo/inject/route.js +++ b/dashboard/app/api/demo/inject/route.js @@ -1,4 +1,9 @@ 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 || @@ -11,41 +16,151 @@ const DISRUPTION_AGENT_URL = 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 Mawar has intensified to Category 5 with sustained winds of 265 km/h and is currently tracking northwest across the Western Pacific Ocean at coordinates 15.2°N 128.5°E. The typhoon is expected to impact shipping lanes between the Philippines and Japan, affecting the primary Shanghai to Los Angeles trade route. Storm surge warnings have been issued for Taiwan Strait, Philippines Sea, and South China Sea. Port authorities in Manila, Kaohsiung, and Hong Kong have issued vessel advisories.', + '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 of the Suez Canal effective 0000 UTC following a series of Houthi missile attacks on vessels in the Red Sea. The Egyptian government has declared a maritime emergency zone covering the Red Sea (15°N to 30°N) and Gulf of Aden. Forty-three vessels currently in the canal are being held pending security assessment. Lloyd's of London has suspended war risk coverage for the corridor. An estimated $12 billion in daily trade is affected. The closure is expected to last a minimum of 21 days.", + '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: - "Workers at Jawaharlal Nehru Port Trust (JNPT) in Mumbai, India have initiated an indefinite strike effective immediately at 06:00 IST. The dockworkers union MSWU representing 4,800 workers is demanding a 40% wage increase following failed negotiations. JNPT handles over 5 million TEUs annually and is India's largest container port. All loading and unloading operations are suspended. Alternative ports Mundra and Nhava Sheva are already operating at 85% capacity.", + '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 (!scenario || !SCENARIOS[scenario]) { + if (!scenarioMeta) { return NextResponse.json( { error: `Unknown scenario. Available: ${Object.keys(SCENARIOS).join(', ')}` }, { status: 400 } ); } - const { description } = SCENARIOS[scenario]; - if (!DISRUPTION_AGENT_URL) { - return NextResponse.json( - { - error: - 'DISRUPTION_AGENT_URL is not configured for this dashboard deployment. Set DISRUPTION_AGENT_URL or NEXT_PUBLIC_DISRUPTION_AGENT_URL to the running disruption agent URL.', - }, - { status: 503 } + return injectSyntheticDisruption( + scenarioKey, + scenarioMeta, + 'DISRUPTION_AGENT_URL is not configured; synthetic fallback used' ); } @@ -54,41 +169,47 @@ export async function POST(req) { headers.Authorization = `Bearer ${process.env.INTERNAL_TOKEN}`; } - const upstream = await fetch(`${DISRUPTION_AGENT_URL}/events`, { - method: 'POST', - headers, - body: JSON.stringify({ description }), - signal: AbortSignal.timeout(15000), - }); + 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 upstream.json().catch(() => ({ error: 'Invalid response from disruption agent' })); + const result = await parseUpstreamBody(upstream); + if (!upstream.ok) { + return injectSyntheticDisruption( + scenarioKey, + scenarioMeta, + `Disruption agent returned ${upstream.status}; synthetic fallback used` + ); + } - if (!upstream.ok) { - return NextResponse.json( - { error: result.error || `Disruption agent returned ${upstream.status}` }, - { status: upstream.status } + 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` ); } - - return NextResponse.json({ - ok: true, - disruptionId: result.data?.id, - traceId: result.traceId, - scenario, - label: SCENARIOS[scenario].label, - published: result.published ?? false, - }); } catch (err) { - if (err?.name === 'TimeoutError' || err?.name === 'AbortError') { - return NextResponse.json( - { error: 'Disruption agent request timed out after 15 seconds' }, - { status: 504 } - ); - } - - return NextResponse.json( - { error: err.message || 'Inject failed — is the disruption agent running?' }, - { status: 500 } - ); + return NextResponse.json({ error: err.message || 'Inject failed' }, { status: 500 }); } -} \ No newline at end of file +} 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 cd1af55..dd704a6 100644 --- a/dashboard/app/components/NavBar.jsx +++ b/dashboard/app/components/NavBar.jsx @@ -2,7 +2,7 @@ import Link from 'next/link'; import { usePathname, useRouter } from 'next/navigation'; -import { useEffect } from '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'; @@ -20,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/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 ( -