diff --git a/.env.example b/.env.example index 99674ac..affa9a8 100644 --- a/.env.example +++ b/.env.example @@ -79,8 +79,11 @@ VAPID_PUBLIC_KEY= VAPID_PRIVATE_KEY= NEXT_PUBLIC_VAPID_PUBLIC_KEY= -# ---- Email Digest (Resend) ---- -RESEND_API_KEY= + +# ---- Email Digest (Brevo) ---- +BREVO_API_KEY= +BREVO_SENDER_EMAIL= +BREVO_SENDER_NAME= DIGEST_EMAIL= # ---- App URL (for email links) ---- diff --git a/dashboard/app/api/demo/inject/route.js b/dashboard/app/api/demo/inject/route.js new file mode 100644 index 0000000..8aefbd8 --- /dev/null +++ b/dashboard/app/api/demo/inject/route.js @@ -0,0 +1,245 @@ +import { NextResponse } from 'next/server'; +import { randomUUID } from 'node:crypto'; +import { db } from '../../../../lib/firebase-admin.js'; +import { createClient } from '@supabase/supabase-js'; + +const _supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL || ''; +const _supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY || ''; +const supabaseAdmin = _supabaseUrl && _supabaseKey ? createClient(_supabaseUrl, _supabaseKey) : null; + +async function writeDisruptionToSupabase(disruptionEvent) { + if (!supabaseAdmin) return; + + const { error } = await supabaseAdmin.from('disruptions').upsert({ + id: disruptionEvent.id, + trace_id: disruptionEvent.id, + type: disruptionEvent.type, + severity: disruptionEvent.severity, + location: disruptionEvent.location, + epicenter_lat: disruptionEvent.epicenterLat, + epicenter_lng: disruptionEvent.epicenterLng, + affected_zones: disruptionEvent.affectedZones || [], + confidence: disruptionEvent.confidence, + raw_description: disruptionEvent.rawDescription || disruptionEvent.description || '', + published: true, + resolved: false, + detected_at: disruptionEvent.detectedAt || new Date().toISOString(), + }, { onConflict: 'id' }); + if (error) throw new Error(`Supabase disruption write failed: ${error.message}`); +} + +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), + writeDisruptionToSupabase(disruptionEvent), + ]); + + const persisted = results[0].status === 'fulfilled' || results[2].status === 'fulfilled'; + const published = results[1].status === 'fulfilled'; + if (results[2].status === 'rejected') { + console.warn('[InjectRoute] Supabase disruption write failed:', results[2].reason?.message); + } + + 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..1e8c2e3 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,23 +12,25 @@ 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' }, { href: '/health', label: 'System', icon: Activity, section: 'analysis' }, ]; -function ThemeToggleButton({ theme, onToggle }) { +function ThemeToggleButton({ onToggle }) { return ( ); } @@ -36,7 +38,7 @@ function ThemeToggleButton({ theme, onToggle }) { export default function NavBar() { const pathname = usePathname(); const router = useRouter(); - const { theme, toggleTheme } = useTheme(); + const { toggleTheme } = useTheme(); const isGlobePage = pathname === '/'; const hasActiveDisruption = useAlertStore((s) => Boolean(s.activeDisruptionId)); @@ -122,7 +124,7 @@ export default function NavBar() { {/* Right actions */}
{!isGlobePage && ( - + )} s.shipments); const handleFilter = (filter) => { @@ -54,17 +56,9 @@ export default function GlobeControls({ onFilterChange, showSimulationControls = return () => window.removeEventListener('keydown', handleKeyDown); }, [onFilterChange]); - async function injectScenario(name) { - setInjecting(name); - try { - await fetch('/api/webhooks/disruption', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ scenario: name.toLowerCase().replace(/ /g, '_') }), - }); - } finally { - setInjecting(null); - } + function injectScenario(name) { + const scenarioId = name.toLowerCase().replace(/ /g, '_'); + router.push(`/demo?scenario=${scenarioId}`); } return ( @@ -118,11 +112,7 @@ export default function GlobeControls({ onFilterChange, showSimulationControls = className="text-[11px] font-bold uppercase tracking-wider px-3 py-2 rounded-xl border border-[var(--border-subtle)] bg-[var(--bg-elevated)]/50 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--accent-cyan)]/40 transition-all flex items-center justify-between group" > {name} - {injecting === name ? ( -
- ) : ( -
- )} +
))}
diff --git a/dashboard/app/demo/page.js b/dashboard/app/demo/page.js new file mode 100644 index 0000000..82781d9 --- /dev/null +++ b/dashboard/app/demo/page.js @@ -0,0 +1,1258 @@ +'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 { watchDisruptionSupabase, watchImpactSupabase, watchResolutionSupabase } from '../lib/supabaseWatcher.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(() => { + if (typeof window === 'undefined') return null; + const scenarioParam = new URLSearchParams(window.location.search).get('scenario'); + const match = SCENARIOS.find((s) => s.id === scenarioParam); + return match ? match.id : 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) => { + log('Impact complete — AI resolution agent active…', 'info'); + const unsub = watchResolutionSupabase(dId, ({ resolution: res, options: opts }) => { + setResolution(res); + setOptions(opts); + setStage('resolution'); + log(`✓ AI generated ${opts.length} resolution strategies`, 'success'); + setTimeout(() => setStage('decision'), 1200); + }); + unsubsRef.current.push(unsub); + }, + [log] + ); + + const watchImpact = useCallback( + (dId) => { + log('Monitor agent processing… watching impact_reports…', 'info'); + const unsub = watchImpactSupabase(dId, (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) => { + log(`Listening for disruption ${dId} in Supabase…`, 'info'); + const unsub = watchDisruptionSupabase(dId, (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 = useCallback(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); + } + }, [selectedScenario, log, watchDisruption]); + + // Auto-launch once scenario is pre-selected from URL + useEffect(() => { + const scenarioParam = typeof window === 'undefined' + ? null + : new URLSearchParams(window.location.search).get('scenario'); + if (!scenarioParam || !selectedScenario) return; + if (selectedScenario === scenarioParam) { + // Small delay to let UI render before launch + const t = setTimeout(() => handleLaunch(), 300); + return () => clearTimeout(t); + } + }, [selectedScenario, handleLaunch]); + + 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({ + traceId: resolution.id, + 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/globals.css b/dashboard/app/globals.css index d03ee9b..1515af6 100644 --- a/dashboard/app/globals.css +++ b/dashboard/app/globals.css @@ -199,6 +199,22 @@ body { } @layer utilities { + .theme-toggle-light-icon { + display: none; + } + + .theme-toggle-dark-icon { + display: inline-block; + } + + [data-theme="light"] .theme-toggle-light-icon { + display: inline-block; + } + + [data-theme="light"] .theme-toggle-dark-icon { + display: none; + } + /* Pure Glass Panel */ .glass-panel { background: var(--glass-bg); 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 ( -