<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>floDl Training Dashboard</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#0f1117;--card:#1a1d27;--border:#2a2d3a;--text:#e4e6eb;--dim:#8b8fa3;
--accent:#6c8cff;--green:#34d399;--red:#f87171;--yellow:#fbbf24;--purple:#a78bfa;
--cyan:#67e8f9;
--tooltip-bg:rgba(26,29,39,0.95);--paper:#fff}
:root[data-theme="light"]{--bg:#fbfbfc;--card:#ffffff;--border:#dde0e8;
--text:#1a1d27;--dim:#5a5f72;--accent:#3656c7;--green:#0e9968;--red:#c93c3c;
--yellow:#8f5b10;--purple:#6e4ad0;--cyan:#0b7b95;
--tooltip-bg:rgba(26,29,39,0.95);--paper:#fbfbfc}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
background:var(--bg);color:var(--text);padding:0;min-height:100vh}
.header{background:var(--card);border-bottom:1px solid var(--border);padding:16px 24px;
display:flex;flex-direction:column;gap:8px}
.header-row{display:flex;align-items:center;gap:24px;flex-wrap:wrap}
.header h1{font-size:18px;font-weight:600;color:var(--accent);letter-spacing:-0.5px}
.header h1 span{color:var(--dim);font-weight:400}
.hw-info{color:var(--dim);font-size:11px;letter-spacing:0.3px;font-family:monospace}
.stat{display:flex;flex-direction:column;gap:2px}
.stat-label{font-size:10px;text-transform:uppercase;letter-spacing:0.5px;color:var(--dim)}
.stat-value{font-size:16px;font-weight:600;font-variant-numeric:tabular-nums}
.progress-bar{flex:1;min-width:120px;max-width:300px;height:6px;background:var(--border);
border-radius:3px;overflow:hidden}
.progress-fill{height:100%;background:var(--accent);border-radius:3px;transition:width 0.3s}
.crumbs{background:var(--card);border-bottom:1px solid var(--border);padding:9px 24px;
display:flex;align-items:center;gap:6px;overflow-x:auto;white-space:nowrap;font-size:12px}
.crumb{color:var(--accent);cursor:pointer;padding:3px 8px;border-radius:4px;
transition:background 0.15s;user-select:none}
.crumb:hover{background:rgba(108,140,255,0.12)}
.crumb.here{color:var(--text);font-weight:600;cursor:default}
.crumb.here:hover{background:none}
.crumb-sep{color:var(--dim);opacity:0.6}
.crumb-note{color:var(--dim);margin-left:8px;font-size:11px;font-family:monospace}
.crumb-badge{margin-left:auto;font-size:11px;padding:3px 9px;border-radius:10px;
background:rgba(248,113,113,0.15);color:var(--red);cursor:pointer;display:none}
.crumb-badge.warn{background:rgba(251,191,36,0.15);color:var(--yellow)}
.crumb-badge.on{display:inline-block}
.kidbar{background:var(--card);border-bottom:1px solid var(--border);
padding:7px 24px;display:flex;align-items:center;gap:6px;flex-wrap:wrap}
.kidbar-label{color:var(--dim);font-size:11px;text-transform:uppercase;
letter-spacing:0.5px;margin-right:2px}
.kid{color:var(--accent);cursor:pointer;padding:3px 9px;border-radius:4px;
font-size:12px;border:1px solid var(--border)}
.kid:hover{background:rgba(108,140,255,0.12)}
.kid.lost{color:var(--dim);text-decoration:line-through}
.kid-note{color:var(--dim);font-size:11px;margin-left:3px}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;padding:16px 24px}
@media(max-width:900px){.grid{grid-template-columns:1fr}}
.card{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:16px;overflow:hidden}
.card h2{font-size:13px;text-transform:uppercase;letter-spacing:0.5px;color:var(--dim);margin-bottom:12px;
display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.card h2 .sub{text-transform:none;letter-spacing:0;font-weight:400;opacity:0.8}
.full-width{grid-column:1/-1}
select{background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;
font-size:11px;padding:2px 6px;font-family:inherit}
canvas{width:100%;height:260px;cursor:crosshair}
.legend{display:flex;flex-wrap:wrap;gap:10px;margin-top:10px}
.legend-item{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--dim);cursor:pointer;
user-select:none;padding:2px 6px;border-radius:4px;transition:opacity 0.2s,background 0.15s}
.legend-item:hover{background:rgba(108,140,255,0.1)}
.legend-item.dimmed{opacity:0.3}
.legend-item.solo{background:rgba(108,140,255,0.15)}
.legend-dot{width:8px;height:8px;border-radius:50%}
.empty{color:var(--dim);font-size:12px;padding:24px 0;text-align:center}
.res-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px}
.res-item{display:flex;flex-direction:column;gap:4px}
.res-label{display:flex;justify-content:space-between;font-size:11px;color:var(--dim);gap:8px}
.res-bar{height:8px;background:var(--border);border-radius:4px;overflow:hidden}
.res-fill{height:100%;border-radius:4px;transition:width 0.5s}
.res-fill.cpu{background:var(--accent)}
.res-fill.ram{background:var(--purple)}
.res-fill.gpu{background:var(--green)}
.res-fill.vram{background:var(--yellow)}
.scroll-wrap{max-height:300px;overflow:auto;scrollbar-width:thin;scrollbar-color:var(--border) transparent}
.scroll-wrap::-webkit-scrollbar{width:6px;height:6px}
.scroll-wrap::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
table{width:100%;border-collapse:collapse;font-size:12px;font-variant-numeric:tabular-nums}
th{text-align:left;color:var(--dim);font-weight:500;padding:6px 8px;white-space:nowrap;
border-bottom:1px solid var(--border);position:sticky;top:0;background:var(--card);z-index:1}
td{padding:5px 8px;border-bottom:1px solid var(--border);white-space:nowrap}
tr:hover td{background:rgba(108,140,255,0.05)}
tr.drill{cursor:pointer}
tr.drill:hover td{background:rgba(108,140,255,0.12)}
tr.epoch-row td{border-left:2px solid var(--accent)}
tr.epoch-row td:first-child{font-weight:600}
tr.dead td{color:var(--red)}
.node-cell{color:var(--accent)}
.node-cell .lbl{color:var(--dim);font-size:11px;margin-left:6px}
.sev-critical{color:var(--red)}
.sev-warn{color:var(--yellow)}
.sev-info{color:var(--dim)}
.res-cell{color:var(--dim);font-size:11px}
.theme-toggle{margin-left:auto;background:none;border:none;color:var(--dim);cursor:pointer;padding:6px;
display:inline-flex;align-items:center;border-radius:4px;line-height:0;
transition:color 0.15s,background 0.15s}
.theme-toggle:hover{color:var(--text);background:rgba(108,140,255,0.08)}
.theme-toggle svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:2;
stroke-linecap:round;stroke-linejoin:round}
.theme-toggle .sun{display:inline-block}
.theme-toggle .moon{display:none}
:root[data-theme="light"] .theme-toggle .sun{display:none}
:root[data-theme="light"] .theme-toggle .moon{display:inline-block}
.svg-toggle{cursor:pointer;user-select:none;display:flex;align-items:center;gap:6px}
.svg-toggle .arrow{transition:transform 0.2s;font-size:10px}
.svg-toggle.open .arrow{transform:rotate(90deg)}
.svg-container{max-height:0;overflow:hidden;transition:max-height 0.3s ease;background:var(--paper);border-radius:6px;margin-top:8px}
.svg-container svg{max-width:100%;height:auto;display:block;margin:0 auto}
.tooltip{position:fixed;background:var(--tooltip-bg);color:#e4e6eb;padding:8px 12px;
border-radius:6px;font-size:11px;pointer-events:none;display:none;z-index:100;
border:1px solid var(--border);white-space:nowrap;backdrop-filter:blur(8px)}
.status{display:inline-flex;align-items:center;gap:6px;font-size:12px}
.status-dot{width:8px;height:8px;border-radius:50%;background:var(--green);
animation:pulse 2s ease-in-out infinite}
.status-dot.done{background:var(--dim);animation:none}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.4}}
</style>
<script>
(function(){
var t=null;
try{t=localStorage.getItem('flodl-theme')}catch(e){}
if(t!=='light'&&t!=='dark'){
t=(typeof ARCHIVE_THEME!=='undefined'&&ARCHIVE_THEME)?ARCHIVE_THEME:'auto';
if(t==='auto'){
t=(window.matchMedia&&window.matchMedia('(prefers-color-scheme: light)').matches)
?'light':'dark';
}
}
if(t==='light')document.documentElement.setAttribute('data-theme','light');
})();
</script>
</head>
<body>
<div class="header">
<div class="header-row">
<h1>floDl <span>Training Dashboard</span></h1>
<div class="status" id="status">
<div class="status-dot" id="statusDot"></div>
<span id="statusText">Waiting...</span>
</div>
<div class="stat">
<div class="stat-label">Epoch</div>
<div class="stat-value" id="epochNum">—</div>
</div>
<div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div>
<div class="stat">
<div class="stat-label">ETA</div>
<div class="stat-value" id="etaDisplay">—</div>
</div>
<div class="stat">
<div class="stat-label">Elapsed</div>
<div class="stat-value" id="elapsedDisplay">—</div>
</div>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle light/dark theme" title="Toggle light/dark theme">
<svg class="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<svg class="moon" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</button>
</div>
<div class="hw-info" id="hwInfo" style="display:none"></div>
</div>
<div class="crumbs" id="crumbs">
<span class="crumb here">root</span>
<span class="crumb-badge" id="alertBadge" onclick="scrollToAlerts()"></span>
</div>
<div class="kidbar" id="kidbar" style="display:none"></div>
<div class="grid">
<div class="card">
<h2>Metrics <span class="sub" id="metricsScope"></span></h2>
<canvas id="metricsChart"></canvas>
<div class="legend" id="metricsLegend"></div>
</div>
<div class="card">
<h2>Resources <span class="sub" id="resourceScope"></span></h2>
<canvas id="resourceChart"></canvas>
<div class="legend" id="resourceLegend"></div>
<div class="res-grid" id="resGrid">
<div class="res-item"><div class="res-label"><span id="cpuLabel">CPU</span><span id="cpuVal">—</span></div>
<div class="res-bar"><div class="res-fill cpu" id="cpuBar" style="width:0"></div></div></div>
<div class="res-item"><div class="res-label"><span id="ramLabel">RAM</span><span id="ramVal">—</span></div>
<div class="res-bar"><div class="res-fill ram" id="ramBar" style="width:0"></div></div></div>
<div class="res-item"><div class="res-label"><span>GPU</span><span id="gpuVal">—</span></div>
<div class="res-bar"><div class="res-fill gpu" id="gpuBar" style="width:0"></div></div></div>
<div class="res-item"><div class="res-label"><span>VRAM</span><span id="vramVal">—</span></div>
<div class="res-bar"><div class="res-fill vram" id="vramBar" style="width:0"></div></div></div>
</div>
</div>
<div class="card full-width" id="childrenCard" style="display:none">
<h2>Children <span class="sub" id="childrenCount"></span>
<span class="sub">compare</span> <select id="compareMetric" onchange="onCompareChange()"></select></h2>
<div class="sub" id="childrenNote" style="display:none;margin-bottom:8px"></div>
<canvas id="childrenChart"></canvas>
<div class="legend" id="childrenLegend"></div>
<div class="scroll-wrap" style="margin-top:12px">
<table>
<thead><tr id="childrenHeader"></tr></thead>
<tbody id="childrenBody"></tbody>
</table>
</div>
</div>
<div class="card full-width">
<h2>Log <span class="sub" id="logScope"></span></h2>
<div class="scroll-wrap">
<table>
<thead><tr id="logHeader"></tr></thead>
<tbody id="logBody"></tbody>
</table>
</div>
<div class="empty" id="logEmpty">waiting for the first record...</div>
</div>
<div class="card full-width" id="alertsCard" style="display:none">
<h2>Alerts <span class="sub">whole run, deepest origin path</span></h2>
<div class="scroll-wrap">
<table>
<thead><tr><th>When</th><th>Class</th><th>Origin</th><th>Detail</th><th>Count</th></tr></thead>
<tbody id="alertsBody"></tbody>
</table>
</div>
</div>
<div class="card full-width" id="metaCard" style="display:none">
<details>
<summary style="cursor:pointer;color:var(--dim);font-size:13px;text-transform:uppercase;letter-spacing:0.5px">Training Configuration</summary>
<pre id="metaPre" style="margin-top:12px;color:var(--text);font-size:12px;white-space:pre-wrap;word-break:break-word"></pre>
</details>
</div>
<div class="card full-width" id="svgCard" style="display:none">
<div class="svg-toggle" id="svgToggle" onclick="toggleSvg()">
<span class="arrow">▶</span>
<h2 style="margin:0">Graph Architecture</h2>
</div>
<div class="svg-container" id="svgContainer"></div>
</div>
</div>
<div class="tooltip" id="tooltip"></div>
<script>
const COLORS_DARK=['#6c8cff','#f87171','#34d399','#fbbf24','#a78bfa','#fb923c','#67e8f9','#f472b6','#86efac','#fcd34d','#c4b5fd','#fdba74'];
const COLORS_LIGHT=['#3656c7','#c93c3c','#0e9968','#8f5b10','#6e4ad0','#b4530c','#0b7b95','#b83280','#12734f','#8a6d0f','#5b3fa8','#9a4a12'];
let THEME={};
function refreshTheme(){
const light=document.documentElement.getAttribute('data-theme')==='light';
const cs=getComputedStyle(document.documentElement);
const v=(n,fb)=>{const x=cs.getPropertyValue(n).trim();return x||fb};
THEME={
light,
dim:v('--dim',light?'#5a5f72':THEME.dim),
text:v('--text',light?'#1a1d27':'#e4e6eb'),
grid:light?'rgba(221,224,232,0.9)':'rgba(42,45,58,0.8)',
ref:light?'rgba(201,60,60,0.7)':'rgba(248,113,113,0.7)',
refDim:light?'rgba(201,60,60,0.45)':'rgba(248,113,113,0.4)',
series:light?COLORS_LIGHT:COLORS_DARK,
};
}
refreshTheme();
const COLORS=new Proxy([],{
get(_,k){
const s=THEME.series||COLORS_DARK;
return k==='length'?s.length:s[k];
},
});
function toggleTheme(){
const root=document.documentElement;
const light=root.getAttribute('data-theme')==='light';
if(light)root.removeAttribute('data-theme');else root.setAttribute('data-theme','light');
try{localStorage.setItem('flodl-theme',light?'dark':'light')}catch(e){}
refreshTheme();
for(const k in soloState)soloState[k]=null;
render();
}
const RES_COLORS={cpu:'#6c8cff',ram:'#a78bfa',gpu:'#34d399',vram:'#fbbf24',
gpuPeak:'#a7f3d0'};
const ROOT='root';
const MAX_ROWS=2000;
const MAX_LOG_ROWS=500;
const HISTORY_N=2048;
const NODES=new Map();
const ALERTS=new Map();
let T0=null;
let recordMode=false;
let META=null;
let HOST={cpu:null,ram_used:null,ram_total:null,gpu:null,vram_alloc:null,vram_total:null};
let legacyT=0;
const legacySeen=new Set();
const GPU_INIT=new Map();
let current=ROOT;
let levelSse=null;
let levelUnreachable=false;
let rootSse=null;
let compareKey=null;
const soloState={metricsChart:null,resourceChart:null,childrenChart:null};
let startTime=null;
let elapsedTimer=null;
let renderQueued=false;
const CORE_REDUCTION={throughput:'sum',batch_share:'sum',loss:'mean',
data_starve:'max',compute_only_ms:'max'};
function reductionOf(key){
if(CORE_REDUCTION[key])return CORE_REDUCTION[key];
if(META&&META.reductions&&META.reductions[key])return META.reductions[key];
return 'mean';
}
function parentOf(path){
const i=path.lastIndexOf('/');
return i<0?null:path.slice(0,i);
}
function natCmp(a,b){
const re=/(\d+)|(\D+)/g;
const as=String(a).match(re)||[],bs=String(b).match(re)||[];
for(let i=0;i<Math.min(as.length,bs.length);i++){
const x=as[i],y=bs[i];
const nx=/^\d/.test(x),ny=/^\d/.test(y);
if(nx&&ny){const d=parseInt(x,10)-parseInt(y,10);if(d)return d}
else if(x!==y)return x<y?-1:1;
}
return as.length-bs.length;
}
function lastSeg(path){
const i=path.lastIndexOf('/');
return i<0?path:path.slice(i+1);
}
function nodeOf(path){
let n=NODES.get(path);
if(n)return n;
n={path,rows:[],keys:new Set(),metricKeys:new Set(),resKeys:new Set(),
children:new Set(),label:null,device:null,alive:null,isLeaf:null,lastRow:null};
NODES.set(path,n);
const p=parentOf(path);
if(p)nodeOf(p).children.add(path);
return n;
}
function addRow(path,row){
const n=nodeOf(path);
const key=row.ts+'|'+row.tick+'|'+row.epoch1+'|'+(row.ec?1:0);
if(n.keys.has(key))return false;
row.key=key;
n.keys.add(key);
let lo=0,hi=n.rows.length;
while(lo<hi){const mid=(lo+hi)>>1;if(n.rows[mid].t<=row.t)lo=mid+1;else hi=mid}
n.rows.splice(lo,0,row);
while(n.rows.length>MAX_ROWS){const drop=n.rows.shift();n.keys.delete(drop.key)}
for(const k in row.metrics)n.metricKeys.add(k);
for(const k in row.res)n.resKeys.add(k);
if(!n.lastRow||row.t>=n.lastRow.t)n.lastRow=row;
return true;
}
function ingestRecord(rec){
if(!rec||typeof rec!=='object')return;
if(rec.kind==='meta'){META=rec;return}
if(rec.kind==='event'){ingestAlert(rec);return}
if(rec.kind!=='node')return;
if(!recordMode)latchRecordMode();
if(T0===null)T0=rec.ts||0;
const n=nodeOf(rec.path);
if(rec.label!=null)n.label=rec.label;
if(rec.device!=null)n.device=rec.device;
if(typeof rec.alive==='boolean'){n.isLeaf=true;n.alive=rec.alive}
else if(rec.alive!=null){n.isLeaf=false;n.aliveCount=rec.alive;n.childCount=rec.children}
addRow(rec.path,{
t:((rec.ts||0)-T0)/1000,
ts:rec.ts||0,
epoch1:rec.epoch!=null?rec.epoch+1:null, tick:rec.tick!=null?rec.tick:null,
ec:rec.epoch_complete===true,
metrics:rec.metrics||{},
res:rec.res||{},
work:rec.work!=null?rec.work:null,
});
scheduleRender();
}
function latchRecordMode(){
recordMode=true;
NODES.clear();
T0=null;
const wasDeep=current!==ROOT;
current=ROOT;
if(levelSse){levelSse.close();levelSse=null}
if(wasDeep)location.hash='#path='+encodeURIComponent(ROOT);
}
function ingestAlert(rec){
const key=rec.class+'|'+rec.path;
const cur=ALERTS.get(key);
const add=rec.count!=null?rec.count:1;
if(cur&&cur.ts>=rec.ts){cur.total+=add}
else ALERTS.set(key,{class:rec.class,path:rec.path,sev:rec.sev,detail:rec.detail,
ts:rec.ts,total:(cur?cur.total:0)+add});
scheduleRender();
}
function ingestEpoch(d){
updateHeader(d);
const r=d.resources||{};
HOST={cpu:r.cpu!=null?r.cpu:null,
ram_used:r.ram_used!=null?r.ram_used:null,
ram_total:r.ram_total!=null?r.ram_total:null,
gpu:r.gpu!=null?r.gpu:null,
vram_alloc:r.vram_alloc!=null?r.vram_alloc:null,
vram_total:r.vram_total!=null?r.vram_total:null};
for(const g of d.gpus||[]){
if(g.dev==null)continue;
const cur=GPU_INIT.get(g.dev)||{};
GPU_INIT.set(g.dev,{name:g.name||cur.name,vram_total:g.vram_total!=null?g.vram_total:cur.vram_total});
}
if(!recordMode)ingestEpochAsLevels(d,r);
scheduleRender();
}
function ingestEpochAsLevels(d,r){
if(legacySeen.has(d.epoch))return;
legacySeen.add(d.epoch);
legacyT+=d.duration||0;
const gpus=d.gpus||[];
const tiered=gpus.length>=2;
const rootRes={};
if(r.cpu!=null)rootRes.cpu=r.cpu;
if(r.ram_used!=null)rootRes.ram_used=r.ram_used;
if(r.ram_total!=null)rootRes.ram_total=r.ram_total;
if(r.gpu!=null)rootRes.gpu_util=r.gpu;
if(r.vram_alloc!=null)rootRes.vram_alloc=r.vram_alloc;
if(r.vram_total!=null)rootRes.vram_total=r.vram_total;
addRow(ROOT,{t:legacyT,ts:Math.round(legacyT*1000),epoch1:d.epoch,tick:null,ec:true,
metrics:cleanMetrics(d.metrics),res:rootRes,work:null});
const rootNode=nodeOf(ROOT);
if(!tiered)rootNode.isLeaf=gpus.length<=1;
if(!tiered)return;
rootNode.isLeaf=false;
for(const g of gpus){
if(g.dev==null)continue;
const path=ROOT+'/gpu'+g.dev;
const kid=nodeOf(path);
kid.isLeaf=true;
kid.alive=true;
kid.device=g.dev;
const init=GPU_INIT.get(g.dev)||{};
if(init.name)kid.label=shortGpuName(init.name);
const metrics={};
if(g.throughput!=null)metrics.throughput=g.throughput;
if(g.chunk!=null)metrics.batch_share=g.chunk;
const res={};
if(g.util!=null)res.gpu_util=g.util;
if(g.vram_alloc!=null)res.vram_alloc=g.vram_alloc;
const vt=g.vram_total!=null?g.vram_total:init.vram_total;
if(vt!=null)res.vram_total=vt;
addRow(path,{t:legacyT,ts:Math.round(legacyT*1000),epoch1:d.epoch,tick:null,ec:true,
metrics,res,work:null});
}
}
function cleanMetrics(m){
const out={};
for(const k in m||{})if(m[k]!=null)out[k]=m[k];
return out;
}
function markComplete(msg){
document.getElementById('statusDot').classList.add('done');
document.getElementById('statusText').textContent=msg||'Complete';
if(elapsedTimer){clearInterval(elapsedTimer);elapsedTimer=null}
}
function pathFromHash(){
const h=(location.hash||'').replace(/^#/,'');
if(!h)return ROOT;
const m=/^path=(.*)$/.exec(h);
return m?decodeURIComponent(m[1]):ROOT;
}
function navigate(path){
const target='#path='+encodeURIComponent(path);
if(location.hash===target)setLevel(path);
else location.hash=target;
}
function setLevel(path){
current=path||ROOT;
nodeOf(current);
subscribeLevel(current);
render();
}
window.addEventListener('hashchange',()=>setLevel(pathFromHash()));
function renderCrumbs(){
const el=document.getElementById('crumbs');
const badge=document.getElementById('alertBadge');
const segs=current.split('/');
let html='';
for(let i=0;i<segs.length;i++){
const p=segs.slice(0,i+1).join('/');
const here=i===segs.length-1;
if(i>0)html+='<span class="crumb-sep">/</span>';
html+='<span class="crumb'+(here?' here':'')+'" data-path="'+esc(p)+'">'+esc(segs[i])+'</span>';
}
const n=NODES.get(current);
const note=[];
if(n&&n.label)note.push(n.label);
if(n&&n.device!=null)note.push('dev '+n.device);
if(n&&n.isLeaf===false&&n.childCount!=null)note.push(n.aliveCount+'/'+n.childCount+' alive');
if(n&&n.isLeaf===true&&n.alive===false)note.push('LOST');
if(note.length)html+='<span class="crumb-note">'+esc(note.join(' · '))+'</span>';
el.innerHTML=html;
el.appendChild(badge);
el.querySelectorAll('.crumb:not(.here)').forEach(c=>{
c.onclick=()=>navigate(c.dataset.path);
});
renderKidBar();
}
function renderKidBar(){
const bar=document.getElementById('kidbar');
const node=NODES.get(current);
const kids=node?childPaths(node):[];
if(!kids.length){bar.style.display='none';bar.innerHTML='';return}
let html='<span class="kidbar-label">in here</span>';
for(const p of kids){
const k=NODES.get(p);
const lost=k&&k.isLeaf===true&&k.alive===false;
const seg=p.split('/').pop();
html+='<span class="kid'+(lost?' lost':'')+'" data-path="'+esc(p)+'" title="'+esc(p)+'">'
+esc(seg)+(k&&k.label?' <span class="kid-note">'+esc(k.label)+'</span>':'')+'</span>';
}
const declared=node.childCount;
if(declared!=null&&declared>kids.length){
html+='<span class="kid-note">'+(declared-kids.length)+' more not loaded</span>';
}
bar.innerHTML=html;
bar.style.display='flex';
bar.querySelectorAll('.kid').forEach(c=>{c.onclick=()=>navigate(c.dataset.path)});
}
function subscribeRoot(){
rootSse=new EventSource('/stream?path='+encodeURIComponent(ROOT));
rootSse.addEventListener('record',e=>{try{ingestRecord(JSON.parse(e.data))}catch(err){}});
}
function subscribeLevel(path){
if(levelSse){levelSse.close();levelSse=null}
levelUnreachable=false;
if(path===ROOT||!rootSse)return;
const enc=encodeURIComponent(path);
levelSse=new EventSource('/stream?path='+enc);
levelSse.addEventListener('record',e=>{
try{
const rec=JSON.parse(e.data);
if(rec.kind!=='event')ingestRecord(rec);
}catch(err){}
});
levelSse.addEventListener('error',()=>{levelUnreachable=true;scheduleRender()});
fetch('/history?path='+enc+'&n='+HISTORY_N)
.then(r=>r.ok?r.json():[])
.then(rows=>{for(const r of rows)if(r.kind!=='event')ingestRecord(r)})
.catch(()=>{levelUnreachable=true;scheduleRender()});
}
function updateHeader(d){
if(!startTime&&d.duration!=null)startTime=Date.now()-(d.epoch*d.duration*1000);
document.getElementById('epochNum').textContent=d.epoch+'/'+d.total;
document.getElementById('progressFill').style.width=(d.epoch/d.total*100)+'%';
document.getElementById('statusDot').classList.remove('done');
document.getElementById('statusText').textContent='Training';
if(d.eta!=null)document.getElementById('etaDisplay').textContent=fmtDur(d.eta);
updateElapsed();
}
function updateElapsed(){
if(!startTime)return;
document.getElementById('elapsedDisplay').textContent=fmtDur((Date.now()-startTime)/1000);
}
function seriesOf(node,group,key){
const out=[];
for(const r of node.rows){const v=r[group][key];if(v!=null)out.push({t:r.t,v,row:r})}
return out;
}
function pctSeries(node,usedKey,totalKey){
const out=[];
for(const r of node.rows){
const u=r.res[usedKey],tt=r.res[totalKey];
if(u!=null&&tt)out.push({t:r.t,v:u/tt*100,row:r});
}
return out;
}
function epochMarks(node){
const out=[];
for(const r of node.rows)if(r.ec&&r.epoch1!=null)out.push({t:r.t,label:'e'+r.epoch1});
return out;
}
function metricKeys(node){
return Array.from(node.metricKeys).sort();
}
function lastOf(series){
return series.length?series[series.length-1].v:null;
}
function scheduleRender(){
if(renderQueued)return;
renderQueued=true;
requestAnimationFrame(()=>{renderQueued=false;render()});
}
function scopeLabel(){return current===ROOT?'run total':lastSeg(current)}
function render(){
renderCrumbs();
const scope=scopeLabel();
document.getElementById('metricsScope').textContent=scope;
document.getElementById('resourceScope').textContent=scope;
const total=nodeOf(current).rows.length;
document.getElementById('logScope').textContent=
(recordMode?'epoch + sub-epoch rows, newest first':'epoch rows, newest first')+
(total>MAX_LOG_ROWS?' — showing newest '+MAX_LOG_ROWS+' of '+total:'');
renderHardware();
drawMetrics();
drawResources();
renderChildren();
renderLog();
renderAlerts();
const atRoot=current===ROOT;
for(const id of ['metaCard','svgCard']){
const el=document.getElementById(id);
if(el.dataset.have==='1')el.style.display=atRoot?'block':'none';
}
}
function drawMetrics(){
const node=nodeOf(current);
const keys=metricKeys(node);
const series={},labels={};
const interior=node.isLeaf===false;
for(const k of keys){
series[k]=seriesOf(node,'metrics',k);
labels[k]=interior?k+' ('+reductionOf(k)+')':k;
}
const solo=soloState.metricsChart;
drawChart('metricsChart',series,COLORS,{
marks:epochMarks(node),
yFmt:solo?fmtForKey(solo):null,
normalize:true,
labels,
});
updateLegend('metricsLegend',keys,COLORS,'metricsChart',drawMetrics,labels);
const scopeEl=document.getElementById('metricsScope');
if(scopeEl){
const scaling=!solo&&keys.length>1;
scopeEl.textContent=scopeLabel()+
(scaling?' · each curve scaled to its own range, click one for real values':'');
}
}
function drawResources(){
const node=nodeOf(current);
const solo=soloState.resourceChart;
const names=[],series={},colors=[];
const add=(name,s,color)=>{if(s.length){names.push(name);series[name]=s;colors.push(color)}};
add('gpu',seriesOf(node,'res','gpu_util'),RES_COLORS.gpu);
add('gpu peak',seriesOf(node,'res','gpu_util_max'),RES_COLORS.gpuPeak);
add('vram',pctSeries(node,'vram_alloc','vram_total'),RES_COLORS.vram);
add('cpu',seriesOf(node,'res','cpu'),RES_COLORS.cpu);
add('ram',pctSeries(node,'ram_used','ram_total'),RES_COLORS.ram);
const vramTotal=lastResValue(node,'vram_total');
let drawn=series,drawColors=colors,pct=true,yFmt=null,ref=null;
if(solo==='vram'&&vramTotal){
drawn={vram:seriesOf(node,'res','vram_alloc')};drawColors=[RES_COLORS.vram];pct=false;
yFmt=fmtBytes;ref={value:vramTotal,dotted:false,color:THEME.ref,label:'Physical VRAM'};
}else if(solo==='ram'){
drawn={ram:seriesOf(node,'res','ram_used')};drawColors=[RES_COLORS.ram];pct=false;yFmt=fmtBytes;
}else if(solo==='gpu'||solo==='cpu'||solo==='gpu peak'){
drawn={};drawn[solo]=series[solo]||[];
drawColors=[colors[names.indexOf(solo)]];yFmt=pctFmt;
}else if(names.includes('vram')&&vramTotal){
ref={value:100,dotted:true,color:THEME.refDim,label:'VRAM limit'};
}
drawChart('resourceChart',drawn,drawColors,{
percent:pct,yFmt:yFmt||(pct?pctFmt:null),refLine:ref,marks:epochMarks(node),
});
updateLegend('resourceLegend',names,colors,'resourceChart',drawResources,
{cpu:'CPU',gpu:'GPU (mean)',ram:'RAM',vram:'VRAM','gpu peak':'GPU (peak)'});
updateGauges(node);
}
function lastResValue(node,key){
for(let i=node.rows.length-1;i>=0;i--){const v=node.rows[i].res[key];if(v!=null)return v}
return null;
}
function updateGauges(node){
const cohort=recordMode;
document.getElementById('cpuLabel').textContent=cohort?'CPU (cohort mean)':'CPU';
document.getElementById('ramLabel').textContent=cohort?'RAM (cohort)':'RAM';
setBar('cpu',HOST.cpu,pctFmt);
if(HOST.ram_used!=null&&HOST.ram_total){
document.getElementById('ramBar').style.width=(HOST.ram_used/HOST.ram_total*100)+'%';
document.getElementById('ramVal').textContent=fmtBytes(HOST.ram_used)+' / '+fmtBytes(HOST.ram_total);
}
const util=lastResValue(node,'gpu_util');
setBar('gpu',util!=null?util:(current===ROOT?HOST.gpu:null),pctFmt);
const alloc=lastResValue(node,'vram_alloc');
const total=lastResValue(node,'vram_total');
const useAlloc=alloc!=null?alloc:(current===ROOT?HOST.vram_alloc:null);
const useTotal=total!=null?total:(current===ROOT?HOST.vram_total:null);
if(useAlloc!=null){
const t=useTotal||0;
const p=t>0?Math.min(useAlloc/t*100,100):0;
const spill=t>0&&useAlloc>t?useAlloc-t:0;
const bar=document.getElementById('vramBar');
bar.style.width=p+'%';
bar.style.background=spill>0?'var(--red)':'var(--yellow)';
document.getElementById('vramVal').textContent=fmtBytes(useAlloc)+(t>0?' / '+fmtBytes(t):'');
}
}
function setBar(id,val,fmt){
if(val==null)return;
document.getElementById(id+'Bar').style.width=Math.min(val,100)+'%';
document.getElementById(id+'Val').textContent=fmt(val);
}
function childPaths(node){
return Array.from(node.children).sort(natCmp);
}
function renderChildren(){
const node=nodeOf(current);
const kids=childPaths(node);
const card=document.getElementById('childrenCard');
const note=document.getElementById('childrenNote');
if(!kids.length){
const declared=node.childCount;
if(declared>0){
card.style.display='block';
note.style.display='block';
note.textContent=levelUnreachable
? declared+' children reported, none loaded — the dashboard server is not answering. '+
'A level is fetched when you open it, so one never opened while the run was live '+
'has no data once the run ends.'
: declared+' children reported, waiting for their records…';
document.getElementById('childrenCount').textContent=declared;
document.getElementById('childrenHeader').innerHTML='';
document.getElementById('childrenBody').innerHTML='';
drawChart('childrenChart',{},[],{});
updateLegend('childrenLegend',[],[],'childrenChart',renderChildren,{});
return;
}
card.style.display='none';return;
}
card.style.display='block';
note.style.display='none';
const labels={},keySet=new Set(),resSet=new Set();
for(const p of kids){
const k=NODES.get(p);
labels[p]=childLegend(p);
if(k)for(const m of k.metricKeys)keySet.add(m);
if(k)for(const m of k.resKeys)resSet.add(m);
}
const metricCols=Array.from(keySet).sort();
const options=metricCols.slice();
if(resSet.has('gpu_util'))options.push('gpu_util');
if(resSet.has('vram_alloc'))options.push('vram_alloc');
syncCompareOptions(options);
const series={},colors=[],names=[];
const isRes=compareKey==='gpu_util'||compareKey==='vram_alloc';
kids.forEach((p,i)=>{
const k=NODES.get(p);
if(!k)return;
const s=isRes?seriesOf(k,'res',compareKey):seriesOf(k,'metrics',compareKey);
if(!s.length)return;
names.push(p);series[p]=s;colors.push(COLORS[i%COLORS.length]);
});
drawChart('childrenChart',series,colors,{
marks:epochMarks(node),yFmt:fmtForKey(compareKey),labels,
});
updateLegend('childrenLegend',names,colors,'childrenChart',renderChildren,labels);
const alive=kids.filter(p=>NODES.get(p)&&NODES.get(p).alive!==false).length;
document.getElementById('childrenCount').textContent=
kids.length+(alive!==kids.length?' ('+alive+' alive)':'');
const cols=['node','dev','work'].concat(metricCols).concat(['gpu','vram']);
const hdr=document.getElementById('childrenHeader');
hdr.innerHTML=cols.map(c=>'<th>'+esc(c)+'</th>').join('');
const body=document.getElementById('childrenBody');
body.innerHTML='';
for(const p of kids){
const k=NODES.get(p);
const last=k&&k.lastRow;
const tr=document.createElement('tr');
tr.className='drill'+(k&&k.alive===false?' dead':'');
let html='<td class="node-cell">'+esc(lastSeg(p))+
(k&&k.label?'<span class="lbl">'+esc(k.label)+'</span>':'')+'</td>';
html+='<td>'+(k&&k.device!=null?k.device:'—')+'</td>';
html+='<td>'+(last&&last.work!=null?fmtVal(last.work):'—')+'</td>';
for(const key of metricCols){
const v=lastMetric(k,key);
html+='<td>'+(v!=null?fmtForKey(key)(v):'—')+'</td>';
}
const u=k?lastResValue(k,'gpu_util'):null;
html+='<td>'+(u!=null?pctFmt(u):'—')+'</td>';
const a=k?lastResValue(k,'vram_alloc'):null;
html+='<td>'+(a!=null?fmtBytes(a):'—')+'</td>';
tr.innerHTML=html;
tr.onclick=()=>navigate(p);
body.appendChild(tr);
}
}
function childLegend(path){
const k=NODES.get(path);
const seg=lastSeg(path);
return k&&k.label?seg+' · '+k.label:seg;
}
function lastMetric(node,key){
if(!node)return null;
for(let i=node.rows.length-1;i>=0;i--){const v=node.rows[i].metrics[key];if(v!=null)return v}
return null;
}
function syncCompareOptions(options){
const sel=document.getElementById('compareMetric');
const want=options.join('|');
if(sel.dataset.opts!==want){
sel.dataset.opts=want;
sel.innerHTML=options.map(o=>'<option value="'+esc(o)+'">'+esc(o)+'</option>').join('');
}
if(!compareKey||!options.includes(compareKey)){
compareKey=['loss','throughput','batch_share'].find(k=>options.includes(k))||options[0]||null;
}
if(compareKey)sel.value=compareKey;
}
function onCompareChange(){
compareKey=document.getElementById('compareMetric').value;
soloState.childrenChart=null;
renderChildren();
}
function renderLog(){
const node=nodeOf(current);
const keys=metricKeys(node);
const empty=document.getElementById('logEmpty');
const header=document.getElementById('logHeader');
const body=document.getElementById('logBody');
if(!node.rows.length){
empty.style.display='block';header.innerHTML='';body.innerHTML='';return;
}
empty.style.display='none';
const cols=['t','epoch'];
if(recordMode)cols.push('window','work');
const cells=cols.concat(keys).concat(['res']);
header.innerHTML=cells.map(c=>'<th>'+esc(c)+'</th>').join('');
const stop=Math.max(0,node.rows.length-MAX_LOG_ROWS);
let html='';
for(let i=node.rows.length-1;i>=stop;i--){
const r=node.rows[i];
let tds='<td>'+fmtDur(r.t)+'</td>';
tds+='<td>'+(r.epoch1!=null?r.epoch1:'—')+'</td>';
if(recordMode){
tds+='<td>'+(r.tick!=null?r.tick:'—')+'</td>';
tds+='<td>'+(r.work!=null?fmtVal(r.work):'—')+'</td>';
}
for(const k of keys){
const v=r.metrics[k];
tds+='<td>'+(v!=null?fmtForKey(k)(v):'—')+'</td>';
}
tds+='<td class="res-cell">'+esc(resSummary(r.res))+'</td>';
html+='<tr class="'+(r.ec?'epoch-row':'')+'">'+tds+'</tr>';
}
body.innerHTML=html;
}
function resSummary(res){
const out=[];
if(res.cpu!=null)out.push('CPU:'+res.cpu.toFixed(0)+'%');
if(res.gpu_util!=null)out.push('GPU:'+res.gpu_util.toFixed(0)+'%');
if(res.vram_alloc!=null){
const t=res.vram_total;
out.push('VRAM:'+fmtBytes(res.vram_alloc)+(t?'/'+fmtBytes(t):''));
}
if(res.ram_used!=null&&res.ram_total)out.push('RAM:'+fmtBytes(res.ram_used));
return out.join(' ');
}
function renderAlerts(){
const card=document.getElementById('alertsCard');
const badge=document.getElementById('alertBadge');
const list=Array.from(ALERTS.values()).sort((a,b)=>b.ts-a.ts);
if(!list.length){card.style.display='none';badge.classList.remove('on');return}
card.style.display='block';
const critical=list.filter(a=>a.sev==='critical').length;
badge.classList.add('on');
badge.classList.toggle('warn',critical===0);
badge.textContent=critical>0?critical+' critical':list.length+' alert'+(list.length>1?'s':'');
const body=document.getElementById('alertsBody');
body.innerHTML='';
for(const a of list){
const tr=document.createElement('tr');
let html='<td class="res-cell">'+esc(fmtClock(a.ts))+'</td>';
html+='<td class="sev-'+esc(a.sev)+'">'+esc(a.class)+'</td>';
html+='<td class="node-cell">'+esc(a.path)+'</td>';
html+='<td class="res-cell">'+esc(a.detail||'')+'</td>';
html+='<td>'+a.total+'</td>';
tr.innerHTML=html;
if(NODES.has(a.path)||a.path===ROOT){
tr.className='drill';
tr.onclick=()=>navigate(a.path);
}
body.appendChild(tr);
}
}
function scrollToAlerts(){
const card=document.getElementById('alertsCard');
if(card.style.display!=='none')card.scrollIntoView({behavior:'smooth',block:'center'});
}
function drawChart(canvasId,series,colors,opts){
opts=opts||{};
const canvas=document.getElementById(canvasId);
if(!canvas)return;
const ctx=canvas.getContext('2d');
const dpr=window.devicePixelRatio||1;
const rect=canvas.getBoundingClientRect();
canvas.width=Math.max(1,rect.width*dpr);canvas.height=Math.max(1,rect.height*dpr);
ctx.scale(dpr,dpr);
const W=rect.width,H=rect.height;
const M={top:10,right:12,bottom:24,left:opts.yFmt?70:52};
const pw=W-M.left-M.right,ph=H-M.top-M.bottom;
ctx.clearRect(0,0,W,H);
const allNames=Object.keys(series);
const solo=soloState[canvasId];
const visible=allNames.filter(n=>!solo||n===solo);
const scaled=!!opts.normalize&&!solo&&visible.length>1;
let minT=Infinity,maxT=-Infinity,minV=Infinity,maxV=-Infinity;
for(const n of allNames)for(const p of series[n]){
if(p.t<minT)minT=p.t;if(p.t>maxT)maxT=p.t;
}
const range={};
if(scaled)for(const n of visible){
let lo=Infinity,hi=-Infinity;
for(const p of series[n]){if(p.v<lo)lo=p.v;if(p.v>hi)hi=p.v}
if(!isFinite(lo)){lo=0;hi=1}
if(lo===hi){lo-=1;hi+=1}
range[n]=[lo,hi];
}
for(const n of visible)for(const p of series[n]){
if(p.v<minV)minV=p.v;if(p.v>maxV)maxV=p.v;
}
if(!isFinite(minT)){
ctx.fillStyle=THEME.dim;ctx.font='12px -apple-system,sans-serif';ctx.textAlign='center';
ctx.fillText('no data yet',W/2,H/2);
canvas._layout=null;
return;
}
if(!isFinite(minV)){minV=0;maxV=1}
if(minV===maxV){minV-=1;maxV+=1}
if(opts.percent){minV=Math.min(0,minV);maxV=Math.max(100,maxV)}
if(opts.refLine!=null&&!scaled){minV=Math.min(minV,opts.refLine.value);maxV=Math.max(maxV,opts.refLine.value)}
if(scaled){minV=0;maxV=1}
else{const pad=(maxV-minV)*0.05;minV-=pad;maxV+=pad}
if(opts.yFmt===fmtBytes)minV=Math.max(0,minV);
const span=Math.max(1e-9,maxT-minT);
const xScale=t=>M.left+((t-minT)/span)*pw;
const yScale=v=>M.top+ph-(v-minV)/(maxV-minV)*ph;
const yOf=(n,v)=>{
if(!scaled)return yScale(v);
const r=range[n];
if(!r)return yScale(v);
return M.top+ph-((v-r[0])/(r[1]-r[0]))*ph;
};
const yLabel=opts.yFmt||fmtVal;
ctx.strokeStyle=THEME.grid;ctx.lineWidth=1;
for(let i=0;i<=4;i++){
const v=minV+(maxV-minV)*i/4;
const y=yScale(v);
ctx.beginPath();ctx.moveTo(M.left,y);ctx.lineTo(W-M.right,y);ctx.stroke();
ctx.fillStyle=THEME.dim;ctx.font='10px -apple-system,sans-serif';ctx.textAlign='right';
ctx.fillText(scaled?Math.round(i/4*100)+'%':yLabel(v),M.left-6,y+3);
}
const marks=(opts.marks||[]).filter(m=>m.t>=minT&&m.t<=maxT);
ctx.textAlign='center';ctx.font='10px -apple-system,sans-serif';
if(marks.length){
const step=Math.max(1,Math.ceil(marks.length/8));
for(let i=0;i<marks.length;i+=step){
const m=marks[i],x=xScale(m.t);
ctx.strokeStyle='rgba(108,140,255,0.13)';
ctx.beginPath();ctx.moveTo(x,M.top);ctx.lineTo(x,M.top+ph);ctx.stroke();
ctx.fillStyle=THEME.dim;ctx.fillText(m.label,x,H-M.bottom+14);
}
}else{
for(let i=0;i<=4;i++){
const t=minT+span*i/4;
ctx.fillStyle=THEME.dim;ctx.fillText(fmtDur(t),xScale(t),H-M.bottom+14);
}
}
if(opts.refLine!=null&&!scaled){
const ry=yScale(opts.refLine.value);
if(ry>=M.top&&ry<=M.top+ph){
ctx.save();
ctx.strokeStyle=opts.refLine.color||THEME.dim;
ctx.lineWidth=1.5;
if(opts.refLine.dotted)ctx.setLineDash([6,4]);
ctx.beginPath();ctx.moveTo(M.left,ry);ctx.lineTo(W-M.right,ry);ctx.stroke();
ctx.setLineDash([]);
if(opts.refLine.label){
ctx.fillStyle=opts.refLine.color||THEME.dim;
ctx.font='9px -apple-system,sans-serif';ctx.textAlign='right';
ctx.fillText(opts.refLine.label,W-M.right,ry-4);
}
ctx.restore();
}
}
allNames.forEach((n,si)=>{
if(solo&&solo!==n)return;
const pts=series[n];
if(!pts.length)return;
const color=colors[si%colors.length]||COLORS[si%COLORS.length];
ctx.strokeStyle=color;ctx.lineWidth=solo?3:2;ctx.beginPath();
pts.forEach((p,i)=>{
const x=xScale(p.t),y=yOf(n,p.v);
if(i===0)ctx.moveTo(x,y);else ctx.lineTo(x,y);
});
ctx.stroke();
const last=pts[pts.length-1];
ctx.fillStyle=color;ctx.beginPath();
ctx.arc(xScale(last.t),yOf(n,last.v),3,0,Math.PI*2);ctx.fill();
});
canvas._layout={xScale,minT,maxT,series,names:allNames,colors,solo,
yFmt:yLabel,labels:opts.labels||null};
}
function updateLegend(id,names,colors,chartId,redrawFn,labels){
const el=document.getElementById(id);
const want=names.join('|');
if(el.dataset.names!==want){
el.dataset.names=want;
el.innerHTML='';
names.forEach((n,i)=>{
const item=document.createElement('div');
item.className='legend-item';
item.dataset.name=n;
const dot=document.createElement('div');
dot.className='legend-dot';
dot.style.background=colors[i%colors.length]||COLORS[i%COLORS.length];
item.appendChild(dot);
item.appendChild(document.createTextNode((labels&&labels[n])||n));
item.addEventListener('click',()=>{
soloState[chartId]=soloState[chartId]===n?null:n;
redrawFn();
});
el.appendChild(item);
});
}
const solo=soloState[chartId];
Array.from(el.children).forEach(item=>{
const n=item.dataset.name;
item.classList.toggle('solo',solo===n);
item.classList.toggle('dimmed',solo!==null&&solo!==n);
});
}
['metricsChart','resourceChart','childrenChart'].forEach(id=>{
const canvas=document.getElementById(id);
if(!canvas)return;
canvas.addEventListener('mousemove',e=>{
const L=canvas._layout;
const tip=document.getElementById('tooltip');
if(!L){tip.style.display='none';return}
const rect=canvas.getBoundingClientRect();
const mx=e.clientX-rect.left;
let best=Infinity,bestRow=null,bestT=null;
for(const n of L.names){
if(L.solo&&L.solo!==n)continue;
for(const p of L.series[n]){
const d=Math.abs(L.xScale(p.t)-mx);
if(d<best){best=d;bestRow=p.row||null;bestT=p.t}
}
}
if(best>30||bestT==null){tip.style.display='none';return}
let html='<b>'+rowTitle(bestRow,bestT)+'</b>';
const fmt=L.yFmt||fmtVal;
for(const n of L.names){
if(L.solo&&L.solo!==n)continue;
const p=L.series[n].find(q=>q.t===bestT);
if(!p)continue;
const c=L.colors[L.names.indexOf(n)%L.colors.length]||COLORS[0];
const name=(L.labels&&L.labels[n])||n;
html+='<br><span style="color:'+c+'">■</span> '+esc(name)+': '+fmt(p.v);
}
tip.innerHTML=html;tip.style.display='block';
let tx=e.clientX+14,ty=e.clientY-10;
if(tx+tip.offsetWidth>window.innerWidth)tx=e.clientX-tip.offsetWidth-14;
if(ty+tip.offsetHeight>window.innerHeight)ty=e.clientY-tip.offsetHeight-10;
tip.style.left=tx+'px';tip.style.top=ty+'px';
});
canvas.addEventListener('mouseleave',()=>{document.getElementById('tooltip').style.display='none'});
});
function rowTitle(row,t){
const bits=[];
if(row&&row.epoch1!=null)bits.push('epoch '+row.epoch1);
if(row&&row.tick!=null)bits.push('window '+row.tick);
bits.push(fmtDur(t));
return esc(bits.join(' · '));
}
function applyLabelHash(label,hash){
const h1=document.querySelector('.header h1');
if(!h1)return;
const short=hash?hash.substring(0,8):null;
if(label&&short)h1.innerHTML='floDl <span>'+esc(label)+' ['+esc(short)+']</span>';
else if(short)h1.innerHTML='floDl <span>['+esc(short)+']</span>';
if(label||short)document.title='floDl'+(label?' - '+label:'')+(short?' ['+short+']':'');
}
function showMeta(meta){
if(!meta)return;
const card=document.getElementById('metaCard'),pre=document.getElementById('metaPre');
if(!card||!pre)return;
try{pre.textContent=typeof meta==='string'?meta:JSON.stringify(meta,null,2)}
catch(e){pre.textContent=String(meta)}
card.dataset.have='1';
card.style.display=current===ROOT?'block':'none';
}
let HARDWARE=null;
function showHardware(hw){
if(!hw)return;
HARDWARE=hw;
renderHardware();
}
function isGpuSeg(s){return /^gr=\d+\s+lr=\d+:/.test(s)}
function hostOfSeg(s){const m=s.match(/^([^:]+):\s*(.*)$/);return m&&!isGpuSeg(s)?m[1]:null}
function hasHostTier(){
for(const p of NODES.keys())if(p.split('/').length>=3)return true;
return false;
}
function hwForLevel(){
if(!HARDWARE)return null;
const segs=HARDWARE.split(' | ').map(s=>s.trim()).filter(Boolean);
const clustered=segs.some(isGpuSeg);
if(!recordMode&&!clustered)return HARDWARE;
const host=current===ROOT?null:current.split('/')[1];
const cpus=clustered
? segs.filter(s=>!isGpuSeg(s))
.filter(s=>{const h=hostOfSeg(s);return !host||!h||h===host})
: (current===ROOT||!hasHostTier()?segs.slice(0,1):[]);
const node=nodeOf(current);
const gpus=[];
if(node.label)gpus.push(node.label);
else for(const p of childPaths(node)){const k=NODES.get(p);if(k&&k.label)gpus.push(k.label)}
const counted=[];
for(const g of gpus){
const last=counted[counted.length-1];
if(last&&last.name===g)last.n++;else counted.push({name:g,n:1});
}
return cpus.concat(counted.map(c=>c.n>1?c.n+'x '+c.name:c.name)).join(' | ');
}
function renderHardware(){
const el=document.getElementById('hwInfo');
if(!el)return;
const line=hwForLevel();
if(!line){el.style.display='none';return}
el.textContent=line;el.style.display='block';
}
function svgOpenHeight(c){return Math.min(c.scrollHeight,Math.round(window.innerHeight*0.7))}
function showSvg(svg){
const card=document.getElementById('svgCard'),c=document.getElementById('svgContainer');
card.dataset.have='1';
card.style.display=current===ROOT?'block':'none';
c.innerHTML=svg;
if(document.getElementById('svgToggle').classList.contains('open')){
c.style.overflowY='auto';
c.style.maxHeight=svgOpenHeight(c)+'px';
}
}
function toggleSvg(){
const t=document.getElementById('svgToggle'),c=document.getElementById('svgContainer');
const opening=!t.classList.contains('open');
t.classList.toggle('open');
c.style.overflowY=opening?'auto':'hidden';
c.style.maxHeight=opening?svgOpenHeight(c)+'px':'0';
}
function seedGpuInit(list){
for(const g of list||[]){
if(g.dev==null)continue;
GPU_INIT.set(g.dev,{name:g.name,vram_total:g.vram_total});
}
}
function fmtDur(s){
if(s==null)return '—';
if(s<1)return Math.round(s*1000)+'ms';
if(s<60){const w=Math.floor(s);const f=Math.floor((s-w)*10);return f>0?w+'.'+f+'s':w+'s'}
const h=Math.floor(s/3600),m=Math.floor(s%3600/60),sec=Math.floor(s%60);
if(h>0)return h+'h '+String(m).padStart(2,'0')+'m';
return m+'m '+String(sec).padStart(2,'0')+'s';
}
function fmtBytes(b){
const GB=1073741824,MB=1048576;
if(b>=GB)return(b/GB).toFixed(1)+' GB';
if(b>=MB)return Math.round(b/MB)+' MB';
return Math.round(b/1024)+' KB';
}
function fmtVal(v){
if(v==null)return '—';
if(Math.abs(v)<0.001&&v!==0)return v.toExponential(2);
if(Math.abs(v)>=1000)return v.toFixed(1);
return v.toFixed(4);
}
function pctFmt(v){return v.toFixed(0)+'%'}
function fmtForKey(k){
if(k==null)return fmtVal;
if(k==='cpu'||k==='gpu_util')return pctFmt;
if(k==='batch_share')return v=>(v*100).toFixed(1)+'%';
if(k.indexOf('vram')===0||k.indexOf('ram_')===0)return fmtBytes;
if(k.indexOf('_ms')>0)return v=>fmtDur(v/1000);
return fmtVal;
}
function fmtClock(ms){
if(!ms)return '—';
const d=new Date(ms);
return String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0')+
':'+String(d.getSeconds()).padStart(2,'0');
}
function esc(s){
return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<')
.replace(/>/g,'>').replace(/"/g,'"');
}
function shortGpuName(name){
return String(name||'').replace(/^NVIDIA\s+/i,'').replace(/GeForce\s+/i,'');
}
if(typeof ARCHIVE_LABEL!=='undefined'||typeof ARCHIVE_HASH!=='undefined'){
applyLabelHash(typeof ARCHIVE_LABEL!=='undefined'?ARCHIVE_LABEL:null,
typeof ARCHIVE_HASH!=='undefined'?ARCHIVE_HASH:null);
}
if(typeof LIVE_LABEL!=='undefined'||typeof LIVE_HASH!=='undefined'){
applyLabelHash(typeof LIVE_LABEL!=='undefined'?LIVE_LABEL:null,
typeof LIVE_HASH!=='undefined'?LIVE_HASH:null);
}
if(typeof ARCHIVE_HARDWARE!=='undefined')showHardware(ARCHIVE_HARDWARE);
if(typeof LIVE_HARDWARE!=='undefined')showHardware(LIVE_HARDWARE);
if(typeof ARCHIVE_META!=='undefined'&&ARCHIVE_META!==null)showMeta(ARCHIVE_META);
if(typeof LIVE_META!=='undefined'&&LIVE_META!==null)showMeta(LIVE_META);
if(typeof ARCHIVE_GPU_INIT!=='undefined'&&ARCHIVE_GPU_INIT!==null)seedGpuInit(ARCHIVE_GPU_INIT);
if(typeof LIVE_GPU_INIT!=='undefined'&&LIVE_GPU_INIT!==null)seedGpuInit(LIVE_GPU_INIT);
current=pathFromHash();
if(typeof ARCHIVE_DATA!=='undefined'){
const archRecords=(typeof ARCHIVE_RECORDS!=='undefined'&&Array.isArray(ARCHIVE_RECORDS))
?ARCHIVE_RECORDS:[];
if(archRecords.length)recordMode=true;
let archiveErr=null,done=0;
for(let i=0;i<ARCHIVE_DATA.length;i++){
try{ingestEpoch(ARCHIVE_DATA[i]);done++}
catch(e){archiveErr=e;console.error('ingestEpoch failed at index '+i+':',e);break}
}
for(let i=0;i<archRecords.length;i++){
try{ingestRecord(archRecords[i])}
catch(e){
archiveErr=archiveErr||e;
console.error('ingestRecord failed at index '+i+':',e);
break;
}
}
try{render()}catch(e){console.error('render:',e);archiveErr=archiveErr||e}
document.getElementById('elapsedDisplay').textContent=
fmtDur(ARCHIVE_DATA.reduce((s,d)=>s+(d.duration||0),0));
markComplete(archiveErr?'Archive error - see console':
(typeof ARCHIVE_COMPLETE!=='undefined'?ARCHIVE_COMPLETE:'Complete'));
if(typeof ARCHIVE_SVG==='string'){try{showSvg(ARCHIVE_SVG)}catch(e){console.error('showSvg:',e)}}
requestAnimationFrame(()=>{try{render()}catch(e){console.error('rAF render:',e)}});
if(archiveErr){
const d=document.createElement('div');
d.style.cssText='position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#1a1d27;color:#f87171;padding:24px;border-radius:8px;border:1px solid #f87171;z-index:999;font-family:monospace;max-width:80%;white-space:pre-wrap';
d.textContent='Archive replay error at epoch '+(done+1)+':\n'+archiveErr.message+
'\n\nCheck browser console (F12) for details.\nProcessed '+done+'/'+ARCHIVE_DATA.length+' epochs.';
document.body.appendChild(d);
}
}else{
const es=new EventSource('/events');
es.addEventListener('epoch',e=>{try{ingestEpoch(JSON.parse(e.data))}catch(err){}});
es.addEventListener('complete',()=>markComplete());
subscribeRoot();
setLevel(current);
if(!elapsedTimer)elapsedTimer=setInterval(updateElapsed,1000);
fetch('/graph.svg').then(r=>{if(r.ok)return r.text();throw 0}).then(showSvg).catch(()=>{});
}
window.addEventListener('resize',()=>scheduleRender());
</script>
</body>
</html>