Spaces:
Sleeping
Sleeping
| CUSTOM_WEB_HTML = """<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>GPU Scheduler Console</title> | |
| <style> | |
| :root{ | |
| --bg:#0b1017; | |
| --bg-soft:#0f1621; | |
| --panel:#121a25; | |
| --panel-2:#0f1722; | |
| --panel-3:#0c141d; | |
| --line:#243244; | |
| --line-strong:#31506f; | |
| --text:#e8eef5; | |
| --muted:#8ea1b5; | |
| --accent:#63b3ed; | |
| --accent-soft:rgba(99,179,237,.14); | |
| --good:#4db877; | |
| --warn:#d4a72c; | |
| --bad:#ef6b64; | |
| --high:#ff8a65; | |
| --medium:#d6b348; | |
| --low:#67a3ff; | |
| --shadow:0 18px 44px rgba(0,0,0,.28); | |
| --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace; | |
| --sans:"Segoe UI",Inter,ui-sans-serif,system-ui,sans-serif; | |
| } | |
| *{box-sizing:border-box} | |
| body{ | |
| margin:0; | |
| font-family:var(--sans); | |
| background: | |
| radial-gradient(circle at top right, rgba(99,179,237,.08), transparent 28%), | |
| linear-gradient(180deg, #0a0f16 0%, #0b1017 100%); | |
| color:var(--text); | |
| } | |
| .page{max-width:1580px;margin:0 auto;padding:20px 18px 28px} | |
| .hero,.card,.panel,.status-box,.timeline-entry,.inventory-row,.mini-card,.kpi,.detail-card{ | |
| background:linear-gradient(180deg, rgba(18,26,37,.96), rgba(13,21,31,.96)); | |
| border:1px solid var(--line); | |
| box-shadow:var(--shadow); | |
| } | |
| .hero,.card,.panel{border-radius:14px} | |
| .hero{padding:18px 20px;margin-bottom:16px} | |
| .eyebrow{ | |
| display:inline-flex; | |
| align-items:center; | |
| gap:8px; | |
| text-transform:uppercase; | |
| letter-spacing:.12em; | |
| font-size:.72rem; | |
| color:var(--muted); | |
| } | |
| .eyebrow::before{ | |
| content:""; | |
| width:8px; | |
| height:8px; | |
| border-radius:999px; | |
| background:var(--accent); | |
| box-shadow:0 0 0 4px rgba(99,179,237,.15); | |
| } | |
| .hero-head{ | |
| display:flex; | |
| justify-content:space-between; | |
| gap:16px; | |
| align-items:flex-end; | |
| } | |
| .hero h1{margin:8px 0 6px;font-size:1.55rem;font-weight:650;letter-spacing:.01em} | |
| .hero p,.copy,.muted{color:var(--muted)} | |
| .hero p{margin:0;max-width:920px;line-height:1.45} | |
| .hero-meta{ | |
| display:flex; | |
| gap:10px; | |
| flex-wrap:wrap; | |
| justify-content:flex-end; | |
| } | |
| .layout{display:grid;grid-template-columns:296px minmax(0,1fr);gap:16px;align-items:start} | |
| .stack{display:grid;gap:16px} | |
| .sidebar{position:sticky;top:18px} | |
| .card{padding:14px} | |
| .sidebar .card{padding:13px} | |
| .card h2,.panel h2{margin:0 0 12px;font-size:1rem;letter-spacing:.01em} | |
| .card p{margin:0} | |
| .row{display:grid;grid-template-columns:1fr 1fr;gap:9px} | |
| .row3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:9px} | |
| label{ | |
| display:block; | |
| margin-bottom:5px; | |
| color:var(--muted); | |
| font-size:.72rem; | |
| text-transform:uppercase; | |
| letter-spacing:.08em; | |
| } | |
| input,select,button{ | |
| width:100%; | |
| min-height:36px; | |
| background:var(--panel-3); | |
| color:var(--text); | |
| border:1px solid var(--line); | |
| border-radius:9px; | |
| padding:8px 10px; | |
| font:inherit; | |
| } | |
| input,select{font-size:.88rem} | |
| button{ | |
| cursor:pointer; | |
| font-weight:600; | |
| background:linear-gradient(180deg, rgba(99,179,237,.22), rgba(99,179,237,.08)); | |
| border-color:rgba(99,179,237,.45); | |
| } | |
| button.secondary{ | |
| background:var(--panel-3); | |
| border-color:var(--line); | |
| color:var(--muted); | |
| } | |
| .meta-strip,.chips{display:flex;gap:8px;flex-wrap:wrap} | |
| .tag,.chip,.tiny-pill{ | |
| display:inline-flex; | |
| align-items:center; | |
| gap:6px; | |
| min-height:28px; | |
| padding:5px 9px; | |
| border-radius:999px; | |
| border:1px solid var(--line); | |
| background:rgba(255,255,255,.02); | |
| color:var(--text); | |
| font-size:.78rem; | |
| white-space:nowrap; | |
| } | |
| .tag{color:var(--muted)} | |
| .chip.high,.priority-dot.high,.pill-high{color:var(--high);border-color:rgba(255,138,101,.38)} | |
| .chip.medium,.priority-dot.medium,.pill-medium{color:var(--medium);border-color:rgba(214,179,72,.38)} | |
| .chip.low,.priority-dot.low,.pill-low{color:var(--low);border-color:rgba(103,163,255,.38)} | |
| .good{color:var(--good);border-color:rgba(77,184,119,.38)} | |
| .warn{color:var(--warn);border-color:rgba(212,167,44,.38)} | |
| .bad{color:var(--bad);border-color:rgba(239,107,100,.38)} | |
| .mono{ | |
| font-family:var(--mono); | |
| font-variant-numeric:tabular-nums; | |
| font-feature-settings:"tnum" 1; | |
| } | |
| .status-stack,.timeline{display:grid;gap:10px} | |
| .status-box,.timeline-entry,.detail-card,.mini-card{ | |
| border-radius:12px; | |
| padding:12px; | |
| } | |
| .status-box strong,.timeline-entry strong,.detail-card strong{display:block;margin-bottom:5px} | |
| .timeline-meta{ | |
| display:flex; | |
| justify-content:space-between; | |
| align-items:center; | |
| gap:8px; | |
| margin-bottom:7px; | |
| } | |
| .panel{padding:16px} | |
| .console-head{ | |
| display:flex; | |
| justify-content:space-between; | |
| gap:16px; | |
| align-items:flex-start; | |
| margin-bottom:12px; | |
| } | |
| .console-head h2{margin:4px 0 6px;font-size:1.1rem} | |
| .console-subtitle{max-width:850px;color:var(--muted);line-height:1.45} | |
| .kpi-strip{ | |
| display:grid; | |
| grid-template-columns:repeat(6,minmax(0,1fr)); | |
| gap:8px; | |
| margin-top:12px; | |
| } | |
| .kpi{ | |
| border-radius:12px; | |
| padding:10px 11px; | |
| min-height:78px; | |
| } | |
| .kpi small{ | |
| display:block; | |
| color:var(--muted); | |
| margin-bottom:8px; | |
| text-transform:uppercase; | |
| letter-spacing:.08em; | |
| font-size:.72rem; | |
| } | |
| .kpi strong{ | |
| display:block; | |
| font-size:1.18rem; | |
| line-height:1.05; | |
| margin-bottom:6px; | |
| } | |
| .kpi span{font-size:.8rem;color:var(--muted)} | |
| .ops-grid{ | |
| display:grid; | |
| grid-template-columns:minmax(0,1.45fr) minmax(360px,.95fr) minmax(290px,.82fr); | |
| gap:14px; | |
| margin-top:14px; | |
| } | |
| .panel-head{ | |
| display:flex; | |
| align-items:flex-end; | |
| justify-content:space-between; | |
| gap:12px; | |
| margin-bottom:12px; | |
| } | |
| .panel-head h3{margin:0;font-size:.96rem} | |
| .panel-head small{color:var(--muted)} | |
| .table-shell{ | |
| border:1px solid var(--line); | |
| border-radius:12px; | |
| overflow:hidden; | |
| background:rgba(8,13,20,.55); | |
| } | |
| table{width:100%;border-collapse:collapse} | |
| th,td{ | |
| padding:11px 12px; | |
| border-bottom:1px solid rgba(36,50,68,.72); | |
| text-align:left; | |
| font-size:.9rem; | |
| vertical-align:middle; | |
| } | |
| th{ | |
| color:var(--muted); | |
| background:rgba(255,255,255,.02); | |
| text-transform:uppercase; | |
| letter-spacing:.08em; | |
| font-size:.72rem; | |
| font-weight:600; | |
| } | |
| tr:last-child td{border-bottom:none} | |
| .ops-row{cursor:pointer} | |
| .ops-row td,.inventory-row{transition:background .16s ease,border-color .16s ease,box-shadow .16s ease,transform .16s ease} | |
| .ops-row:hover td,.inventory-row:hover{background:rgba(99,179,237,.07)} | |
| .ops-row.selected,.inventory-row.selected{ | |
| background:rgba(99,179,237,.11); | |
| box-shadow:inset 0 0 0 1px rgba(99,179,237,.42),0 0 0 1px rgba(99,179,237,.18); | |
| } | |
| .ops-row.selected td{background:rgba(99,179,237,.1)} | |
| .ops-row.selected td:first-child{box-shadow:inset 3px 0 0 0 var(--accent)} | |
| .ops-row.risk-critical td{background:rgba(239,107,100,.045)} | |
| .ops-row.risk-warning td{background:rgba(212,167,44,.045)} | |
| .priority-cell{display:flex;align-items:center;gap:8px} | |
| .priority-dot{ | |
| width:8px; | |
| height:8px; | |
| border-radius:999px; | |
| display:inline-block; | |
| background:currentColor; | |
| box-shadow:0 0 0 4px rgba(255,255,255,.04); | |
| } | |
| .fit-count{ | |
| display:inline-flex; | |
| align-items:center; | |
| gap:6px; | |
| padding:3px 8px; | |
| border-radius:999px; | |
| background:rgba(255,255,255,.03); | |
| border:1px solid var(--line); | |
| font-size:.78rem; | |
| color:var(--muted); | |
| } | |
| .slack-cell{display:grid;gap:4px} | |
| .slack-pill,.inventory-meta-pill{ | |
| display:inline-flex; | |
| align-items:center; | |
| gap:6px; | |
| padding:3px 8px; | |
| border-radius:999px; | |
| border:1px solid var(--line); | |
| background:rgba(255,255,255,.03); | |
| font-size:.76rem; | |
| width:max-content; | |
| } | |
| .slack-pill.critical{color:var(--bad);border-color:rgba(239,107,100,.42);background:rgba(239,107,100,.08)} | |
| .slack-pill.warning{color:var(--warn);border-color:rgba(212,167,44,.42);background:rgba(212,167,44,.08)} | |
| .slack-pill.stable{color:var(--good);border-color:rgba(77,184,119,.38);background:rgba(77,184,119,.06)} | |
| .cell-subtext{font-size:.75rem;color:var(--muted)} | |
| .inventory{ | |
| display:grid; | |
| gap:9px; | |
| } | |
| .inventory-row{ | |
| border-radius:12px; | |
| padding:11px; | |
| border:1px solid var(--line); | |
| cursor:pointer; | |
| } | |
| .inventory-row:hover{transform:translateY(-1px);border-color:rgba(99,179,237,.28)} | |
| .inventory-row.selected{border-color:rgba(99,179,237,.42)} | |
| .inventory-head{ | |
| display:flex; | |
| justify-content:space-between; | |
| align-items:flex-start; | |
| gap:10px; | |
| margin-bottom:8px; | |
| } | |
| .inventory-head strong{display:block;font-size:.95rem} | |
| .inventory-head small{display:block;color:var(--muted);line-height:1.4} | |
| .capacity-track{ | |
| position:relative; | |
| height:12px; | |
| border-radius:999px; | |
| background:#08111a; | |
| border:1px solid rgba(255,255,255,.06); | |
| overflow:hidden; | |
| } | |
| .capacity-fill{ | |
| height:100%; | |
| border-radius:999px; | |
| background:linear-gradient(90deg, rgba(99,179,237,.96), rgba(77,184,119,.78)); | |
| width:var(--fill,0%); | |
| } | |
| .capacity-fill.busy-hot{ | |
| background:linear-gradient(90deg, rgba(239,107,100,.96), rgba(212,167,44,.82)); | |
| } | |
| .inventory-foot{ | |
| display:flex; | |
| justify-content:space-between; | |
| gap:8px; | |
| margin-top:7px; | |
| color:var(--muted); | |
| font-size:.78rem; | |
| } | |
| .inventory-context{ | |
| margin-top:8px; | |
| padding-top:8px; | |
| border-top:1px solid rgba(255,255,255,.06); | |
| display:flex; | |
| justify-content:space-between; | |
| gap:8px; | |
| align-items:center; | |
| } | |
| .inventory-context strong{display:block;font-size:.8rem} | |
| .inventory-context small{display:block;color:var(--muted);line-height:1.35} | |
| .inventory-meta-pill.warn{background:rgba(212,167,44,.08)} | |
| .inventory-meta-pill.good{background:rgba(77,184,119,.06)} | |
| .detail-stack{display:grid;gap:12px} | |
| .detail-label{ | |
| color:var(--muted); | |
| text-transform:uppercase; | |
| letter-spacing:.08em; | |
| font-size:.72rem; | |
| margin-bottom:5px; | |
| display:block; | |
| } | |
| .detail-main{font-size:1.02rem;font-weight:620;margin-bottom:8px} | |
| .stat-grid{ | |
| display:grid; | |
| grid-template-columns:1fr 1fr; | |
| gap:10px; | |
| margin-top:10px; | |
| } | |
| .mini-card small{ | |
| display:block; | |
| color:var(--muted); | |
| margin-bottom:5px; | |
| text-transform:uppercase; | |
| letter-spacing:.08em; | |
| font-size:.7rem; | |
| } | |
| .mini-card strong{font-size:1rem} | |
| .signal-list{ | |
| display:grid; | |
| gap:8px; | |
| margin-top:10px; | |
| } | |
| .signal-row{ | |
| display:grid; | |
| grid-template-columns:86px 1fr 42px; | |
| gap:8px; | |
| align-items:center; | |
| font-size:.82rem; | |
| } | |
| .signal-row span:first-child{color:var(--muted)} | |
| .signal-track{ | |
| height:8px; | |
| border-radius:999px; | |
| overflow:hidden; | |
| background:#08111a; | |
| border:1px solid rgba(255,255,255,.06); | |
| } | |
| .signal-fill{ | |
| height:100%; | |
| border-radius:999px; | |
| background:linear-gradient(90deg, rgba(99,179,237,.96), rgba(77,184,119,.78)); | |
| width:var(--fill,0%); | |
| } | |
| .spark-shell{ | |
| border:1px solid var(--line); | |
| border-radius:12px; | |
| background:rgba(255,255,255,.02); | |
| padding:12px; | |
| } | |
| .spark-head{ | |
| display:flex; | |
| justify-content:space-between; | |
| gap:10px; | |
| align-items:flex-end; | |
| margin-bottom:10px; | |
| } | |
| .sparkline{ | |
| min-height:78px; | |
| display:grid; | |
| align-items:center; | |
| } | |
| .sparkline svg{width:100%;height:78px;display:block} | |
| .spark-grid line{stroke:rgba(255,255,255,.06);stroke-width:1} | |
| .spark-path{ | |
| fill:none; | |
| stroke:var(--accent); | |
| stroke-width:2.5; | |
| stroke-linecap:round; | |
| stroke-linejoin:round; | |
| } | |
| .spark-dot{fill:var(--accent)} | |
| .spark-axis{ | |
| display:flex; | |
| justify-content:space-between; | |
| gap:8px; | |
| margin-top:8px; | |
| color:var(--muted); | |
| font-size:.74rem; | |
| } | |
| .footer-grid{ | |
| display:grid; | |
| grid-template-columns:1fr 1fr; | |
| gap:12px; | |
| margin-top:14px; | |
| } | |
| .footer-card{ | |
| border-radius:12px; | |
| padding:10px 12px; | |
| border:1px solid var(--line); | |
| background:rgba(255,255,255,.02); | |
| min-height:48px; | |
| } | |
| .footer-card h4{margin:0 0 6px;font-size:.84rem} | |
| .footer-list{display:flex;flex-wrap:wrap;gap:8px} | |
| .empty{ | |
| color:var(--muted); | |
| font-style:italic; | |
| } | |
| details{ | |
| border:1px solid var(--line); | |
| border-radius:12px; | |
| background:var(--panel-2); | |
| } | |
| summary{ | |
| cursor:pointer; | |
| padding:12px 14px; | |
| font-weight:600; | |
| } | |
| pre{ | |
| margin:0; | |
| padding:14px; | |
| overflow:auto; | |
| white-space:pre-wrap; | |
| word-break:break-word; | |
| font-family:var(--mono); | |
| font-size:.83rem; | |
| color:#cfd8e3; | |
| } | |
| hr{border:none;border-top:1px solid var(--line);margin:14px 0} | |
| @media (max-width:1340px){ | |
| .ops-grid{grid-template-columns:minmax(0,1fr)} | |
| .kpi-strip{grid-template-columns:repeat(3,minmax(0,1fr))} | |
| .sidebar{position:static} | |
| } | |
| @media (max-width:980px){ | |
| .layout{grid-template-columns:1fr} | |
| .kpi-strip{grid-template-columns:repeat(2,minmax(0,1fr))} | |
| .row,.row3,.stat-grid,.footer-grid{grid-template-columns:1fr} | |
| .hero-head,.console-head{flex-direction:column;align-items:flex-start} | |
| } | |
| @media (max-width:640px){ | |
| .page{padding:14px 12px 24px} | |
| .kpi-strip{grid-template-columns:1fr} | |
| th,td{padding:10px 8px} | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <main class="page"> | |
| <section class="hero"> | |
| <div class="hero-head"> | |
| <div> | |
| <div class="eyebrow">Interactive Scheduler Demo</div> | |
| <h1>GPU Scheduler Console</h1> | |
| <p>Operate the benchmark scenario like a compact placement console: inspect pending work, check eligible GPUs, and dispatch one placement decision at a time.</p> | |
| </div> | |
| <div class="hero-meta"> | |
| <span class="tag mono">Environment: openenv</span> | |
| <span class="tag mono">Mode: manual dispatch</span> | |
| </div> | |
| </div> | |
| </section> | |
| <section class="layout"> | |
| <aside class="stack sidebar"> | |
| <article class="card"> | |
| <h2>Placement Controls</h2> | |
| <p class="copy">Reset a scenario, load a placement decision, and dispatch one scheduling tick at a time.</p> | |
| <div class="row" style="margin-top:12px"> | |
| <div><label for="episode">Scenario</label><select id="episode"><option value="deadline_crunch">deadline_crunch</option><option value="cost_pressure">cost_pressure</option><option value="wait_for_capacity">wait_for_capacity</option><option value="reserve_large_gpu">reserve_large_gpu</option></select></div> | |
| <div><label for="seed">Seed</label><input id="seed" type="number" min="0" placeholder="optional" /></div> | |
| </div> | |
| <div class="row3" style="margin-top:10px"> | |
| <button id="reset" class="secondary">Reset</button> | |
| <button id="refresh" class="secondary">Sync</button> | |
| <button id="step">Dispatch</button> | |
| </div> | |
| <hr /> | |
| <div class="row"> | |
| <div><label for="action">Decision</label><select id="action"><option value="place">place job</option><option value="defer">defer placement</option></select></div> | |
| <div><label for="job">Pending job</label><select id="job"></select></div> | |
| </div> | |
| <div class="row" style="margin-top:10px"> | |
| <div><label for="gpu">Target GPU</label><select id="gpu"></select></div> | |
| <div><label for="rationale">Rationale</label><input id="rationale" maxlength="240" placeholder="Why this dispatch?" /></div> | |
| </div> | |
| </article> | |
| <article class="card"> | |
| <h2>Last Decision</h2> | |
| <div id="status" class="status-stack"></div> | |
| </article> | |
| <article class="card"> | |
| <h2>Decision Log</h2> | |
| <div id="timeline" class="timeline"></div> | |
| </article> | |
| </aside> | |
| <section class="stack"> | |
| <article class="panel"> | |
| <div class="console-head"> | |
| <div> | |
| <div class="eyebrow">Cluster Board</div> | |
| <h2>Placement Board</h2> | |
| <p id="summary" class="console-subtitle">Reset the environment to start a new scenario.</p> | |
| </div> | |
| <div id="context" class="meta-strip"></div> | |
| </div> | |
| <div id="kpis" class="kpi-strip"></div> | |
| <div class="ops-grid"> | |
| <section class="panel" style="padding:14px"> | |
| <div class="panel-head"> | |
| <div> | |
| <h3>Pending Queue</h3> | |
| <small>Operator view of actionable jobs and their current schedulability.</small> | |
| </div> | |
| <span id="queuemeta" class="chip mono">No queue data</span> | |
| </div> | |
| <div class="table-shell"> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>Job</th> | |
| <th>Pri</th> | |
| <th>Eligible GPUs</th> | |
| <th>VRAM</th> | |
| <th>Slack</th> | |
| <th>Age</th> | |
| <th>Placement</th> | |
| </tr> | |
| </thead> | |
| <tbody id="queueops"></tbody> | |
| </table> | |
| </div> | |
| </section> | |
| <section class="panel" style="padding:14px"> | |
| <div class="panel-head"> | |
| <div> | |
| <h3>Cluster Inventory</h3> | |
| <small>Capacity, placement, and schedulability by GPU.</small> | |
| </div> | |
| <span id="clustermeta" class="chip mono">No node data</span> | |
| </div> | |
| <div id="nodeinventory" class="inventory"></div> | |
| </section> | |
| <aside class="panel" style="padding:14px"> | |
| <div class="panel-head"> | |
| <div> | |
| <h3>Operator Detail</h3> | |
| <small>Current action, recommendation, and policy signals.</small> | |
| </div> | |
| </div> | |
| <div class="detail-stack"> | |
| <div id="selectiondetail" class="detail-card"></div> | |
| <div class="spark-shell"> | |
| <div class="spark-head"> | |
| <div> | |
| <strong>Simulator Score</strong> | |
| <div class="muted">Cumulative benchmark score across recent decisions.</div> | |
| </div> | |
| <span id="sparkmeta" class="chip mono">No history</span> | |
| </div> | |
| <div id="sparkline" class="sparkline"></div> | |
| </div> | |
| <div id="signals" class="detail-card"></div> | |
| </div> | |
| </aside> | |
| </div> | |
| <div class="footer-grid"> | |
| <div class="footer-card"> | |
| <h4>Completed Jobs</h4> | |
| <div id="completeddock" class="footer-list"></div> | |
| </div> | |
| <div class="footer-card"> | |
| <h4>Missed Jobs</h4> | |
| <div id="misseddock" class="footer-list"></div> | |
| </div> | |
| </div> | |
| <div id="episodesummary" style="margin-top:14px"></div> | |
| </article> | |
| <article class="card"> | |
| <details> | |
| <summary>Raw JSON</summary> | |
| <pre id="raw"></pre> | |
| </details> | |
| </article> | |
| </section> | |
| </section> | |
| </main> | |
| <script> | |
| const state={observation:null,reward:null,done:false,lastSelectedScenario:null,focusJobId:null,focusGpuId:null}; | |
| const el=id=>document.getElementById(id); | |
| const f1=v=>v==null||Number.isNaN(Number(v))?"-":Number(v).toFixed(1); | |
| const f2=v=>v==null||Number.isNaN(Number(v))?"-":Number(v).toFixed(2); | |
| const pct=v=>v==null||Number.isNaN(Number(v))?"-":`${(Number(v)*100).toFixed(0)}%`; | |
| const txt=v=>v==null?"":String(v); | |
| const priorityClass=p=>txt(p).toLowerCase()||"low"; | |
| const priorityChip=p=>`<span class="chip ${priorityClass(p)}">${txt(p)}</span>`; | |
| const priorityRank=p=>({high:0,medium:1,low:2}[priorityClass(p)] ?? 3); | |
| const slackValue=(job, obs)=>Number(job.deadline)-Number(obs.current_tick); | |
| function slackClass(slack){ | |
| if(slack<=2){ return "critical"; } | |
| if(slack<=4){ return "warning"; } | |
| return "stable"; | |
| } | |
| function slackLabel(slack){ | |
| if(slack<0){ return `missed by ${Math.abs(slack)}`; } | |
| if(slack===0){ return "due now"; } | |
| if(slack<=2){ return `${slack} step left`; } | |
| return `${slack} step buffer`; | |
| } | |
| function waitRiskClass(value){ | |
| if(value>=3){ return "critical"; } | |
| if(value>=1){ return "warning"; } | |
| return "stable"; | |
| } | |
| function setOptions(select, options, placeholder){ | |
| const current=select.value; | |
| select.innerHTML=""; | |
| if(!options.length){ | |
| select.innerHTML=`<option value="">${placeholder}</option>`; | |
| select.disabled=true; | |
| return; | |
| } | |
| select.innerHTML=`<option value="">${placeholder}</option>`+options.map(o=>`<option value="${o.value}">${o.label}</option>`).join(""); | |
| select.disabled=false; | |
| if(options.some(o=>o.value===current)){ select.value=current; } | |
| } | |
| function parseStepDetails(details){ | |
| const raw=txt(details); | |
| const parts=raw.split(";").map(s=>s.trim()).filter(Boolean); | |
| return { | |
| validation:parts[0]||"No validation details available.", | |
| event:parts[1]||"No environment event recorded." | |
| }; | |
| } | |
| function normalize(payload){ | |
| return { | |
| scenario_id:payload.scenario_id??payload.episode_id, | |
| current_tick:payload.current_tick??payload.current_step??payload.step_count??0, | |
| tick_limit:payload.tick_limit??payload.max_steps, | |
| summary:payload.summary, | |
| action_hint:payload.action_hint, | |
| last_step_valid_action:payload.last_step_valid_action, | |
| last_step_details:payload.last_step_details, | |
| priority_alerts:payload.priority_alerts||[], | |
| feasible_assignments:payload.feasible_assignments||[], | |
| suggested_assignments:payload.suggested_assignments||[], | |
| pending_jobs:(payload.pending_jobs ?? payload.queued_jobs) || [], | |
| running_jobs:payload.running_jobs||[], | |
| completed_jobs:payload.completed_jobs||[], | |
| missed_jobs:payload.missed_jobs||[], | |
| gpus:payload.gpus||[], | |
| metrics:payload.metrics||{}, | |
| last_event:payload.last_event, | |
| action_log:payload.action_log||[] | |
| }; | |
| } | |
| function gpuChoices(jobId){ | |
| if(!state.observation){ return []; } | |
| const job=state.observation.pending_jobs.find(j=>j.job_id===jobId); | |
| return state.observation.gpus | |
| .filter(g=>!g.busy && (!job || g.vram_capacity>=job.vram_requirement)) | |
| .map(g=>({ | |
| value:g.gpu_id, | |
| label:`${g.gpu_id} | VRAM ${g.vram_capacity} | speed ${g.speed_multiplier} | cost ${g.cost_per_step}` | |
| })); | |
| } | |
| function fitCandidates(job, obs, idleOnly=true){ | |
| return (obs.gpus||[]).filter(gpu=>(!idleOnly || !gpu.busy) && gpu.vram_capacity>=job.vram_requirement); | |
| } | |
| function bestGpuForJob(job, obs){ | |
| const fits=fitCandidates(job, obs, true).slice().sort((a,b)=> | |
| Number(a.cost_per_step)-Number(b.cost_per_step) || | |
| Number(b.speed_multiplier)-Number(a.speed_multiplier) || | |
| Number(a.vram_capacity)-Number(b.vram_capacity) | |
| ); | |
| return fits[0]||null; | |
| } | |
| function bestJobForGpu(gpu, obs){ | |
| return (obs.pending_jobs||[]) | |
| .filter(job=>gpu.vram_capacity>=job.vram_requirement) | |
| .slice() | |
| .sort((a,b)=> | |
| slackValue(a, obs)-slackValue(b, obs) || | |
| priorityRank(a.priority)-priorityRank(b.priority) || | |
| Number(b.pending_ticks)-Number(a.pending_ticks) || | |
| Number(b.vram_requirement)-Number(a.vram_requirement) | |
| )[0]||null; | |
| } | |
| function selectJob(jobId){ | |
| if(!state.observation){ return; } | |
| state.focusJobId=jobId||null; | |
| el("action").value="place"; | |
| updateControls(); | |
| el("job").value=jobId||""; | |
| setOptions(el("gpu"), gpuChoices(el("job").value), "Select an eligible GPU"); | |
| if(el("job").value && !el("gpu").value){ | |
| const job=(state.observation.pending_jobs||[]).find(item=>item.job_id===el("job").value); | |
| const best=job?bestGpuForJob(job, state.observation):null; | |
| if(best && [...el("gpu").options].some(option=>option.value===best.gpu_id)){ el("gpu").value=best.gpu_id; } | |
| } | |
| state.focusGpuId=el("gpu").value||state.focusGpuId; | |
| if(el("job").value && el("gpu").value){ | |
| el("rationale").value=`Place ${el("job").value} on ${el("gpu").value} because it best balances fit, slack, and current pending pressure.`.slice(0,240); | |
| } | |
| render(); | |
| } | |
| function selectGpu(gpuId){ | |
| if(!state.observation){ return; } | |
| state.focusGpuId=gpuId||null; | |
| el("action").value="place"; | |
| updateControls(); | |
| if([...el("gpu").options].some(option=>option.value===gpuId)){ el("gpu").value=gpuId; } | |
| if(!el("job").value){ | |
| const gpu=(state.observation.gpus||[]).find(item=>item.gpu_id===gpuId); | |
| const bestJob=gpu?bestJobForGpu(gpu, state.observation):null; | |
| if(bestJob && [...el("job").options].some(option=>option.value===bestJob.job_id)){ | |
| el("job").value=bestJob.job_id; | |
| setOptions(el("gpu"), gpuChoices(el("job").value), "Select an eligible GPU"); | |
| if([...el("gpu").options].some(option=>option.value===gpuId)){ el("gpu").value=gpuId; } | |
| } | |
| } | |
| state.focusJobId=el("job").value||state.focusJobId; | |
| if(el("job").value && el("gpu").value){ | |
| el("rationale").value=`Place ${el("job").value} on ${el("gpu").value} because it best balances fit, slack, and current pending pressure.`.slice(0,240); | |
| } | |
| render(); | |
| } | |
| function updateControls(){ | |
| const obs=state.observation; | |
| const queued=obs?obs.pending_jobs:[]; | |
| setOptions( | |
| el("job"), | |
| queued.map(j=>({ | |
| value:j.job_id, | |
| label:`${j.job_id} | ${j.priority} | deadline ${j.deadline} | runtime ${j.remaining_runtime} | VRAM ${j.vram_requirement}` | |
| })), | |
| "Select a pending job" | |
| ); | |
| setOptions(el("gpu"), gpuChoices(el("job").value), "Select an eligible GPU"); | |
| const waiting=el("action").value==="defer"; | |
| el("job").disabled=waiting||!queued.length; | |
| el("gpu").disabled=waiting||!el("gpu").options.length; | |
| if(waiting){ | |
| el("job").value=""; | |
| el("gpu").value=""; | |
| } | |
| } | |
| function pressureStats(obs){ | |
| const queued=obs.pending_jobs||[]; | |
| const allGpus=obs.gpus||[]; | |
| const urgent=queued.filter(job=>job.deadline-obs.current_tick<=2).length; | |
| const constrained=queued.filter(job=>fitCandidates(job, obs, false).length<=1).length; | |
| const idleFit=queued.filter(job=>fitCandidates(job, obs, true).length>0).length; | |
| return [ | |
| {label:"Deadline", value:queued.length?(urgent/queued.length)*100:0}, | |
| {label:"Constraint", value:queued.length?(constrained/queued.length)*100:0}, | |
| {label:"Schedulable", value:queued.length?(idleFit/queued.length)*100:0}, | |
| {label:"Allocated", value:allGpus.length?(allGpus.filter(gpu=>gpu.busy).length/allGpus.length)*100:0} | |
| ]; | |
| } | |
| function recommendation(obs){ | |
| const selectedAction=el("action").value; | |
| const selectedJobId=el("job").value; | |
| const selectedGpuId=el("gpu").value; | |
| if(selectedAction==="defer"){ | |
| return { | |
| title:"Defer placement", | |
| body:"Defer placement for one tick. Use this when no eligible GPU can absorb high-risk jobs without burning the wrong capacity tier." | |
| }; | |
| } | |
| if(selectedJobId && selectedGpuId){ | |
| return { | |
| title:`${selectedJobId} -> ${selectedGpuId}`, | |
| body:"Loaded into the placement form. Dispatch this if the selected GPU is still the right fit for the pending job." | |
| }; | |
| } | |
| const suggested=(obs.suggested_assignments||[])[0]; | |
| if(suggested){ | |
| return { | |
| title:`${txt(suggested.job_id)} -> ${txt(suggested.gpu_id)}`, | |
| body:txt(suggested.rationale||"Top suggestion from the environment.") | |
| }; | |
| } | |
| const fallbackJob=(obs.pending_jobs||[])[0]; | |
| const fallbackGpu=fallbackJob?bestGpuForJob(fallbackJob, obs):null; | |
| if(fallbackJob && fallbackGpu){ | |
| return { | |
| title:`${fallbackJob.job_id} -> ${fallbackGpu.gpu_id}`, | |
| body:"Lowest-cost eligible placement for the next pending job." | |
| }; | |
| } | |
| return { | |
| title:"No actionable placement", | |
| body:"Either the pending queue is empty or no eligible GPU can satisfy the current VRAM requirements." | |
| }; | |
| } | |
| function renderKpis(obs){ | |
| const m=obs.metrics||{}; | |
| const queued=obs.pending_jobs||[]; | |
| const running=obs.running_jobs||[]; | |
| const risk=queued.filter(job=>job.deadline-obs.current_tick<=2).length; | |
| const constrained=queued.filter(job=>fitCandidates(job, obs, false).length<=1).length; | |
| const allocationNow=(obs.gpus||[]).length ? ((obs.gpus||[]).filter(gpu=>gpu.busy).length/(obs.gpus||[]).length) : 0; | |
| const costNow=(obs.gpus||[]).filter(gpu=>gpu.busy).reduce((sum,gpu)=>sum+Number(gpu.cost_per_step||0),0); | |
| const cards=[ | |
| ["Pending", queued.length, `${queued.filter(job=>fitCandidates(job, obs, true).length===0).length} unschedulable now`], | |
| ["Running", running.length, `${(obs.gpus||[]).filter(gpu=>gpu.busy).length} allocated GPUs`], | |
| ["Deadline Pressure", risk, risk?`${risk} jobs within 2 ticks`:"No immediate deadline pressure"], | |
| ["GPU Allocation", pct(allocationNow), `${txt(m.completed_jobs||0)} completed`], | |
| ["Scarce Capacity", constrained, constrained?`${constrained} jobs fit 1 GPU tier`:"No single-fit jobs right now"], | |
| ["Cost Rate", f1(costNow), `${txt((obs.gpus||[]).filter(gpu=>gpu.busy).length)} GPUs active`] | |
| ]; | |
| el("kpis").innerHTML=cards.map(([label,value,detail])=>` | |
| <div class="kpi"> | |
| <small>${label}</small> | |
| <strong class="mono">${value}</strong> | |
| <span>${detail}</span> | |
| </div> | |
| `).join(""); | |
| } | |
| function renderQueueBoard(obs){ | |
| const queued=obs.pending_jobs||[]; | |
| const selectedJob=state.focusJobId||el("job").value; | |
| el("queuemeta").textContent=`${queued.length} jobs pending`; | |
| const ranked=queued.slice().sort((a,b)=> | |
| slackValue(a, obs)-slackValue(b, obs) || | |
| priorityRank(a.priority)-priorityRank(b.priority) || | |
| Number(b.pending_ticks)-Number(a.pending_ticks) | |
| ); | |
| el("queueops").innerHTML=ranked.length?ranked.map(job=>{ | |
| const fits=fitCandidates(job, obs, true); | |
| const best=bestGpuForJob(job, obs); | |
| const slack=slackValue(job, obs); | |
| const risk=slackClass(slack); | |
| const waitRisk=waitRiskClass(Number(job.pending_ticks||0)); | |
| const rowClass=["ops-row", `risk-${risk}`, selectedJob===job.job_id?"selected":""].filter(Boolean).join(" "); | |
| return `<tr class="${rowClass}" data-job="${job.job_id}"> | |
| <td class="mono">${job.job_id}</td> | |
| <td><span class="priority-cell"><span class="priority-dot ${priorityClass(job.priority)}"></span>${txt(job.priority)}</span></td> | |
| <td><span class="fit-count mono">${fits.length}</span></td> | |
| <td class="mono">${job.vram_requirement}</td> | |
| <td> | |
| <div class="slack-cell"> | |
| <span class="slack-pill ${risk} mono">${slackLabel(slack)}</span> | |
| <span class="cell-subtext mono">deadline d${job.deadline}</span> | |
| </div> | |
| </td> | |
| <td> | |
| <div class="slack-cell"> | |
| <span class="slack-pill ${waitRisk} mono">age ${job.pending_ticks}</span> | |
| <span class="cell-subtext">${best?"schedulable now":"blocked now"}</span> | |
| </div> | |
| </td> | |
| <td class="mono">${best?best.gpu_id:"defer"}</td> | |
| </tr>`; | |
| }).join(""):`<tr><td colspan="7" class="empty">No pending jobs remain.</td></tr>`; | |
| } | |
| function renderNodeInventory(obs){ | |
| const queued=obs.pending_jobs||[]; | |
| const running=obs.running_jobs||[]; | |
| const selectedGpu=state.focusGpuId||el("gpu").value; | |
| const totalVram=(obs.gpus||[]).reduce((sum,gpu)=>sum+Number(gpu.vram_capacity||0),0); | |
| el("clustermeta").textContent=`${obs.gpus.length} GPUs | ${totalVram} total VRAM`; | |
| el("nodeinventory").innerHTML=(obs.gpus||[]).length?(obs.gpus||[]).map(gpu=>{ | |
| const runningJob=running.find(job=>job.assigned_gpu_id===gpu.gpu_id)||null; | |
| const recommendedJob=!gpu.busy?bestJobForGpu(gpu, obs):null; | |
| const used=runningJob?Number(runningJob.vram_requirement||0):0; | |
| const fill=Math.max(0,Math.min(100,(used/Math.max(1,Number(gpu.vram_capacity||0)))*100)); | |
| const fitCount=queued.filter(job=>gpu.vram_capacity>=job.vram_requirement).length; | |
| const selected=["inventory-row", selectedGpu===gpu.gpu_id?"selected":""].filter(Boolean).join(" "); | |
| return `<div class="${selected}" data-gpu="${gpu.gpu_id}"> | |
| <div class="inventory-head"> | |
| <div> | |
| <strong class="mono">${gpu.gpu_id}</strong> | |
| <small>${gpu.busy?"allocated":"idle"} | speed ${f1(gpu.speed_multiplier)} | cost ${f1(gpu.cost_per_step)}</small> | |
| </div> | |
| <span class="chip ${gpu.busy?"warn":"good"}">${gpu.busy?"allocated":"idle"}</span> | |
| </div> | |
| <div class="capacity-track"> | |
| <div class="capacity-fill ${fill>=80?"busy-hot":""}" style="--fill:${fill}%"></div> | |
| </div> | |
| <div class="inventory-foot"> | |
| <span class="mono">${used}/${gpu.vram_capacity} VRAM used</span> | |
| <span class="mono">${gpu.busy ? txt(runningJob?.job_id||"loaded") : `${fitCount} eligible jobs`}</span> | |
| </div> | |
| <div class="inventory-context"> | |
| <div> | |
| <strong class="mono">${gpu.busy ? txt(runningJob?.job_id||"current load") : recommendedJob ? `Rec ${recommendedJob.job_id}` : "No eligible job"}</strong> | |
| <small>${gpu.busy | |
| ? `Current workload | pr ${txt(runningJob?.priority)} | ${used}/${gpu.vram_capacity} VRAM` | |
| : recommendedJob | |
| ? `Recommended placement | slack ${slackValue(recommendedJob, obs)} | pr ${txt(recommendedJob.priority)}` | |
| : "No pending job fits this GPU right now."}</small> | |
| </div> | |
| <span class="inventory-meta-pill ${gpu.busy ? "warn" : recommendedJob ? "good" : ""} mono">${gpu.busy ? "current" : recommendedJob ? "eligible" : "idle"}</span> | |
| </div> | |
| </div>`; | |
| }).join(""):`<div class="detail-card empty">No GPU state available.</div>`; | |
| } | |
| function renderSelectionDetail(obs){ | |
| const rec=recommendation(obs); | |
| const selectedJob=(obs.pending_jobs||[]).find(job=>job.job_id===(state.focusJobId||el("job").value))||null; | |
| const selectedGpu=(obs.gpus||[]).find(gpu=>gpu.gpu_id===(state.focusGpuId||el("gpu").value))||null; | |
| const busyCount=(obs.gpus||[]).filter(gpu=>gpu.busy).length; | |
| const idleCount=(obs.gpus||[]).length-busyCount; | |
| let body=` | |
| <span class="detail-label">Loaded decision</span> | |
| <div class="detail-main mono">${rec.title}</div> | |
| <div class="muted">${rec.body}</div> | |
| <div class="stat-grid"> | |
| <div class="mini-card"><small>Idle GPUs</small><strong class="mono">${idleCount}</strong></div> | |
| <div class="mini-card"><small>Allocated GPUs</small><strong class="mono">${busyCount}</strong></div> | |
| </div> | |
| `; | |
| if(selectedJob){ | |
| const fits=fitCandidates(selectedJob, obs, true); | |
| const slack=selectedJob.deadline-obs.current_tick; | |
| body+=` | |
| <hr /> | |
| <span class="detail-label">Selected job</span> | |
| <div class="detail-main mono">${selectedJob.job_id}</div> | |
| <div class="muted">Priority ${txt(selectedJob.priority)} | runtime ${f1(selectedJob.remaining_runtime)} | VRAM ${selectedJob.vram_requirement}</div> | |
| <div class="stat-grid"> | |
| <div class="mini-card"><small>Eligible GPUs</small><strong class="mono">${fits.length}</strong></div> | |
| <div class="mini-card"><small>Deadline slack</small><strong class="mono">${slack}</strong></div> | |
| </div> | |
| `; | |
| } | |
| if(selectedGpu){ | |
| body+=` | |
| <hr /> | |
| <span class="detail-label">Selected GPU</span> | |
| <div class="detail-main mono">${selectedGpu.gpu_id}</div> | |
| <div class="muted">${selectedGpu.busy?"Currently allocated":"Eligible for placement"} | speed ${f1(selectedGpu.speed_multiplier)} | cost ${f1(selectedGpu.cost_per_step)} | VRAM ${selectedGpu.vram_capacity}</div> | |
| `; | |
| } | |
| el("selectiondetail").innerHTML=body; | |
| } | |
| function renderSignals(obs){ | |
| const signalMarkup=pressureStats(obs).map(stat=>` | |
| <div class="signal-row"> | |
| <span>${stat.label}</span> | |
| <div class="signal-track"><div class="signal-fill" style="--fill:${stat.value.toFixed(0)}%"></div></div> | |
| <span class="mono">${stat.value.toFixed(0)}%</span> | |
| </div> | |
| `).join(""); | |
| const hint=txt(obs.action_hint||"Use pending demand, eligible GPUs, and deadline pressure to decide whether to place now or defer."); | |
| el("signals").innerHTML=` | |
| <span class="detail-label">Policy signals</span> | |
| <div class="muted">${hint}</div> | |
| <div class="signal-list">${signalMarkup}</div> | |
| `; | |
| } | |
| function renderSparkline(obs){ | |
| const history=(obs.action_log||[]).slice(-12); | |
| el("sparkmeta").textContent=history.length?`${history.length} ticks`:"No history"; | |
| if(!history.length){ | |
| el("sparkline").innerHTML=`<div class="empty">No decision history yet.</div>`; | |
| return; | |
| } | |
| let runningTotal=0; | |
| const values=history.map(entry=>{ | |
| runningTotal+=Number((entry.score_delta ?? entry.reward_delta) || 0); | |
| return runningTotal; | |
| }); | |
| const min=Math.min(...values); | |
| const max=Math.max(...values); | |
| const width=260; | |
| const height=78; | |
| const padX=8; | |
| const padY=10; | |
| const denom=(max-min)||1; | |
| const points=values.map((value,index)=>{ | |
| const x=padX + (index*(width-padX*2))/Math.max(1, values.length-1); | |
| const y=height-padY-((value-min)*(height-padY*2))/denom; | |
| return [x,y]; | |
| }); | |
| const path=points.map(([x,y],index)=>`${index===0?"M":"L"} ${x.toFixed(2)} ${y.toFixed(2)}`).join(" "); | |
| const last=values[values.length-1]; | |
| el("sparkline").innerHTML=` | |
| <svg viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" aria-hidden="true"> | |
| <g class="spark-grid"> | |
| <line x1="0" y1="${height-14}" x2="${width}" y2="${height-14}"></line> | |
| <line x1="0" y1="${height/2}" x2="${width}" y2="${height/2}"></line> | |
| <line x1="0" y1="14" x2="${width}" y2="14"></line> | |
| </g> | |
| <path class="spark-path" d="${path}"></path> | |
| ${points.map(([x,y],index)=>`<circle class="spark-dot" cx="${x.toFixed(2)}" cy="${y.toFixed(2)}" r="${index===points.length-1?3.4:2.2}"></circle>`).join("")} | |
| </svg> | |
| <div class="spark-axis"> | |
| <span class="mono">start</span> | |
| <span class="mono">score ${f1(last)}</span> | |
| </div> | |
| `; | |
| } | |
| function renderFooter(obs){ | |
| const completed=obs.completed_jobs||[]; | |
| const missed=obs.missed_jobs||[]; | |
| el("completeddock").innerHTML=completed.length | |
| ? completed.slice(-8).map(job=>`<span class="tiny-pill good mono">${job.job_id}</span>`).join("") | |
| : `<span class="empty">Nothing completed yet.</span>`; | |
| el("misseddock").innerHTML=missed.length | |
| ? missed.slice(-8).map(job=>`<span class="tiny-pill bad mono">${job.job_id}</span>`).join("") | |
| : `<span class="empty">No missed jobs.</span>`; | |
| } | |
| function renderEpisodeSummary(obs){ | |
| const summaryCard=el("episodesummary"); | |
| if(!state.done){ | |
| summaryCard.innerHTML=""; | |
| return; | |
| } | |
| const m=obs.metrics||{}; | |
| const completed=Number(m.completed_jobs||0); | |
| const missed=Number(m.missed_deadlines||0); | |
| const invalid=Number(m.invalid_actions||0); | |
| const verdict=missed===0 && invalid===0 | |
| ? "Clean finish: the scenario ended without missed deadlines or invalid moves." | |
| : missed>0 | |
| ? "The scenario finished, but deadline misses still left score on the table." | |
| : "The scenario finished, but invalid actions reduced the total score."; | |
| summaryCard.innerHTML=` | |
| <div class="detail-card"> | |
| <span class="detail-label">Run summary</span> | |
| <div class="detail-main">${verdict}</div> | |
| <div class="stat-grid"> | |
| <div class="mini-card"><small>Simulator score</small><strong class="mono">${f1(m.total_reward)}</strong></div> | |
| <div class="mini-card"><small>Completed jobs</small><strong class="mono">${completed}</strong></div> | |
| <div class="mini-card"><small>Missed deadlines</small><strong class="mono">${missed}</strong></div> | |
| <div class="mini-card"><small>Accumulated cost</small><strong class="mono">${f1(m.total_cost)}</strong></div> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| function renderStatus(obs){ | |
| const details=parseStepDetails(obs.last_step_details||obs.last_event||"Reset the environment to begin."); | |
| el("status").innerHTML=` | |
| <div class="status-box ${obs.last_step_valid_action==null?"":obs.last_step_valid_action?"good":"bad"}"> | |
| <strong>${obs.last_step_valid_action==null?"Ready":obs.last_step_valid_action?"Valid decision":"Invalid decision"}</strong> | |
| <div class="muted">${details.validation}</div> | |
| </div> | |
| <div class="status-box"> | |
| <strong>Scheduler event</strong> | |
| <div class="muted">${details.event}</div> | |
| </div> | |
| <div class="status-box"> | |
| <strong>Simulator score delta <span class="mono">${state.reward==null?"0.0":f1(state.reward)}</span></strong> | |
| <div class="muted">Run complete: ${state.done}</div> | |
| </div> | |
| `; | |
| } | |
| function renderTimeline(obs){ | |
| const logs=(obs.action_log||[]).slice(-6).reverse(); | |
| el("timeline").innerHTML=logs.length?logs.map(entry=>` | |
| <div class="timeline-entry"> | |
| <div class="timeline-meta"> | |
| <strong>Tick ${entry.tick_index ?? entry.step_index}</strong> | |
| <span class="chip ${entry.valid?"good":"bad"} mono">${entry.valid?"valid":"invalid"} | score ${f1(entry.score_delta ?? entry.reward_delta)}</span> | |
| </div> | |
| <div class="muted">${txt(entry.details)}</div> | |
| <div style="margin-top:8px">${txt(entry.rationale||"")}</div> | |
| </div> | |
| `).join(""):`<div class="detail-card empty">No decisions have been logged yet.</div>`; | |
| } | |
| function render(){ | |
| const obs=state.observation; | |
| if(!obs){ return; } | |
| el("summary").textContent=obs.summary; | |
| el("context").innerHTML=[ | |
| `<span class="chip mono">Scenario ${txt(obs.scenario_id)}</span>`, | |
| `<span class="chip ${state.done?"good":"warn"} mono">Run ${state.done?"complete":"active"}</span>`, | |
| `<span class="chip mono">Tick ${txt(obs.current_tick)}/${txt(obs.tick_limit)}</span>` | |
| ].join(""); | |
| renderKpis(obs); | |
| renderQueueBoard(obs); | |
| renderNodeInventory(obs); | |
| renderSelectionDetail(obs); | |
| renderSignals(obs); | |
| renderSparkline(obs); | |
| renderFooter(obs); | |
| renderEpisodeSummary(obs); | |
| renderStatus(obs); | |
| renderTimeline(obs); | |
| el("raw").textContent=JSON.stringify({reward:state.reward,done:state.done,observation:obs},null,2); | |
| updateControls(); | |
| } | |
| async function resetEnv(){ | |
| const payload={scenario_id:el("episode").value}; | |
| if(el("seed").value.trim()){ payload.seed=Number(el("seed").value); } | |
| const res=await fetch("/web/reset",{ | |
| method:"POST", | |
| headers:{"Content-Type":"application/json"}, | |
| body:JSON.stringify(payload) | |
| }); | |
| const body=await res.json(); | |
| const observation=body.observation; | |
| state.reward=body.reward??0; | |
| state.done=Boolean(body.done); | |
| state.observation=observation; | |
| state.focusJobId=null; | |
| state.focusGpuId=null; | |
| state.lastSelectedScenario=observation?.scenario_id||payload.scenario_id; | |
| el("episode").value=observation?.scenario_id&&["deadline_crunch","cost_pressure","wait_for_capacity","reserve_large_gpu"].includes(observation.scenario_id) | |
| ? observation.scenario_id | |
| : el("episode").value; | |
| el("rationale").value=""; | |
| render(); | |
| } | |
| async function refreshState(){ | |
| const res=await fetch("/web/state"); | |
| const body=await res.json(); | |
| state.reward=null; | |
| state.done=false; | |
| state.observation=normalize(body); | |
| state.focusJobId=null; | |
| state.focusGpuId=null; | |
| render(); | |
| } | |
| async function stepEnv(){ | |
| if(!state.observation){ | |
| await resetEnv(); | |
| return; | |
| } | |
| const action=el("action").value; | |
| const wrapped={ | |
| action:{ | |
| action, | |
| rationale:(el("rationale").value.trim()||(action==="defer" | |
| ? "Defer placement for a better scheduling window." | |
| : "Place the selected job on the selected GPU.")).slice(0,240), | |
| metadata:{} | |
| } | |
| }; | |
| if(action==="place"){ | |
| if(!el("job").value||!el("gpu").value){ | |
| el("status").innerHTML=`<div class="status-box bad"><strong>Action incomplete</strong><div class="muted">Choose both a pending job and an eligible GPU before dispatching.</div></div>`; | |
| return; | |
| } | |
| wrapped.action.job_id=el("job").value; | |
| wrapped.action.gpu_id=el("gpu").value; | |
| } | |
| const res=await fetch("/web/step",{ | |
| method:"POST", | |
| headers:{"Content-Type":"application/json"}, | |
| body:JSON.stringify(wrapped) | |
| }); | |
| const body=await res.json(); | |
| state.reward=body.reward; | |
| state.done=Boolean(body.done); | |
| state.observation=body.observation; | |
| state.focusJobId=el("job").value||state.focusJobId; | |
| state.focusGpuId=el("gpu").value||state.focusGpuId; | |
| render(); | |
| } | |
| el("action").addEventListener("change",()=>{ | |
| updateControls(); | |
| if(state.observation){ render(); } | |
| }); | |
| el("job").addEventListener("change",()=>{ | |
| state.focusJobId=el("job").value||null; | |
| setOptions(el("gpu"), gpuChoices(el("job").value), "Select an eligible GPU"); | |
| if(el("job").value && el("gpu").value){ | |
| el("rationale").value=`Place ${el("job").value} on ${el("gpu").value} because it best balances fit, slack, and current pending pressure.`.slice(0,240); | |
| } | |
| if(state.observation){ render(); } | |
| }); | |
| el("gpu").addEventListener("change",()=>{ | |
| state.focusGpuId=el("gpu").value||null; | |
| if(el("action").value==="place" && el("job").value && el("gpu").value){ | |
| el("rationale").value=`Place ${el("job").value} on ${el("gpu").value} because it best balances fit, slack, and current pending pressure.`.slice(0,240); | |
| } | |
| if(state.observation){ render(); } | |
| }); | |
| el("episode").addEventListener("change",()=>{ | |
| if(state.observation && el("episode").value!==state.observation.scenario_id){ el("rationale").value=""; } | |
| }); | |
| el("queueops").addEventListener("click",event=>{ | |
| const row=event.target.closest("[data-job]"); | |
| if(!row){ return; } | |
| selectJob(row.getAttribute("data-job")); | |
| }); | |
| el("nodeinventory").addEventListener("click",event=>{ | |
| const row=event.target.closest("[data-gpu]"); | |
| if(!row){ return; } | |
| selectGpu(row.getAttribute("data-gpu")); | |
| }); | |
| el("reset").addEventListener("click",resetEnv); | |
| el("refresh").addEventListener("click",refreshState); | |
| el("step").addEventListener("click",stepEnv); | |
| resetEnv(); | |
| </script> | |
| </body> | |
| </html>""" | |