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);
});