← back to Harness Viewer
index.html
195 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<script>
// Credential-safe fetch guard (fleet drop-in) — if this page is opened with
// credentials in the URL (a saved bookmark / Chrome-remembered basic-auth:
// https://user:pass@host/…), the browser poisons document.baseURI, so any
// relative or root-relative fetch('/api/…') throws "Request cannot be constructed
// from a URL that includes credentials" and callers silently fall to an empty
// state. Resolve every non-absolute request URL against the credential-free
// location instead of baseURI. Placed first so it wraps window.fetch before any
// app script runs. Ref: creds-in-url-fetch-guard-fleet-pattern.
(function () {
var _fetch = window.fetch.bind(window);
var cleanBase = function () { return location.origin + location.pathname; };
window.fetch = function (input, init) {
try {
if (typeof input === 'string' && !/^[a-z]+:\/\//i.test(input) && input.indexOf('//') !== 0) {
input = new URL(input, cleanBase()).href;
} else if (input instanceof Request && !/^[a-z]+:\/\//i.test(input.url)) {
input = new Request(new URL(input.url, cleanBase()).href, input);
}
} catch (_) { /* fall through to native */ }
return _fetch(input, init);
};
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Harness Cockpit</title>
<style>
:root{--bg:#0f1117;--panel:#171a22;--panel2:#1d212b;--line:#272c38;--fg:#e6e9ef;--dim:#8a92a6;--accent:#5aa9ff;--green:#3fb950;--red:#f85149;--yellow:#d29922;--blue:#58a6ff;--purple:#bc8cff;}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font:13px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,monospace}
a{color:var(--accent);text-decoration:none}
header{position:sticky;top:0;z-index:10;background:linear-gradient(180deg,#12141b,#0f1117);border-bottom:1px solid var(--line);padding:10px 16px;display:flex;flex-wrap:wrap;align-items:center;gap:14px}
header h1{font-size:15px;margin:0;font-weight:600;letter-spacing:.3px}
.counts{display:flex;flex-wrap:wrap;gap:8px;align-items:center;font-size:12px}
.pill{background:var(--panel2);border:1px solid var(--line);border-radius:12px;padding:3px 10px;color:var(--dim)}
.pill b{color:var(--fg)}
.grow{flex:1}
.updated{color:var(--dim);font-size:11px}
.chiprow{display:flex;flex-wrap:wrap;gap:8px;padding:12px 16px 4px}
.chip{display:flex;flex-direction:column;gap:2px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:8px 12px;min-width:150px}
.chip .top{display:flex;align-items:center;gap:7px;font-weight:600}
.dot{width:9px;height:9px;border-radius:50%;flex:none}
.dot.up{background:var(--green);box-shadow:0 0 6px var(--green)}
.dot.down{background:var(--red);box-shadow:0 0 6px var(--red)}
.chip .ep{color:var(--dim);font-size:11px;word-break:break-all}
.chip .pt{color:var(--dim);font-size:10px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(360px,1fr));gap:14px;padding:14px 16px 40px}
.panel{background:var(--panel);border:1px solid var(--line);border-radius:10px;overflow:hidden;display:flex;flex-direction:column;max-height:560px}
.panel h2{margin:0;font-size:12px;text-transform:uppercase;letter-spacing:.6px;color:var(--dim);padding:10px 14px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:8px;background:var(--panel2)}
.panel h2 .n{color:var(--fg);background:#0f1117;border:1px solid var(--line);border-radius:10px;padding:1px 8px;font-size:11px}
.panel .body{overflow:auto;padding:4px 0}
.row{padding:7px 14px;border-bottom:1px solid #1e222c}
.row:last-child{border-bottom:none}
.row .l1{display:flex;align-items:center;gap:8px;justify-content:space-between}
.row .name{font-weight:600;color:var(--fg)}
.row .meta{color:var(--dim);font-size:11px;white-space:nowrap}
.row .txt{color:#c3c9d6;font-size:12px;margin-top:2px;word-break:break-word}
.badge{font-size:10px;font-weight:700;border-radius:5px;padding:1px 7px;letter-spacing:.4px}
.b-DOING{background:#153a5b;color:#79c0ff}
.b-BLOCKED{background:#5b2a15;color:#ffa657}
.b-DONE{background:#183a1f;color:#56d364}
.ages{display:flex;gap:6px;margin-left:auto}
.age{font-size:10px;border-radius:10px;padding:1px 8px;border:1px solid var(--line);color:var(--dim)}
.age.hot{color:#ffa657;border-color:#5b2a15}
.memo-link{cursor:pointer}
.memo-link:hover{color:var(--accent)}
.undo{color:#7d8598;font-size:11px;margin-top:2px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all}
button{background:var(--panel2);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:4px 10px;font-size:11px;cursor:pointer}
button:hover{border-color:var(--accent)}
pre{white-space:pre-wrap;word-break:break-word;margin:0;padding:10px 14px;font:11px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;color:#c3c9d6}
.modal{position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;align-items:center;justify-content:center;z-index:50;padding:20px}
.modal.show{display:flex}
.modal .box{background:var(--panel);border:1px solid var(--line);border-radius:10px;max-width:820px;width:100%;max-height:80vh;display:flex;flex-direction:column}
.modal .box h3{margin:0;padding:12px 16px;border-bottom:1px solid var(--line);font-size:13px;display:flex;justify-content:space-between;align-items:center}
.modal .box .c{overflow:auto}
.empty{color:var(--dim);padding:14px;font-size:12px}
.spin{color:var(--dim)}
</style>
</head>
<body>
<header>
<h1>🛰 Harness Cockpit</h1>
<div class="counts" id="counts"><span class="spin">loading…</span></div>
<div class="grow"></div>
<div class="updated" id="updated"></div>
</header>
<div class="chiprow" id="chips"></div>
<div class="grid">
<section class="panel"><h2>Live Lanes <span class="n" id="n-lanes">0</span><span class="grow" style="flex:1"></span></h2><div class="body" id="p-lanes"></div></section>
<section class="panel"><h2>Tickets <span class="n" id="n-tickets">0</span></h2><div class="body" id="p-tickets"></div></section>
<section class="panel"><h2>Gate Queue <span class="n" id="n-gate">0</span></h2><div class="body" id="p-gate"></div></section>
<section class="panel"><h2>Reversible Ledger <span class="n" id="n-ledger">0</span></h2><div class="body" id="p-ledger"></div></section>
<section class="panel" style="grid-column:1/-1">
<h2>Overnight Audit <span class="grow" style="flex:1"></span><button id="run-audit">Run audit</button></h2>
<div class="body"><pre id="p-audit" class="empty">On-demand — click "Run audit" to run audit-overnight-closes.mjs (proof-join of closes backed by evidence vs not). Not part of auto-refresh.</pre></div>
</section>
</div>
<div class="modal" id="modal"><div class="box"><h3><span id="m-title">memo</span><button onclick="closeModal()">close</button></h3><div class="c"><pre id="m-body"></pre></div></div></div>
<script>
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
function ago(iso){ if(!iso) return ''; const d=Date.now()-Date.parse(iso); if(isNaN(d)) return ''; const m=Math.floor(d/60000); if(m<1) return 'just now'; if(m<60) return m+'m ago'; const h=Math.floor(m/60); if(h<24) return h+'h '+(m%60)+'m ago'; return Math.floor(h/24)+'d ago'; }
async function load(){
let s;
try{ const r=await fetch('/api/state'); s=await r.json(); }
catch(e){ $('counts').innerHTML='<span class="pill">fetch failed — retrying</span>'; return; }
render(s);
}
function render(s){
// header counts
$('counts').innerHTML =
pill('engines','<b>'+s.enginesUp+'</b>/'+s.enginesTotal+' up') +
pill('active lanes','<b>'+(s.lanes?s.lanes.length:0)+'</b>') +
pill('tickets DOING','<b>'+(s.doing||0)+'</b>') +
pill('gated','<b>'+(s.gate?s.gate.total:0)+'</b>');
$('updated').textContent = 'updated '+ago(s.ts);
// engine chips
$('chips').innerHTML = (s.engines||[]).map(e=>
'<div class="chip"><div class="top"><span class="dot '+(e.up?'up':'down')+'"></span>'+esc(e.label)+'</div>'+
'<div class="ep">'+esc(e.endpoint)+'</div><div class="pt">probed '+ago(e.ts)+'</div></div>').join('');
// lanes
$('n-lanes').textContent = (s.lanes||[]).length;
$('p-lanes').innerHTML = (s.lanes&&s.lanes.length) ? s.lanes.map(l=>
'<div class="row"><div class="l1"><span class="name">'+esc(l.agent)+'</span>'+
'<span class="meta">'+l.actions+' act · '+ago(l.ts)+'</span></div>'+
'<div class="txt">'+esc(l.lastText)+'</div></div>').join('')
: '<div class="empty">No agent activity in the last 30 minutes.</div>';
// tickets
$('n-tickets').textContent = (s.tickets||[]).length;
$('p-tickets').innerHTML = (s.tickets&&s.tickets.length) ? s.tickets.map(t=>
'<div class="row"><div class="l1"><span class="name">'+esc(t.id)+' <span class="badge b-'+esc(t.state)+'">'+esc(t.state)+'</span></span>'+
'<span class="meta">'+esc(t.agent||'—')+' · '+ago(t.ts)+'</span></div>'+
'<div class="txt">'+esc(t.lastText)+'</div></div>').join('')
: '<div class="empty">No recent ticket activity.</div>';
// gate queue
const g=s.gate||{total:0,newest:[]};
$('n-gate').textContent = g.total;
let ghdr='<div class="row"><div class="l1"><span class="name">'+g.total+' pending memos</span>'+
'<span class="ages"><span class="age'+(g.p3?' hot':'')+'">>3h: '+g.p3+'</span>'+
'<span class="age'+(g.p24?' hot':'')+'">>24h: '+g.p24+'</span>'+
'<span class="age'+(g.p7d?' hot':'')+'">>7d: '+g.p7d+'</span></span></div></div>';
const grows=(g.newest||[]).map(m=>
'<div class="row"><div class="l1"><span class="name memo-link" onclick="openMemo(\''+esc(m.f).replace(/'/g,"\\'")+'\')">'+esc(m.f)+'</span>'+
'<span class="meta">'+ago(m.ts)+'</span></div></div>').join('');
$('p-gate').innerHTML = ghdr + (grows || '<div class="empty">Queue empty.</div>');
// ledger
$('n-ledger').textContent = (s.ledger||[]).length;
$('p-ledger').innerHTML = (s.ledger&&s.ledger.length) ? s.ledger.map(r=>
'<div class="row"><div class="l1"><span class="name">'+esc(r.agent||'—')+'</span>'+
'<span class="meta">'+esc(r.ticket||'')+' · '+ago(r.ts)+'</span></div>'+
'<div class="txt">'+esc(r.action)+'</div>'+
(r.undo_cmd?'<div class="undo">↩ '+esc(r.undo_cmd)+'</div>':'')+'</div>').join('')
: '<div class="empty">No ledger rows.</div>';
}
function pill(k,v){ return '<span class="pill">'+k+' '+v+'</span>'; }
async function openMemo(f){
$('m-title').textContent=f; $('m-body').textContent='loading…'; $('modal').classList.add('show');
try{ const r=await fetch('/api/memo?f='+encodeURIComponent(f)); const j=await r.json();
$('m-body').textContent = j.error ? ('error: '+j.error) : (j.lines||'(empty)'); }
catch(e){ $('m-body').textContent='fetch failed'; }
}
function closeModal(){ $('modal').classList.remove('show'); }
$('modal').addEventListener('click',e=>{ if(e.target.id==='modal') closeModal(); });
$('run-audit').addEventListener('click', async ()=>{
const btn=$('run-audit'); btn.disabled=true; btn.textContent='running…';
$('p-audit').className=''; $('p-audit').textContent='running audit-overnight-closes.mjs (up to 30s)…';
try{ const r=await fetch('/api/audit'); const j=await r.json();
$('p-audit').textContent = j.error ? ('error: '+j.error) : (j.output||'(no output)'); }
catch(e){ $('p-audit').textContent='fetch failed'; }
finally{ btn.disabled=false; btn.textContent='Run audit'; }
});
load();
setInterval(load, 10000); // auto-refresh 10s (audit excluded)
</script>
</body>
</html>