logistics-hackathon-env / dashboard.html
Jerry
Fix reward hacking loophole and add Phase 3 Hardening
0378774
Raw
History Blame Contribute Delete
31.9 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>🚛 Logistics Shipment RL — Live Dashboard</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<style>
:root {
--bg: #0a0e1a;
--surface: #111827;
--surface2: #1f2937;
--border: #2d3748;
--accent: #6366f1;
--accent2: #8b5cf6;
--green: #10b981;
--yellow: #f59e0b;
--red: #ef4444;
--orange: #f97316;
--text: #f1f5f9;
--muted: #94a3b8;
--card-radius: 14px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Inter', sans-serif;
min-height: 100vh;
padding: 24px;
}
/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 28px;
flex-wrap: wrap;
gap: 16px;
}
.header-left h1 {
font-size: 1.6rem;
font-weight: 800;
background: linear-gradient(135deg, #6366f1, #8b5cf6, #ec4899);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.header-left p {
color: var(--muted);
font-size: 0.875rem;
margin-top: 4px;
}
.badge {
background: rgba(99,102,241,0.15);
border: 1px solid rgba(99,102,241,0.3);
color: #a5b4fc;
padding: 4px 12px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.05em;
}
/* Controls */
.controls {
display: flex;
gap: 12px;
margin-bottom: 24px;
flex-wrap: wrap;
align-items: center;
}
select, input {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
padding: 9px 14px;
border-radius: 8px;
font-family: inherit;
font-size: 0.875rem;
outline: none;
cursor: pointer;
transition: border-color 0.2s;
}
select:hover, input:hover { border-color: var(--accent); }
.btn {
padding: 9px 20px;
border-radius: 8px;
border: none;
font-family: inherit;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 6px;
}
.btn-primary {
background: linear-gradient(135deg, var(--accent), var(--accent2));
color: white;
}
.btn-primary:hover { opacity: 0.9; transform: translateY(-1px); }
.btn-secondary {
background: var(--surface2);
color: var(--text);
border: 1px solid var(--border);
}
.btn-secondary:hover { border-color: var(--accent); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none !important; }
/* Stats Bar */
.stats-bar {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 24px;
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--card-radius);
padding: 16px 20px;
position: relative;
overflow: hidden;
transition: border-color 0.2s;
}
.stat-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, var(--accent), var(--accent2));
}
.stat-card .label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
margin-bottom: 8px;
}
.stat-card .value {
font-size: 1.6rem;
font-weight: 800;
font-family: 'JetBrains Mono', monospace;
}
.stat-card .sub {
font-size: 0.75rem;
color: var(--muted);
margin-top: 2px;
}
/* Reward meter */
.reward-bar-container {
margin-top: 6px;
background: var(--border);
border-radius: 999px;
height: 4px;
overflow: hidden;
}
.reward-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--green));
border-radius: 999px;
transition: width 0.5s ease;
}
/* Grid layout */
.main-grid {
display: grid;
grid-template-columns: 1fr 340px;
gap: 20px;
align-items: start;
}
@media (max-width: 900px) { .main-grid { grid-template-columns: 1fr; } }
/* Shipment cards */
.section-title {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--muted);
font-weight: 600;
margin-bottom: 12px;
}
.shipment-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.shipment-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--card-radius);
padding: 16px;
transition: all 0.3s;
position: relative;
}
.shipment-card:hover { transform: translateY(-2px); border-color: var(--accent); }
.shipment-card.critical { border-color: var(--red); }
.shipment-card.delayed { border-color: var(--orange); }
.shipment-card.on-time { border-color: var(--green); }
.shipment-card.in-transit { border-color: var(--yellow); }
.ship-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 10px;
}
.ship-id {
font-family: 'JetBrains Mono', monospace;
font-size: 0.85rem;
font-weight: 600;
color: #a5b4fc;
}
.status-pill {
font-size: 0.65rem;
font-weight: 700;
padding: 3px 8px;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.status-DELAYED { background: rgba(249,115,22,0.15); color: #fb923c; border: 1px solid rgba(249,115,22,0.3); }
.status-CRITICAL { background: rgba(239,68,68,0.15); color: #f87171; border: 1px solid rgba(239,68,68,0.3); }
.status-IN_TRANSIT{ background: rgba(245,158,11,0.15); color: #fbbf24; border: 1px solid rgba(245,158,11,0.3); }
.status-RESOLVED { background: rgba(16,185,129,0.15); color: #34d399; border: 1px solid rgba(16,185,129,0.3); }
.ship-cargo {
font-size: 0.85rem;
font-weight: 500;
margin-bottom: 8px;
line-height: 1.3;
}
.ship-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
font-size: 0.75rem;
}
.meta-item { color: var(--muted); }
.meta-item span { color: var(--text); font-weight: 500; }
.meta-item.bad span { color: var(--red); }
.meta-item.warn span { color: var(--orange); }
.meta-item.good span { color: var(--green); }
.priority-star {
position: absolute;
top: 12px; right: 12px;
font-size: 0.9rem;
}
/* Disruptions panel */
.disruptions-panel, .log-panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--card-radius);
padding: 16px;
margin-bottom: 16px;
}
.disruption-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid var(--border);
font-size: 0.8rem;
line-height: 1.4;
}
.disruption-item:last-child { border-bottom: none; }
.disruption-dot {
width: 8px; height: 8px;
background: var(--red);
border-radius: 50%;
margin-top: 4px;
flex-shrink: 0;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* Step log */
.log-panel {
max-height: 400px;
overflow-y: auto;
}
.log-entry {
font-family: 'JetBrains Mono', monospace;
font-size: 0.72rem;
padding: 5px 8px;
border-radius: 6px;
margin-bottom: 4px;
line-height: 1.5;
}
.log-entry.step { background: rgba(99,102,241,0.08); color: #a5b4fc; }
.log-entry.start { background: rgba(16,185,129,0.08); color: #34d399; }
.log-entry.end { background: rgba(245,158,11,0.08); color: #fbbf24; }
.log-entry.error { background: rgba(239,68,68,0.08); color: #f87171; }
/* Status indicator */
.api-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.75rem;
color: var(--muted);
}
.api-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--muted);
}
.api-dot.online { background: var(--green); }
.api-dot.offline { background: var(--red); }
/* Action panel */
.action-panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--card-radius);
padding: 16px;
margin-bottom: 16px;
}
.action-form { display: flex; flex-direction: column; gap: 8px; }
.action-form select, .action-form input { width: 100%; }
.hint { font-size: 0.72rem; color: var(--muted); margin-top: -4px; }
.spinner {
display: inline-block;
width: 14px; height: 14px;
border: 2px solid rgba(255,255,255,0.2);
border-top-color: white;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.empty-state {
text-align: center;
padding: 48px 24px;
color: var(--muted);
}
.empty-state .big-icon { font-size: 3rem; margin-bottom: 12px; }
.empty-state p { font-size: 0.875rem; }
/* --- Logistics Map Styles --- */
.map-container {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--card-radius);
position: relative;
height: 380px;
overflow: hidden;
margin-bottom: 24px;
background-image: radial-gradient(circle at 2px 2px, rgba(255,255,255,0.03) 1px, transparent 0);
background-size: 32px 32px;
}
.map-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(255,255,255,0.02);
}
.map-canvas {
position: relative;
width: 100%;
height: 320px;
}
.city-node {
position: absolute;
width: 10px; height: 10px;
background: var(--muted);
border-radius: 50%;
transform: translate(-50%, -50%);
z-index: 10;
box-shadow: 0 0 10px rgba(148,163,184,0.3);
}
.city-label {
position: absolute;
color: var(--muted);
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
transform: translate(-50%, 10px);
white-space: nowrap;
}
.route-line {
position: absolute;
height: 1px;
background: rgba(148, 163, 184, 0.1);
transform-origin: left center;
z-index: 5;
}
.shipment-marker {
position: absolute;
width: 28px; height: 28px;
background: var(--accent);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: white;
z-index: 20;
transform: translate(-50%, -50%);
transition: all 0.8s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 0 15px rgba(99, 102, 241, 0.4);
}
.shipment-marker.delayed {
background: var(--red);
box-shadow: 0 0 15px rgba(239, 68, 68, 0.5);
animation: map-pulse 2s infinite;
}
@keyframes map-pulse {
0% { transform: translate(-50%, -50%) scale(1); box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); }
70% { transform: translate(-50%, -50%) scale(1.15); box-shadow: 0 0 0 12px rgba(239, 68, 68, 0); }
100% { transform: translate(-50%, -50%) scale(1); box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
}
</style>
</head>
<body>
<!-- Header -->
<div class="header">
<div class="header-left">
<h1>🚛 Logistics Shipment RL Environment</h1>
<p>Meta PyTorch OpenEnv Hackathon — Live Interactive Dashboard</p>
</div>
<div style="display:flex;align-items:center;gap:12px;">
<div class="api-status">
<div class="api-dot" id="apiDot"></div>
<span id="apiStatusText">Connecting…</span>
</div>
<span class="badge">OpenEnv Compatible</span>
</div>
</div>
<!-- Controls -->
<div class="controls">
<input id="apiBase" type="text" placeholder="API URL (leave blank for local origin)" value="" style="width:360px;" />
<select id="taskSelect">
<option value="TASK-EASY">🟢 TASK-EASY — Port Backlog Clearance</option>
<option value="TASK-MEDIUM" selected>🟡 TASK-MEDIUM — Mumbai Crisis</option>
<option value="TASK-HARD">🔴 TASK-HARD — Multi-Port Collapse</option>
</select>
<button class="btn btn-primary" id="resetBtn" onclick="doReset()">
▶ Reset Episode
</button>
<label style="display:flex; align-items:center; gap:8px; font-size:0.75rem; color:var(--muted); cursor:pointer;">
<input type="checkbox" id="autoRefreshCheck" onchange="toggleAutoRefresh()" style="width:auto;">
📡 Live Sync Mode
</label>
<button class="btn btn-secondary" id="statusBtn" onclick="doStatus()" disabled>
📡 Get Status
</button>
</div>
<!-- Stats Bar -->
<div class="stats-bar">
<div class="stat-card">
<div class="label">Turn</div>
<div class="value" id="statTurn"></div>
<div class="sub" id="statMaxTurns">of — turns</div>
</div>
<div class="stat-card">
<div class="label">Cumulative Reward</div>
<div class="value" id="statReward" style="color:var(--green)"></div>
<div class="reward-bar-container">
<div class="reward-bar-fill" id="rewardBar" style="width:0%"></div>
</div>
</div>
<div class="stat-card">
<div class="label">Active Shipments</div>
<div class="value" id="statShipments"></div>
<div class="sub" id="statDelayed">— delayed</div>
</div>
<div class="stat-card">
<div class="label">Last Action Reward</div>
<div class="value" id="statIncReward" style="color:var(--accent)"></div>
<div class="sub" id="statFeedback" style="font-size:0.7rem;max-width:160px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"></div>
</div>
<div class="stat-card">
<div class="label">Episode Status</div>
<div class="value" id="statDone" style="font-size:1rem;margin-top:4px;">Idle</div>
<div class="sub" id="statTask"></div>
</div>
</div>
<!-- Main Grid -->
<div class="main-grid">
<!-- Left: Shipments -->
<div>
<!-- Logistics Map -->
<div class="map-container">
<div class="map-header">
<div class="section-title" style="margin-bottom:0">🗺️ LIVE LOGISTICS NETWORK</div>
<div style="display:flex; gap:12px; font-size:0.7rem; color:var(--muted);">
<span style="display:flex;align-items:center;gap:4px;"><span style="width:8px;height:8px;border-radius:50%;background:var(--accent);"></span> In Transit</span>
<span style="display:flex;align-items:center;gap:4px;"><span style="width:8px;height:8px;border-radius:50%;background:var(--red);"></span> Delayed</span>
</div>
</div>
<div id="mapCanvas" class="map-canvas"></div>
</div>
<div class="section-title">📦 Active Shipments</div>
<div id="shipmentsGrid" class="shipment-grid">
<div class="empty-state" style="grid-column:1/-1">
<div class="big-icon">🚛</div>
<p>Press <strong>Reset Episode</strong> to load a scenario</p>
</div>
</div>
<!-- Quick Guide -->
<div class="section-title">📖 Quick Start Guide</div>
<div class="action-panel" style="font-size:0.8rem; line-height:1.5; color:var(--muted);">
<ol style="margin-left:16px;">
<li>Select a <strong>Task</strong> and click <strong>Reset Episode</strong> to load shipments.</li>
<li>Check the <strong>Network Congestion</strong> on the right to find clear routes.</li>
<li>Select <strong>reroute_shipment</strong> below, enter a <strong>Shipment ID</strong> (e.g., SHIP-001) and a clear <strong>Route</strong> (e.g., R4).</li>
<li>Click <strong>Execute Action</strong> to improve your reward!</li>
</ol>
</div>
<!-- Action panel -->
<div class="section-title">⚡ Execute Action</div>
<div class="action-panel">
<div class="action-form">
<select id="actionType" onchange="updateActionForm()">
<option value="get_network_status">get_network_status</option>
<option value="reroute_shipment">reroute_shipment</option>
<option value="set_priority">set_priority</option>
<option value="communicate_eta">communicate_eta</option>
<option value="escalate">escalate</option>
<option value="end_turn">end_turn</option>
</select>
<div id="actionFields"></div>
<button class="btn btn-primary" id="stepBtn" onclick="doStep()" disabled>
↗ Execute Action
</button>
</div>
</div>
</div>
<!-- Right: Info panels -->
<div>
<div class="section-title">🚦 Network Congestion (Multi-Agent)</div>
<div class="disruptions-panel" id="loadPanel">
<div style="color:var(--muted);font-size:0.8rem;text-align:center;padding:16px;">Connecting to network…</div>
</div>
<div class="section-title">🚨 Active Disruptions</div>
<div class="disruptions-panel" id="disruptionsPanel">
<div style="color:var(--muted);font-size:0.8rem;text-align:center;padding:16px;">No active episode</div>
</div>
<div class="section-title">📋 Episode Log</div>
<div class="log-panel" id="logPanel">
<div style="color:var(--muted);font-size:0.72rem;font-family:'JetBrains Mono',monospace;text-align:center;padding:16px;">
Episode log will appear here…
</div>
</div>
</div>
</div>
<script>
let state = { obs: null, done: false, steps: 0 };
const logs = [];
function apiBase() {
const val = document.getElementById('apiBase').value.trim().replace(/\/$/, '');
return val || window.location.origin;
}
async function ping() {
try {
const r = await fetch(apiBase() + '/schema', { signal: AbortSignal.timeout(4000) });
const ok = r.ok;
document.getElementById('apiDot').className = ok ? 'api-dot online' : 'api-dot offline';
document.getElementById('apiStatusText').textContent = ok ? 'API Online' : 'API Error';
} catch {
document.getElementById('apiDot').className = 'api-dot offline';
document.getElementById('apiStatusText').textContent = 'Offline / Sleeping';
}
}
async function doReset() {
const btn = document.getElementById('resetBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Resetting…';
state.done = false; state.steps = 0; logs.length = 0;
try {
const task = document.getElementById('taskSelect').value;
const r = await fetch(apiBase() + '/reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_id: task })
});
if (!r.ok) throw new Error(await r.text());
const data = await r.json();
// OpenEnv wraps response under 'observation' key
const obs = data.observation || data;
state.obs = obs;
addLog('start', `[START] task=${task} — Episode reset ✓`);
renderAll(obs);
document.getElementById('statusBtn').disabled = false;
document.getElementById('stepBtn').disabled = false;
} catch(e) {
addLog('error', `Reset failed: ${e.message}`);
} finally {
btn.disabled = false;
btn.innerHTML = '▶ Reset Episode';
}
}
async function doStatus() {
const action = { action_type: 'get_network_status' };
await sendStep(action);
}
async function doStep() {
const type = document.getElementById('actionType').value;
const action = { action_type: type };
if (type === 'reroute_shipment') {
action.shipment_id = document.getElementById('f_shipment_id')?.value || '';
action.new_route = document.getElementById('f_new_route')?.value || '';
action.new_carrier = document.getElementById('f_new_carrier')?.value || '';
action.reason = document.getElementById('f_reason')?.value || '';
} else if (type === 'communicate_eta') {
action.shipment_id = document.getElementById('f_shipment_id')?.value || '';
action.message = document.getElementById('f_message')?.value || '';
} else if (type === 'set_priority') {
const raw = document.getElementById('f_priority_ids')?.value || '';
action.priority_ids = raw.split(',').map(s => s.trim()).filter(Boolean);
} else if (type === 'escalate') {
action.shipment_id = document.getElementById('f_shipment_id')?.value || '';
action.reason = document.getElementById('f_reason')?.value || '';
}
await sendStep(action);
}
async function sendStep(action) {
if (state.done) { addLog('error', 'Episode is finished. Reset to play again.'); return; }
const btn = document.getElementById('stepBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Executing…';
state.steps++;
try {
const r = await fetch(apiBase() + '/step', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action })
});
if (!r.ok) {
const err = await r.json();
throw new Error(JSON.stringify(err.detail || err));
}
const data = await r.json();
const obs = data.observation;
const reward = data.reward ?? 0;
state.obs = obs;
state.done = data.done;
addLog('step', `[STEP ${state.steps}] ${action.action_type} reward=${reward.toFixed(3)} done=${data.done}`);
if (data.done) addLog('end', `[END] Episode Complete — Cumulative: ${obs.cumulative_reward?.toFixed(3) ?? '?'}`);
renderAll(obs, data.done);
} catch(e) {
addLog('error', `Error: ${e.message.substring(0, 120)}`);
} finally {
btn.disabled = state.done;
btn.innerHTML = '↗ Execute Action';
}
}
function renderAll(obs, done = false) {
// Stats
document.getElementById('statTurn').textContent = obs.turn ?? '0';
document.getElementById('statMaxTurns').textContent = `of ${obs.max_turns ?? '?'} turns`;
const cr = obs.cumulative_reward ?? 0;
document.getElementById('statReward').textContent = cr.toFixed(3);
document.getElementById('rewardBar').style.width = Math.min(100, Math.max(0, cr / 2 * 100)) + '%';
const ships = obs.shipments ?? [];
document.getElementById('statShipments').textContent = ships.length;
const delayed = ships.filter(s => s.status !== 'IN_TRANSIT').length;
document.getElementById('statDelayed').textContent = `${delayed} disrupted`;
document.getElementById('statIncReward').textContent = (obs.incremental_reward ?? 0).toFixed(3);
document.getElementById('statFeedback').textContent = obs.feedback ?? '';
document.getElementById('statFeedback').title = obs.feedback ?? '';
document.getElementById('statDone').textContent = done ? '🏁 Done' : '🟢 Running';
document.getElementById('statDone').style.color = done ? 'var(--yellow)' : 'var(--green)';
document.getElementById('statTask').textContent = obs.task ?? '';
// Disruptions
const disp = document.getElementById('disruptionsPanel');
const disruptions = obs.disruptions ?? [];
if (!disruptions.length) {
disp.innerHTML = '<div style="color:var(--muted);font-size:0.8rem;text-align:center;padding:8px;">No disruptions</div>';
} else {
disp.innerHTML = disruptions.map(d => `
<div class="disruption-item">
<div class="disruption-dot"></div>
<span>${d}</span>
</div>`).join('');
}
// Route Load (Theme #1)
const loadPanel = document.getElementById('loadPanel');
const load = obs.route_load || {};
if (!Object.keys(load).length) {
loadPanel.innerHTML = '<div style="color:var(--muted);font-size:0.8rem;text-align:center;padding:8px;">No data</div>';
} else {
loadPanel.innerHTML = Object.entries(load).map(([rid, val]) => {
const pct = (val * 100).toFixed(0);
const color = val > 0.8 ? 'var(--red)' : val > 0.6 ? 'var(--orange)' : 'var(--green)';
return `
<div style="margin-bottom:10px;">
<div style="display:flex;justify-content:space-between;font-size:0.75rem;margin-bottom:4px;">
<span style="font-family:'JetBrains Mono'">${rid} Load</span>
<span style="color:${color};font-weight:700">${pct}%</span>
</div>
<div style="background:var(--border);height:6px;border-radius:999px;overflow:hidden;">
<div style="background:${color};width:${pct}%;height:100%;transition:width 0.5s;"></div>
</div>
</div>`;
}).join('');
}
// Shipment cards
const grid = document.getElementById('shipmentsGrid');
if (!ships.length) {
grid.innerHTML = '<div class="empty-state" style="grid-column:1/-1"><div class="big-icon">📭</div><p>No shipments</p></div>';
return;
}
grid.innerHTML = ships.map(s => {
const slaClass = s.sla_buffer_h < -2 ? 'bad' : s.sla_buffer_h < 0 ? 'warn' : 'good';
const cardClass = s.status === 'CRITICAL' ? 'critical' : s.status === 'DELAYED' ? 'delayed' : s.status === 'RESOLVED' ? 'on-time' : 'in-transit';
return `
<div class="shipment-card ${cardClass}">
${s.priority ? '<div class="priority-star" title="Priority">⭐</div>' : ''}
<div class="ship-header">
<div class="ship-id">${s.id}</div>
<div class="status-pill status-${s.status}">${s.status.replace('_', ' ')}</div>
</div>
<div class="ship-cargo">${s.cargo}</div>
<div class="ship-meta">
<div class="meta-item">Route <span>${s.route}</span></div>
<div class="meta-item">Carrier <span>${s.carrier}</span></div>
<div class="meta-item">Delay <span>${s.delay_h}h</span></div>
<div class="meta-item ${slaClass}">SLA Buffer <span>${s.sla_buffer_h}h</span></div>
<div class="meta-item">₹ Value <span>${(s.value/1000).toFixed(0)}K</span></div>
<div class="meta-item" style="grid-column:1/-1" title="${s.notes}">Notes <span style="font-size:0.72rem">${s.notes ?? '—'}</span></div>
</div>
</div>`;
}).join('');
}
function addLog(type, msg) {
logs.unshift({ type, msg, time: new Date().toLocaleTimeString() });
const panel = document.getElementById('logPanel');
panel.innerHTML = logs.slice(0, 50).map(l =>
`<div class="log-entry ${l.type}">[${l.time}] ${l.msg}</div>`
).join('');
}
function updateActionForm() {
const type = document.getElementById('actionType').value;
const f = document.getElementById('actionFields');
const tpl = {
reroute_shipment: `
<input id="f_shipment_id" placeholder="shipment_id (e.g. SHIP-001)" />
<select id="f_new_route"><option value="">Select route...</option><option>R1</option><option>R2</option><option>R3</option><option>R4</option><option>R5</option><option>R6</option></select>
<input id="f_new_carrier" placeholder="new_carrier (e.g. SpeedLane)" />
<input id="f_reason" placeholder="reason for rerouting" />`,
communicate_eta: `
<input id="f_shipment_id" placeholder="shipment_id (e.g. SHIP-001)" />
<input id="f_message" placeholder="ETA message (apologize + reason + ETA)" />
<div class="hint">💡 Tip: Include 'apologize', 'ETA', 'port congestion' for higher score</div>`,
set_priority: `
<input id="f_priority_ids" placeholder="priority_ids (comma-separated: SHIP-001,SHIP-003)" />`,
escalate: `
<input id="f_shipment_id" placeholder="shipment_id (e.g. SHIP-001)" />
<input id="f_reason" placeholder="reason for escalation" />
<div class="hint">⚠️ Escalation incurs -0.1 penalty</div>`,
};
f.innerHTML = tpl[type] || '';
}
// --- Map Visualization ---
const CITIES = {
"Mumbai": { x: 15, y: 60 },
"Pune": { x: 28, y: 75 },
"Delhi": { x: 45, y: 15 },
"Agra": { x: 55, y: 25 },
"Chennai": { x: 75, y: 85 },
"Bangalore": { x: 88, y: 70 }
};
const ROUTES_LINKS = [
["Mumbai", "Pune"], ["Delhi", "Agra"], ["Chennai", "Bangalore"]
];
function initMap() {
const canvas = document.getElementById('mapCanvas');
canvas.innerHTML = '';
// Draw Lines
ROUTES_LINKS.forEach(([c1, c2]) => {
const s = CITIES[c1], e = CITIES[c2];
const dx = e.x - s.x, dy = e.y - s.y;
const dist = Math.sqrt(dx*dx + dy*dy);
const angle = Math.atan2(dy, dx) * 180 / Math.PI;
const line = document.createElement('div');
line.className = 'route-line';
line.style.width = `${dist}%`;
line.style.left = `${s.x}%`;
line.style.top = `${s.y}%`;
line.style.transform = `rotate(${angle}deg)`;
canvas.appendChild(line);
});
// Draw Cities
Object.entries(CITIES).forEach(([name, pos]) => {
const node = document.createElement('div');
node.className = 'city-node';
node.style.left = `${pos.x}%`;
node.style.top = `${pos.y}%`;
const label = document.createElement('div');
label.className = 'city-label';
label.innerText = name;
node.appendChild(label);
canvas.appendChild(node);
});
}
function updateMapShipments(shipments) {
document.querySelectorAll('.shipment-marker').forEach(m => m.remove());
const canvas = document.getElementById('mapCanvas');
shipments.forEach((s, i) => {
const origin = CITIES[s.origin] || {x:50,y:50};
const dest = CITIES[s.destination] || {x:50,y:50};
const marker = document.createElement('div');
const delayed = s.status === 'DELAYED' || s.status === 'CRITICAL';
marker.className = `shipment-marker ${delayed ? 'delayed' : ''}`;
// Offset slightly per shipment so they don't overlap
const offset = (i * 3) - 5;
marker.style.left = `${origin.x + (dest.x - origin.x) * 0.3 + offset}%`;
marker.style.top = `${origin.y + (dest.y - origin.y) * 0.3 + offset}%`;
marker.innerHTML = '🚚';
marker.title = `${s.id}: ${s.origin}${s.destination}`;
canvas.appendChild(marker);
});
}
// Inject into renderAll
const originalRenderAll = renderAll;
renderAll = function(obs, done) {
originalRenderAll(obs, done);
updateMapShipments(obs.shipments || []);
};
let refreshInterval = null;
function toggleAutoRefresh() {
const checked = document.getElementById('autoRefreshCheck').checked;
if (checked) {
refreshInterval = setInterval(async () => {
// Just fetch the latest obs and render
try {
const r = await fetch(apiBase() + '/reset', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ task_id: document.getElementById('taskSelect').value, dry_run: true }) });
const data = await r.json();
renderAll(data.observation || data);
} catch(e) {}
}, 2000);
addLog('start', 'Live Sync Mode: ON');
} else {
clearInterval(refreshInterval);
addLog('end', 'Live Sync Mode: OFF');
}
}
// Init
initMap();
ping();
setInterval(ping, 15000);
updateActionForm();
</script>
</body>
</html>