Spaces:
Sleeping
Sleeping
| <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 ; } | |
| /* 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> | |