File size: 6,818 Bytes
2a49d9a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 | /* NanoDex — shared frontend helpers */
const NX = {
fmtTokens(n){ n = Number(n||0);
if(n>=1e9) return (n/1e9).toFixed(2)+'B';
if(n>=1e6) return (n/1e6).toFixed(n>=1e7?0:1).replace(/\.0$/,'')+'M';
if(n>=1e3) return Math.round(n/1e3)+'k';
return String(n); },
fmtParams(n){ n = Number(n||0);
return n>=1e6 ? (n/1e6).toFixed(2)+'M' : Math.round(n/1e3)+'k'; },
fmtTime(s){ s = Number(s||0); if(!isFinite(s)||s<0) return '—';
if(s<90) return Math.round(s)+'s';
if(s<5400) return Math.round(s/60)+' min';
return (s/3600).toFixed(1)+' h'; },
ago(ts){ if(!ts) return '—';
const d = Date.now()/1000 - ts;
if(d<60) return 'just now';
if(d<3600) return Math.floor(d/60)+'m ago';
if(d<86400) return Math.floor(d/3600)+'h ago';
return Math.floor(d/86400)+'d ago'; },
esc(s){ const d=document.createElement('div'); d.textContent=s==null?'':String(s);
return d.innerHTML; },
chip(status, extra){
const label = {queued:'queued', running:'training', done:'completed',
failed:'failed', cancelled:'cancelled'}[status] || status;
return `<span class="chip ${status}"><i></i>${label}${extra?' · '+NX.esc(extra):''}</span>`;
},
async get(url){
const r = await fetch(url, {headers:{'Accept':'application/json'}});
if(!r.ok) throw new Error((await r.json().catch(()=>({}))).error || r.statusText);
return r.json();
},
async post(url, body){
const r = await fetch(url, {method:'POST',
headers:{'Content-Type':'application/json'}, body: JSON.stringify(body||{})});
const j = await r.json().catch(()=>({}));
if(!r.ok) throw new Error(j.error || r.statusText);
return j;
},
toast(msg, kind){
let t = document.querySelector('.toast');
if(!t){ t = document.createElement('div'); t.className='toast';
document.body.appendChild(t); }
t.className = 'toast ' + (kind||'');
t.innerHTML = msg;
requestAnimationFrame(()=> t.classList.add('on'));
clearTimeout(t._h);
t._h = setTimeout(()=> t.classList.remove('on'), 5200);
},
runRow(j){
const pct = (j.progress*100).toFixed(1);
const bits = [];
if(j.status==='running'){
bits.push(`<span><b>${pct}%</b> of ${NX.fmtTokens(j.target_tokens)}</span>`);
if(j.loss) bits.push(`<span>loss <b>${j.loss.toFixed(4)}</b></span>`);
bits.push(`<span><b>${Math.round(j.tok_per_s/1e3)}k</b> tok/s</span>`);
if(j.eta_s) bits.push(`<span><b>${NX.fmtTime(j.eta_s)}</b> left</span>`);
} else if(j.status==='queued'){
bits.push(`<span>position <b>#${j.queue_position||'?'}</b></span>`);
bits.push(`<span><b>${NX.fmtTokens(j.target_tokens)}</b> tokens queued</span>`);
} else if(j.status==='done'){
bits.push(`<span><b>${NX.fmtTokens(j.tokens_seen)}</b> tokens</span>`);
if(j.loss) bits.push(`<span>final loss <b>${j.loss.toFixed(4)}</b></span>`);
bits.push(`<span>${j.pushed_repo ? 'published' : 'not published'}</span>`);
} else {
bits.push(`<span>${NX.esc(j.error||'—')}</span>`);
}
const showBar = j.status==='running';
return `<a class="run" href="/models/${j.id}">
<div class="run-top">
<h4>${NX.esc(j.name)}</h4>
${NX.chip(j.status)}
</div>
<div class="run-meta">
<span class="mono">${NX.esc(j.tier_label)}</span>
<span>${Number(j.n_params).toLocaleString()} params</span>
${bits.join('')}
<span class="muted">${NX.ago(j.created_at)}</span>
</div>
${showBar ? `<div class="bar"><i style="width:${pct}%"></i></div>` : ''}
</a>`;
},
/* Minimal dependency-free loss chart.
The viewBox is set to the element's real pixel size so that 1 user unit ==
1 CSS pixel: a fixed 760-wide viewBox squeezed into a 280px phone would
render the axis labels at ~4px. */
lossChart(el, pts){
if(!el) return;
el._pts = pts;
const rect = el.getBoundingClientRect();
const W = Math.max(280, Math.round(rect.width) || 760);
const H = Math.max(170, Math.round(rect.height) || 280);
const narrow = W < 460;
const PL = narrow ? 40 : 54, PR = narrow ? 8 : 14;
const PT = 16, PB = narrow ? 28 : 34;
const TICKS = narrow ? 3 : 4;
el.setAttribute('viewBox', `0 0 ${W} ${H}`);
if(!pts || pts.length < 2){
el.innerHTML = `<text x="${W/2}" y="${H/2}" class="ph"
text-anchor="middle">waiting for the first metrics…</text>`;
return;
}
const xs = pts.map(p=>p.tokens), ys = pts.map(p=>p.loss);
const x0=Math.min(...xs), x1=Math.max(...xs);
let y0=Math.min(...ys), y1=Math.max(...ys);
const padY=(y1-y0)*0.12 || 0.4; y0-=padY; y1+=padY;
const sx = v => PL + (v-x0)/((x1-x0)||1)*(W-PL-PR);
const sy = v => PT + (1-(v-y0)/((y1-y0)||1))*(H-PT-PB);
const d = pts.map((p,i)=> (i?'L':'M')+sx(p.tokens).toFixed(1)+' '+sy(p.loss).toFixed(1)).join(' ');
const area = d + ` L${sx(x1).toFixed(1)} ${H-PB} L${sx(x0).toFixed(1)} ${H-PB} Z`;
let g='';
for(let i=0;i<=TICKS;i++){
const v = y0+(y1-y0)*i/TICKS, y = sy(v);
g += `<line class="gl" x1="${PL}" y1="${y.toFixed(1)}" x2="${W-PR}" y2="${y.toFixed(1)}"/>`;
g += `<text class="ax" x="${PL-7}" y="${(y+3.5).toFixed(1)}" text-anchor="end">${v.toFixed(2)}</text>`;
}
for(let i=0;i<=TICKS;i++){
const v = x0+(x1-x0)*i/TICKS, x = sx(v);
const anchor = i===0 ? 'start' : (i===TICKS ? 'end' : 'middle');
g += `<text class="ax" x="${x.toFixed(1)}" y="${H-PB+18}" text-anchor="${anchor}">${NX.fmtTokens(v)}</text>`;
}
const last = pts[pts.length-1];
el.innerHTML = `<defs><linearGradient id="lg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#2ee6a8" stop-opacity=".22"/>
<stop offset="100%" stop-color="#2ee6a8" stop-opacity="0"/>
</linearGradient></defs>
${g}
<path class="ar" d="${area}"/>
<path class="ln" d="${d}"/>
<circle cx="${sx(last.tokens).toFixed(1)}" cy="${sy(last.loss).toFixed(1)}"
r="4" fill="#2ee6a8"/>
<text class="ax" x="${W-PR}" y="${PT+2}" text-anchor="end"
style="fill:#2ee6a8;font-size:12px">loss ${last.loss.toFixed(4)}</text>`;
},
poll(fn, ms){
let stop=false;
const tick = async () => {
if(stop) return;
try { await fn(); } catch(e){ /* keep polling */ }
if(!stop) setTimeout(tick, ms);
};
tick();
return ()=>{ stop=true; };
}
};
window.NX = NX;
/* Charts use pixel-sized viewBoxes, so they must be redrawn when the box
changes — rotating a phone, or opening the keyboard. */
let _rsT;
addEventListener('resize', () => {
clearTimeout(_rsT);
_rsT = setTimeout(() => {
document.querySelectorAll('svg.chart').forEach(el => {
if(el._pts) NX.lossChart(el, el._pts);
});
}, 160);
});
|