Spaces:
Running
Running
feat(sentra-v4): exec-grade visual surface (/sentra/exec) + /exec-summary, /heatmap, /geo, gate weight tuning
86abfd0 verified | <!-- SPDX-License-Identifier: Apache-2.0 | |
| © 2026 Lutar, Stephen P. — SZL Holdings · ORCID 0009-0001-0110-4173 · Doctrine v11 | |
| Authored by Yachay (CTO). Co-Authored-By: Perplexity Computer Agent. | |
| Sentra v4 — EXECUTIVE-GRADE visual command surface. Sovereign: vanilla JS + | |
| inline SVG, no CDN, no external fonts. ADDITIVE — /sentra/inspect untouched. --> | |
| <html lang="en"><head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"> | |
| <title>Sentra · Executive Command</title> | |
| <style> | |
| :root{ | |
| --bg:#0A0E1A; --bg2:#0e1424; --card:#121a2e; --card2:#0d1322; | |
| --ink:#eef3fb; --mut:#8499b8; --dim:#5a6b87; --line:#1e2a44; | |
| --gold:#D4A444; --green:#34d399; --red:#fb5d6a; --amber:#f59e0b; | |
| --mono:ui-monospace,'JetBrains Mono','SF Mono',Menlo,Consolas,monospace; | |
| --sans:Inter,ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif; | |
| } | |
| *{box-sizing:border-box} | |
| body{margin:0;background:radial-gradient(1200px 600px at 70% -10%,#101a32 0,var(--bg) 55%); | |
| color:var(--ink);font-family:var(--sans);-webkit-font-smoothing:antialiased;letter-spacing:-.01em} | |
| .wrap{max-width:1320px;margin:0 auto;padding:0 24px 80px} | |
| /* ---- masthead ---- */ | |
| .mast{display:flex;align-items:baseline;justify-content:space-between;padding:26px 0 8px;border-bottom:1px solid var(--line)} | |
| .mast h1{font-size:21px;font-weight:700;margin:0;letter-spacing:-.02em} | |
| .mast h1 b{color:var(--gold)} | |
| .mast .meta{font-family:var(--mono);font-size:11px;color:var(--dim)} | |
| .dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--green);margin-right:6px; | |
| box-shadow:0 0 8px var(--green);animation:pulse 2s infinite} | |
| @keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}} | |
| /* ---- ticker ---- */ | |
| .ticker{margin:0;height:38px;overflow:hidden;background:var(--card2);border-bottom:1px solid var(--line); | |
| display:flex;align-items:center;font-family:var(--mono);font-size:12.5px;white-space:nowrap;position:relative} | |
| .ticker-track{display:inline-block;padding-left:100%;animation:scroll 38s linear infinite;will-change:transform} | |
| .ticker:hover .ticker-track{animation-play-state:paused} | |
| @keyframes scroll{0%{transform:translateX(0)}100%{transform:translateX(-100%)}} | |
| .tk{margin-right:30px}.tk .ok{color:var(--green)}.tk .no{color:var(--red);font-weight:700}.tk .t{color:var(--dim)}.tk .id{color:var(--mut)} | |
| /* ---- exec summary card ---- */ | |
| .execcard{margin:18px 0 4px;background:linear-gradient(100deg,var(--card),var(--card2));border:1px solid var(--line); | |
| border-left:3px solid var(--gold);border-radius:14px;padding:16px 20px;display:flex;align-items:center;gap:14px; | |
| font-size:15px;line-height:1.5;box-shadow:0 12px 40px -16px rgba(0,0,0,.7)} | |
| .execcard .sig{font-family:var(--mono);font-size:10.5px;color:var(--green);margin-left:auto;white-space:nowrap} | |
| .execcard .sig.unsigned{color:var(--amber)} | |
| /* ---- hero numbers ---- */ | |
| .hero{display:grid;grid-template-columns:repeat(3,1fr);gap:18px;margin:18px 0} | |
| .heronum{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:22px 24px;position:relative;overflow:hidden} | |
| .heronum .lab{font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--mut)} | |
| .heronum .big{font-size:72px;font-weight:800;line-height:1;margin:8px 0 2px;letter-spacing:-.04em;font-variant-numeric:tabular-nums} | |
| .heronum .sub{font-family:var(--mono);font-size:11.5px;color:var(--dim)} | |
| .heronum.g .big{color:var(--ink)} .heronum.p .big{color:var(--green)} .heronum.r .big{color:var(--red)} | |
| .heronum .spark{position:absolute;right:0;bottom:0;left:0;height:42px;opacity:.5} | |
| /* ---- section title ---- */ | |
| .sec{font-family:var(--mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--mut); | |
| margin:30px 0 12px;display:flex;align-items:center;gap:10px} | |
| .sec::after{content:"";flex:1;height:1px;background:var(--line)} | |
| /* ---- gate tiles ---- */ | |
| .gates{display:grid;grid-template-columns:repeat(4,1fr);gap:14px} | |
| @media(max-width:1000px){.gates{grid-template-columns:repeat(2,1fr)}.hero{grid-template-columns:1fr}} | |
| .tile{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px 16px;cursor:pointer; | |
| transition:transform .15s,border-color .15s,box-shadow .15s;position:relative} | |
| .tile:hover{transform:translateY(-3px);border-color:var(--gold);box-shadow:0 14px 36px -16px rgba(212,164,68,.4)} | |
| .tile .gn{font-size:13.5px;font-weight:600;margin:0 0 2px} | |
| .tile .gc{font-family:var(--mono);font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.06em} | |
| .badge{position:absolute;top:13px;right:14px;font-family:var(--mono);font-size:10px;font-weight:700; | |
| padding:2px 9px;border-radius:999px} | |
| .badge.armed{background:rgba(52,211,153,.14);color:var(--green)} | |
| .badge.firing{background:rgba(251,93,106,.16);color:var(--red)} | |
| .badge.idle{background:rgba(132,153,184,.12);color:var(--mut)} | |
| .sparkbar{display:flex;gap:2px;align-items:flex-end;height:30px;margin:14px 0 8px} | |
| .sparkbar i{flex:1;background:var(--green);border-radius:1px;min-height:2px;opacity:.85} | |
| .sparkbar i.bad{background:var(--red)} | |
| .tilefoot{display:flex;justify-content:space-between;font-family:var(--mono);font-size:11px} | |
| .tilefoot .pr{color:var(--green)} .tilefoot .rj{color:var(--red)} | |
| /* ---- heatmap ---- */ | |
| .heatcard,.geocard{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px 18px} | |
| .heatrow{display:flex;align-items:center;gap:10px;margin:3px 0} | |
| .heatrow .hl{width:150px;font-family:var(--mono);font-size:11px;color:var(--mut);text-align:right;flex:none} | |
| .heatcells{display:flex;gap:2px;flex:1} | |
| .heatcells i{flex:1;height:16px;border-radius:2px;background:#16203a} | |
| .heataxis{display:flex;justify-content:space-between;font-family:var(--mono);font-size:10px;color:var(--dim); | |
| margin:8px 0 0 160px} | |
| .legend{display:flex;gap:14px;align-items:center;font-family:var(--mono);font-size:10.5px;color:var(--mut);margin-top:10px} | |
| .legend .sw{display:inline-block;width:11px;height:11px;border-radius:2px;margin-right:5px;vertical-align:-1px} | |
| /* ---- geo ---- */ | |
| .geowrap{position:relative} | |
| .geowrap svg{width:100%;height:auto;display:block} | |
| .geowrap .land{fill:#16203a;stroke:#1e2a44;stroke-width:.5} | |
| .gp{transition:r .3s} | |
| /* ---- drill modal ---- */ | |
| .modal{position:fixed;inset:0;background:rgba(5,8,16,.78);backdrop-filter:blur(4px);display:none; | |
| align-items:center;justify-content:center;z-index:50;padding:24px} | |
| .modal.on{display:flex} | |
| .mbox{background:var(--card);border:1px solid var(--line);border-radius:16px;max-width:880px;width:100%; | |
| padding:22px 24px;box-shadow:0 30px 80px -20px #000} | |
| .mbox h3{margin:0 0 2px;font-size:17px}.mbox .msub{font-family:var(--mono);font-size:11px;color:var(--mut);margin-bottom:16px} | |
| .mbox .x{float:right;cursor:pointer;color:var(--mut);font-size:22px;line-height:1} | |
| .chart{display:flex;align-items:flex-end;gap:3px;height:160px;border-bottom:1px solid var(--line);padding-bottom:0} | |
| .chart i{flex:1;border-radius:2px 2px 0 0;min-height:2px;background:var(--green)} | |
| .chart i.bad{background:var(--red)} | |
| .mstats{display:flex;gap:26px;margin-top:14px;font-family:var(--mono);font-size:12px;color:var(--mut)} | |
| .mstats b{color:var(--ink);font-size:18px;display:block} | |
| .foot{margin-top:36px;border-top:1px solid var(--line);padding-top:14px;font-family:var(--mono);font-size:11px;color:var(--dim);line-height:1.7} | |
| .foot a{color:var(--gold);text-decoration:none} | |
| </style></head> | |
| <body> | |
| <div class="ticker" id="ticker"><div class="ticker-track" id="ttrack"><span class="tk t">awaiting live verdicts…</span></div></div> | |
| <div class="wrap"> | |
| <div class="mast"> | |
| <h1>Sentra <b>·</b> Executive Command</h1> | |
| <div class="meta"><span class="dot"></span><span id="status">live</span> · 8 fail-CLOSED gates · Doctrine v11 749/14/163 · <span id="clock">--:--:--</span></div> | |
| </div> | |
| <!-- exec summary card --> | |
| <div class="execcard"> | |
| <span id="exec-sentence">Loading live posture…</span> | |
| <span class="sig" id="exec-sig">receipt…</span> | |
| </div> | |
| <!-- hero numbers --> | |
| <div class="hero"> | |
| <div class="heronum g"><div class="lab">Verdicts Today</div><div class="big" id="h-total">—</div><div class="sub">inspections processed</div> | |
| <svg class="spark" id="sp-total" viewBox="0 0 100 42" preserveAspectRatio="none"></svg></div> | |
| <div class="heronum p"><div class="lab">Pass Rate</div><div class="big" id="h-pass">—</div><div class="sub" id="h-pass-sub">clean admissions</div> | |
| <svg class="spark" id="sp-pass" viewBox="0 0 100 42" preserveAspectRatio="none"></svg></div> | |
| <div class="heronum r"><div class="lab">Reject Rate</div><div class="big" id="h-rej">—</div><div class="sub" id="h-rej-sub">blocked at the door</div> | |
| <svg class="spark" id="sp-rej" viewBox="0 0 100 42" preserveAspectRatio="none"></svg></div> | |
| </div> | |
| <!-- gate tiles --> | |
| <div class="sec">8 Immune Gates · click any tile to drill into last 100 invocations</div> | |
| <div class="gates" id="gates"></div> | |
| <!-- heatmap --> | |
| <div class="sec">Reject Heat-Map · gate × time · last 30 min, 30-second cells</div> | |
| <div class="heatcard"> | |
| <div id="heatrows"></div> | |
| <div class="heataxis"><span>−30 min</span><span>−20 min</span><span>−10 min</span><span>now</span></div> | |
| <div class="legend"><span>reject intensity:</span> | |
| <span><i class="sw" style="background:#16203a"></i>0</span> | |
| <span><i class="sw" style="background:#5a2530"></i>low</span> | |
| <span><i class="sw" style="background:#a32b3c"></i>med</span> | |
| <span><i class="sw" style="background:#fb5d6a"></i>high</span></div> | |
| </div> | |
| <!-- geo threat map --> | |
| <div class="sec">Geographic Threat Origin · reject events by rough geo</div> | |
| <div class="geocard geowrap" id="geocard"></div> | |
| <div class="foot"> | |
| Sovereign surface — vanilla JS + inline SVG, no CDN. Fail-CLOSED AND-gate: verdict is PASS only if all 8 gates pass; any gate error ⇒ REJECT. | |
| Every endpoint emits a DSSE-signed Khipu receipt (real ECDSA-P256 cosign when the secret is present; honest UNSIGNED otherwise). | |
| Doctrine v11 LOCKED 749/14/163. The text-heavy operator console remains live at <a href="/sentra/inspect">/sentra/inspect</a>. | |
| Endpoints: <a href="/api/sentra/v4/exec-summary">/api/sentra/v4/exec-summary</a> · /api/sentra/v4/heatmap · /api/sentra/v4/geo · POST /api/sentra/v4/gates/{name}/weight. | |
| </div> | |
| </div> | |
| <!-- drill-down modal --> | |
| <div class="modal" id="modal"><div class="mbox"> | |
| <span class="x" onclick="closeModal()">×</span> | |
| <h3 id="m-title">gate</h3><div class="msub" id="m-sub"></div> | |
| <div class="chart" id="m-chart"></div> | |
| <div class="mstats" id="m-stats"></div> | |
| </div></div> | |
| <script> | |
| ; | |
| const API="/api/sentra/v4"; | |
| let GATES=[], VERDICTS=[], HEAT=null; | |
| function pct(x){return x==null?"—":(x*100).toFixed(1)+"%";} | |
| function hhmmss(iso){return (iso||"").slice(11,19);} | |
| function el(id){return document.getElementById(id);} | |
| // ---- clock ---- | |
| setInterval(()=>{el("clock").textContent=new Date().toISOString().slice(11,19)+"Z";},1000); | |
| // ---- ticker (poll verdicts every 2s) ---- | |
| async function pollVerdicts(){ | |
| try{ | |
| const r=await fetch(API+"/verdicts?limit=50"); const d=await r.json(); | |
| VERDICTS=d.verdicts||[]; | |
| const track=el("ttrack"); | |
| if(!VERDICTS.length){track.innerHTML='<span class="tk t">awaiting live verdicts…</span>';return;} | |
| let html=""; | |
| VERDICTS.forEach(v=>{ | |
| const pass=v.verdict==="PASS"; | |
| const id=(v.action_preview||"").replace(/[<>]/g,"").slice(0,22)||"action"; | |
| html+=`<span class="tk"><span class="${pass?'ok':'no'}">${pass?'✓ pass':'✗ REJECT'}</span> ` | |
| +`<span class="id">${id}</span> <span class="t">@ ${hhmmss(v.ts)}</span></span>`; | |
| }); | |
| track.innerHTML=html+html; // duplicate for seamless scroll | |
| }catch(e){} | |
| } | |
| // ---- hero + exec summary (every 60s, hero also on verdict poll) ---- | |
| function sparkPath(svgId,vals,color){ | |
| const svg=el(svgId);if(!svg)return;const n=vals.length||1;const mx=Math.max(1,...vals); | |
| let d="";vals.forEach((v,i)=>{const x=i/(n-1||1)*100;const y=42-(v/mx)*38;d+=(i?"L":"M")+x.toFixed(1)+" "+y.toFixed(1)+" ";}); | |
| const area=d+`L100 42 L0 42 Z`; | |
| svg.innerHTML=`<path d="${area}" fill="${color}" opacity=".12"/><path d="${d}" fill="none" stroke="${color}" stroke-width="1.5"/>`; | |
| } | |
| async function pollExec(){ | |
| try{ | |
| const r=await fetch(API+"/exec-summary"); const d=await r.json(); | |
| el("exec-sentence").textContent=d.sentence||""; | |
| const sig=el("exec-sig"); const signed=d.receipt&&d.receipt.signed; | |
| sig.textContent=signed?("◈ signed "+(d.receipt.receipt_sha||"").slice(0,22)):"◈ unsigned receipt"; | |
| sig.className="sig"+(signed?"":" unsigned"); | |
| el("h-total").textContent=(d.total_today||0).toLocaleString(); | |
| el("h-pass").textContent=pct(d.pass_rate); | |
| el("h-rej").textContent=pct(d.reject_rate); | |
| el("h-pass-sub").textContent=(d.pass_today||0).toLocaleString()+" passed"; | |
| el("h-rej-sub").textContent=(d.reject_today||0).toLocaleString()+" rejected"; | |
| }catch(e){} | |
| } | |
| // build last-60s reject sparkline per hero from verdicts | |
| function heroSparks(){ | |
| const now=Date.now()/1000; const buckets=20; const span=60; | |
| const tot=new Array(buckets).fill(0),ps=new Array(buckets).fill(0),rj=new Array(buckets).fill(0); | |
| VERDICTS.forEach(v=>{const age=now-(v.ts_epoch||0);if(age<0||age>span)return; | |
| const b=buckets-1-Math.floor(age/span*buckets);if(b<0||b>=buckets)return; | |
| tot[b]++; if(v.verdict==="PASS")ps[b]++;else rj[b]++;}); | |
| sparkPath("sp-total",tot,"#8499b8");sparkPath("sp-pass",ps,"#34d399");sparkPath("sp-rej",rj,"#fb5d6a"); | |
| } | |
| // ---- gate tiles ---- | |
| function gateState(g){ | |
| if(!g.evaluations)return"idle"; | |
| if(g.reject_rate&&g.reject_rate>0.05)return"firing"; | |
| return"armed"; | |
| } | |
| // per-gate last-60s sparkbar from verdicts ring | |
| function gateSpark(gid){ | |
| const now=Date.now()/1000;const buckets=18;const span=60;const arr=new Array(buckets).fill(0);const bad=new Array(buckets).fill(0); | |
| VERDICTS.forEach(v=>{const age=now-(v.ts_epoch||0);if(age<0||age>span)return; | |
| const b=buckets-1-Math.floor(age/span*buckets);if(b<0||b>=buckets)return;arr[b]++; | |
| if((v.rejected_gates||[]).includes(gid))bad[b]++;}); | |
| return arr.map((c,i)=>`<i class="${bad[i]?'bad':''}" style="height:${Math.min(30,4+c*6)}px"></i>`).join(""); | |
| } | |
| async function pollGates(){ | |
| try{ | |
| const r=await fetch(API+"/gates"); const d=await r.json(); GATES=d.gates||[]; | |
| const wrap=el("gates");wrap.innerHTML=""; | |
| GATES.forEach(g=>{ | |
| const st=gateState(g);const stxt=st==="firing"?"FIRING":st==="armed"?"ARMED":"IDLE"; | |
| const div=document.createElement("div");div.className="tile";div.onclick=()=>drill(g); | |
| div.innerHTML=`<span class="badge ${st}">${stxt}</span>` | |
| +`<div class="gn">${g.label}</div><div class="gc">${g.id} · ${g.category}</div>` | |
| +`<div class="sparkbar">${gateSpark(g.id)}</div>` | |
| +`<div class="tilefoot"><span class="pr">pass ${pct(g.pass_rate)}</span>` | |
| +`<span class="rj">✗ ${g.reject||0}</span></div>`; | |
| wrap.appendChild(div); | |
| }); | |
| }catch(e){} | |
| } | |
| // ---- drill-down: last 100 invocations of a gate ---- | |
| function drill(g){ | |
| el("m-title").textContent=g.label; | |
| el("m-sub").textContent=g.id+" · "+g.category+" · fail-CLOSED · "+(g.evaluations||0)+" evaluations"; | |
| // chart: per verdict in ring, did this gate reject? show last 100 as bars (height=1, red if rejected) | |
| const rows=VERDICTS.slice(0,100).reverse(); | |
| const chart=el("m-chart"); | |
| if(!rows.length){chart.innerHTML='<i style="height:100%;background:#16203a"></i>';} | |
| else chart.innerHTML=rows.map(v=>{ | |
| const bad=(v.rejected_gates||[]).includes(g.id); | |
| return `<i class="${bad?'bad':''}" style="height:${bad?100:22}%" title="${v.ts} ${v.verdict}"></i>`; | |
| }).join(""); | |
| const rejN=rows.filter(v=>(v.rejected_gates||[]).includes(g.id)).length; | |
| el("m-stats").innerHTML= | |
| `<div><b>${g.evaluations||0}</b>total evals</div>` | |
| +`<div><b>${pct(g.pass_rate)}</b>pass rate</div>` | |
| +`<div><b style="color:var(--red)">${g.reject||0}</b>rejects (all-time)</div>` | |
| +`<div><b>${rejN}</b>rejects (last 100)</div>`; | |
| el("modal").classList.add("on"); | |
| } | |
| function closeModal(){el("modal").classList.remove("on");} | |
| el("modal").addEventListener("click",e=>{if(e.target.id==="modal")closeModal();}); | |
| // ---- heatmap ---- | |
| function heatColor(v,mx){ | |
| if(!v)return"#16203a";const t=mx?v/mx:0; | |
| if(t<0.34)return"#5a2530";if(t<0.67)return"#a32b3c";return"#fb5d6a"; | |
| } | |
| async function pollHeat(){ | |
| try{ | |
| const r=await fetch(API+"/heatmap"); const d=await r.json(); HEAT=d; | |
| const wrap=el("heatrows");wrap.innerHTML="";const mx=d.max_cell||1; | |
| (d.gates||[]).forEach(g=>{ | |
| const cells=g.cells.map(c=>`<i style="background:${heatColor(c,mx)}" title="${c} rejects"></i>`).join(""); | |
| const row=document.createElement("div");row.className="heatrow"; | |
| row.innerHTML=`<span class="hl">${g.label}</span><div class="heatcells">${cells}</div>`; | |
| wrap.appendChild(row); | |
| }); | |
| }catch(e){} | |
| } | |
| // ---- geo threat map (inline equirectangular world, no CDN) ---- | |
| const WORLD_PATH="M158 92 L210 78 L262 70 L320 66 L388 70 L452 82 L510 98 L548 120 L560 150 L540 180 L500 196 L440 200 L380 196 L320 188 L262 182 L210 170 L172 144 Z M598 96 L660 84 L720 92 L740 120 L724 150 L680 162 L632 150 L606 124 Z M250 220 L300 214 L330 240 L322 290 L296 320 L268 300 L252 260 Z M150 110 L120 140 L130 170 L160 170 L168 140 Z M600 200 L650 196 L672 222 L658 256 L620 262 L596 232 Z"; | |
| function lonlat(lon,lat){return [ (lon+180)/360*800, (90-lat)/180*400 ];} | |
| async function pollGeo(){ | |
| try{ | |
| const r=await fetch(API+"/geo"); const d=await r.json(); | |
| const pts=(d.points||[]).filter(p=>p.region!=="unknown"); | |
| // aggregate by region | |
| const agg={};pts.forEach(p=>{const k=p.region;(agg[k]=agg[k]||{...p,n:0}).n++;}); | |
| let dots="";Object.values(agg).forEach(p=>{const[x,y]=lonlat(p.lon,p.lat);const r=4+Math.min(14,p.n*2); | |
| dots+=`<circle class="gp" cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${r}" fill="${p.color}" opacity=".75">` | |
| +`<title>${p.region} · ${p.n} reject(s) · ${p.category}</title></circle>` | |
| +`<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${r}" fill="none" stroke="${p.color}" stroke-width="1" opacity=".4"><animate attributeName="r" from="${r}" to="${r+12}" dur="2s" repeatCount="indefinite"/><animate attributeName="opacity" from=".5" to="0" dur="2s" repeatCount="indefinite"/></circle>`;}); | |
| const unk=(d.points||[]).length-pts.length; | |
| el("geocard").innerHTML=`<svg viewBox="0 0 800 400" preserveAspectRatio="xMidYMid meet">` | |
| +`<path class="land" d="${WORLD_PATH}"/>${dots}</svg>` | |
| +`<div class="legend"><span>threat class:</span>` | |
| +`<span><i class="sw" style="background:#ff5252"></i>SQLi/XSS/inject</span>` | |
| +`<span><i class="sw" style="background:#ff9800"></i>size-DoS</span>` | |
| +`<span><i class="sw" style="background:#e040fb"></i>STIX indicator</span>` | |
| +`<span><i class="sw" style="background:#ffd54f"></i>Λ-gate</span>` | |
| +(unk?`<span style="color:var(--dim)">· ${unk} ungeolocated</span>`:"")+`</div>`; | |
| }catch(e){} | |
| } | |
| // ---- orchestration ---- | |
| async function fast(){ await pollVerdicts(); heroSparks(); await pollGates(); } | |
| async function slow(){ await pollExec(); await pollHeat(); await pollGeo(); } | |
| fast(); slow(); | |
| setInterval(fast,2000); // ticker + hero sparks + gate tiles (real-time) | |
| setInterval(slow,60000); // exec card + heatmap + geo (every 60s) | |
| setInterval(pollHeat,15000); // heatmap a little faster for attack-pattern spotting | |
| </script> | |
| </body></html> | |