Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions dashboard/app/components/alerts/AlertToast.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useEffect, useRef } from 'react';
import { toast } from 'sonner';
import { useAlertStore } from '../../store/alertStore.js';
import { useDemoInject } from '../../hooks/useDemoInject.js';

const TYPE_ICONS = {
WEATHER: '🌊',
Expand All @@ -20,6 +21,8 @@ export default function AlertToastController() {
const disruptions = useAlertStore((s) => s.disruptions);
const prevLengthRef = useRef(null);
const seenIdsRef = useRef(new Set());
const activeInjectionsRef = useRef(new Set());
const { injectDisruption } = useDemoInject(null);

useEffect(() => {
// Record initial snapshot as baseline so existing disruptions do not trigger toasts.
Expand Down Expand Up @@ -68,7 +71,15 @@ export default function AlertToastController() {
<button
className="mt-2 text-[10px] font-bold uppercase tracking-widest bg-[var(--bg-elevated)] hover:bg-[var(--bg-overlay)] text-[var(--text-primary)] px-3 py-1.5 rounded-xl border border-[var(--border-subtle)] transition-all text-center active:scale-95"
onClick={() => {
useAlertStore.getState().setActiveDisruptionId(id);
// Prevent duplicate injections
if (activeInjectionsRef.current.has(id)) return;
activeInjectionsRef.current.add(id);

// Trigger full injection flow
injectDisruption(id, null);

// Dismiss toast after action
setTimeout(() => toast.dismiss(toastId), 300);
}}
>
Protocol Analysis →
Expand All @@ -89,7 +100,7 @@ export default function AlertToastController() {
);
}
prevLengthRef.current = disruptions.length;
}, [disruptions]);
}, [disruptions, injectDisruption]);

return null;
}
9 changes: 8 additions & 1 deletion dashboard/app/components/decision/DecisionModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,14 @@ export default function DecisionModal() {
if (isApproving || !traceId || approvedRank || isExecuted) return;
setIsApproving(true);
try {
const res = await fetch('/api/execute', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ traceId, rank }) });
// Get disruptionId from activeResolution (set by demo page bridge)
const disruptionId = activeResolution?.disruptionId;

const res = await fetch('/api/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ traceId, rank, disruptionId })
});
const result = await res.json();
if (!res.ok) throw new Error(result.error || `HTTP ${res.status}`);
setApprovedRank(rank);
Expand Down
101 changes: 46 additions & 55 deletions dashboard/app/demo/page.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';
import dynamic from 'next/dynamic';
import {
collection,
getDocs,
Expand All @@ -11,7 +12,8 @@ import {
where,
} from 'firebase/firestore';
import { db, isFirebaseConfigured } from '../lib/firebase.js';
import { watchDisruptionSupabase, watchImpactSupabase, watchResolutionSupabase } from '../lib/supabaseWatcher.js';
import { useAlertStore } from '../store/alertStore.js';
import { useDemoInject } from '../hooks/useDemoInject.js';
import {
Wind,
Anchor,
Expand All @@ -32,6 +34,11 @@ import {
} from 'lucide-react';
import NavBar from '../components/NavBar.jsx';

const DecisionModal = dynamic(
() => import('../components/decision/DecisionModal.jsx'),
{ ssr: false, loading: () => null }
);

const SCENARIOS = [
{
id: 'pacific_storm',
Expand Down Expand Up @@ -417,6 +424,14 @@ export default function DemoPage() {
const unsubsRef = useRef([]);
const logEndRef = useRef(null);

// Extract store actions
const setActiveDisruptionId = useAlertStore((s) => s.setActiveDisruptionId);
const setResolutionWithOptions = useAlertStore((s) => s.setResolutionWithOptions);
const clearActiveDisruption = useAlertStore((s) => s.clearActiveDisruption);

// Injection hook - handles watcher chain
const { injectDisruption } = useDemoInject((msg, type) => log(msg, type));

const log = useCallback((msg, type = 'info') => {
const ts = new Date().toLocaleTimeString('en-US', { hour12: false });
setLogs((prev) => [...prev.slice(-60), { ts, msg, type }]);
Expand All @@ -430,59 +445,15 @@ export default function DemoPage() {
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]
);
// Update local demo page state when stages progress
const handleStageChange = useCallback((newStage) => {
setStage(newStage);
}, []);

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 handleResolutionReady = useCallback((res, opts) => {
setResolution(res);
setOptions(opts);
}, []);

const handleLaunch = useCallback(async () => {
if (!selectedScenario) return;
Expand Down Expand Up @@ -520,19 +491,35 @@ export default function DemoPage() {
setDisruptionId(dId);
setTraceId(tId);

// Bridge into alertStore — activates DecisionModal pipeline
setActiveDisruptionId(dId);

log(`✓ Disruption published — ID: ${dId}`, 'success');
log(` TraceId: ${tId}`, 'info');
log('Event bus fan-out complete. Agent pipeline starting…', 'info');

watchDisruption(dId);
// Inject into alert system - handles full watcher chain
const unsubscribers = injectDisruption(dId, (stage, data) => {
handleStageChange(stage);
// Update local state with data from watchers
if (stage === 'monitoring' && data) {
setDisruption(data);
} else if (stage === 'impact' && data) {
setImpactReport(data);
} else if (stage === 'resolution' && data?.resolution && data?.options) {
setResolution(data.resolution);
setOptions(data.options);
}
});
unsubsRef.current.push(...unsubscribers);
} catch (err) {
setError(err.message);
setStage('idle');
log(`✗ Injection failed: ${err.message}`, 'error');
} finally {
setLaunching(false);
}
}, [selectedScenario, log, watchDisruption]);
}, [selectedScenario, log, injectDisruption, handleStageChange]);

// Auto-launch once scenario is pre-selected from URL
useEffect(() => {
Expand Down Expand Up @@ -626,6 +613,7 @@ export default function DemoPage() {
const handleReset = () => {
unsubsRef.current.forEach((u) => u?.());
unsubsRef.current = [];
clearActiveDisruption();
setStage('idle');
setSelectedScenario(null);
setDisruptionId(null);
Expand Down Expand Up @@ -1300,6 +1288,9 @@ export default function DemoPage() {
</div>
</div>
</main>

<DecisionModal />

</div>
</>
);
Expand Down
95 changes: 95 additions & 0 deletions dashboard/app/hooks/useDemoInject.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
'use client';

import { useCallback } from 'react';
import { watchDisruptionSupabase, watchImpactSupabase, watchResolutionSupabase } from '../lib/supabaseWatcher.js';
import { useAlertStore } from '../store/alertStore.js';

/**
* Hook: useDemoInject
*
* Provides reusable injection logic for triggering demo/alert flows.
* Handles:
* - Starting Supabase watchers
* - Bridging data into alertStore
* - Opening DecisionModal on resolution
*/
export function useDemoInject(onLog) {
const setActiveDisruptionId = useAlertStore((s) => s.setActiveDisruptionId);
const setResolutionWithOptions = useAlertStore((s) => s.setResolutionWithOptions);

/**
* Injects a disruption into the decision flow
* @param {string} disruptionId - The disruption ID
* @param {Function} onStageChange - Callback when stage changes
* @returns {Array} Array of unsubscribe functions for cleanup
*/
const injectDisruption = useCallback(
(disruptionId, onStageChange) => {
if (!disruptionId) return [];

const unsubscribers = [];

// Notify the store that this disruption is active
setActiveDisruptionId(disruptionId);
onLog?.('Injecting disruption into alert system…', 'info');

// Watch disruption
const unsubDisruption = watchDisruptionSupabase(disruptionId, (data) => {
onLog?.(
`✓ Disruption confirmed: ${data.title || data.type || disruptionId}`,
'success'
);
onStageChange?.('monitoring', data);

// Watch impact after disruption confirmed
const unsubImpact = watchImpactSupabase(disruptionId, (impactData) => {
onLog?.(
`✓ Impact scored: $${Number(impactData.totalCargoAtRiskUSD || 0).toLocaleString()} at risk across ${(impactData.affectedShipments || []).length} shipments`,
'success'
);
onStageChange?.('impact', impactData);

// Watch resolution after impact available
const unsubResolution = watchResolutionSupabase(
disruptionId,
({ resolution: res, options: opts }) => {
onLog?.(
`✓ AI generated ${opts.length} resolution strategies`,
'success'
);
onStageChange?.('resolution', { resolution: res, options: opts });

// Bridge into alertStore — opens DecisionModal
setResolutionWithOptions({
...res,
disruptionId,
options: opts,
urgency: res.urgency ?? 8,
analysisText: res.analysisText,
impactReport: {
totalCargoAtRiskUSD: res.totalCargoAtRiskUSD,
cascadeRisk: res.cascadeRisk,
urgency: res.urgency,
analysisText: res.analysisText,
affectedShipments: Array.from({
length: res.shipmentCount || 0,
}),
},
});

setTimeout(() => onStageChange?.('decision'), 1200);
}
);
unsubscribers.push(unsubResolution);
});
unsubscribers.push(unsubImpact);
});
unsubscribers.push(unsubDisruption);

return unsubscribers;
},
[setActiveDisruptionId, setResolutionWithOptions, onLog]
);

return { injectDisruption };
}
Loading