neoagent 2.4.2-beta.4 → 2.4.2-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -185,6 +185,29 @@ button:disabled,
185
185
  border: 1px solid rgba(222, 138, 120, 0.20);
186
186
  }
187
187
 
188
+ .range-btn {
189
+ padding: 5px 12px;
190
+ font-size: 12px;
191
+ font-weight: 600;
192
+ border: 1px solid var(--border);
193
+ border-radius: 6px;
194
+ background: transparent;
195
+ color: var(--text-muted);
196
+ cursor: pointer;
197
+ transition: background 0.15s, color 0.15s, border-color 0.15s;
198
+ }
199
+
200
+ .range-btn:hover {
201
+ background: rgba(224, 240, 224, 0.07);
202
+ color: var(--text);
203
+ }
204
+
205
+ .range-btn.active {
206
+ background: var(--accent-muted);
207
+ color: var(--accent);
208
+ border-color: var(--accent);
209
+ }
210
+
188
211
  .btn-danger:hover:not(:disabled) {
189
212
  background: rgba(222, 138, 120, 0.20);
190
213
  }
@@ -355,11 +355,11 @@ async function loadModels() {
355
355
  try {
356
356
  const data = await api('/admin/api/models').then((r) => r.json());
357
357
  const models = data.models || [];
358
- const enabledModels = data.enabledModels || [];
358
+ const disabledSet = new Set(data.disabledModels || []);
359
359
 
360
- if (!models.length) { el.innerHTML = '<div class="empty">No models found</div>'; return; }
360
+ if (!models.length) { el.innerHTML = '<div class="empty">No models found — configure providers first.</div>'; return; }
361
361
 
362
- // Group by provider for better readability
362
+ // Sort: provider alpha, then price low→high within provider
363
363
  models.sort((a, b) => {
364
364
  if (a.provider !== b.provider) return a.provider.localeCompare(b.provider);
365
365
  const ac = a.inputCostPerM ?? Infinity;
@@ -367,12 +367,12 @@ async function loadModels() {
367
367
  return ac - bc;
368
368
  });
369
369
 
370
- const enabledCount = enabledModels.length === 0 ? models.length : enabledModels.length;
370
+ const enabledCount = models.length - disabledSet.size;
371
371
 
372
372
  let html = `
373
373
  <div style="display:flex;align-items:center;gap:8px;margin-bottom:14px;flex-wrap:wrap;">
374
- <button class="btn btn-ghost" style="padding:5px 12px;font-size:12px;" onclick="toggleAllModels(true)">Select All</button>
375
- <button class="btn btn-ghost" style="padding:5px 12px;font-size:12px;" onclick="toggleAllModels(false)">Select None</button>
374
+ <button class="btn btn-ghost" style="padding:5px 12px;font-size:12px;" onclick="toggleAllModels(true)">Enable All</button>
375
+ <button class="btn btn-ghost" style="padding:5px 12px;font-size:12px;" onclick="toggleAllModels(false)">Disable All</button>
376
376
  <span style="margin-left:4px;font-size:12px;color:var(--text-muted);" id="model-count-label">${enabledCount} of ${models.length} enabled</span>
377
377
  </div>
378
378
  <table class="users-table">
@@ -387,15 +387,14 @@ async function loadModels() {
387
387
  <tbody>`;
388
388
 
389
389
  for (const m of models) {
390
- const isChecked = enabledModels.length === 0 || enabledModels.includes(m.id);
391
- const rowOpacity = isChecked ? '1' : '0.45';
390
+ const isEnabled = !disabledSet.has(m.id);
392
391
  const priceStr = fmtModelPrice(m.inputCostPerM);
393
392
  const tierCls = priceTierClass(m.priceTier);
394
393
  const icon = purposeIcon(m.purpose);
395
394
  html += `
396
- <tr style="opacity:${rowOpacity}" id="model-row-${esc(m.id)}">
395
+ <tr style="opacity:${isEnabled ? '1' : '0.45'}">
397
396
  <td style="text-align:center;">
398
- <input type="checkbox" class="model-cb" value="${esc(m.id)}" ${isChecked ? 'checked' : ''}
397
+ <input type="checkbox" class="model-cb" value="${esc(m.id)}" ${isEnabled ? 'checked' : ''}
399
398
  onchange="onModelToggle(this)">
400
399
  </td>
401
400
  <td>
@@ -403,7 +402,7 @@ async function loadModels() {
403
402
  <div style="font-size:11px;color:var(--text-muted);font-family:var(--font-mono);margin-top:2px;">${esc(m.id)}</div>
404
403
  </td>
405
404
  <td style="font-size:13px;text-transform:capitalize;">${esc(m.provider)}</td>
406
- <td><span class="badge badge-idle" style="gap:4px;">${icon} ${esc(m.purpose)}</span></td>
405
+ <td><span class="badge badge-idle">${icon} ${esc(m.purpose)}</span></td>
407
406
  <td><span class="badge ${tierCls}">${esc(m.priceTier ?? '?')}</span></td>
408
407
  <td style="text-align:right;font-family:var(--font-mono);font-size:13px;font-weight:600;color:var(--text);">${priceStr}</td>
409
408
  </tr>
@@ -442,20 +441,19 @@ function toggleAllModels(enable) {
442
441
  async function saveEnabledModels(btn) {
443
442
  const cbs = document.querySelectorAll('.model-cb');
444
443
  if (!cbs.length) return;
445
-
446
- // If all are checked, we can save an empty list to mean "all enabled"
447
- const allChecked = Array.from(cbs).every(cb => cb.checked);
448
- const enabledModels = allChecked ? [] : Array.from(cbs).filter(cb => cb.checked).map(cb => cb.value);
449
-
444
+
445
+ // Persist only the disabled (unchecked) models
446
+ const disabledModels = Array.from(cbs).filter(cb => !cb.checked).map(cb => cb.value);
447
+
450
448
  btn.disabled = true;
451
449
  const original = btn.textContent;
452
450
  btn.textContent = 'Saving…';
453
-
451
+
454
452
  try {
455
- const res = await api('/admin/api/models/enabled', {
453
+ const res = await api('/admin/api/models/config', {
456
454
  method: 'PUT',
457
455
  headers: { 'Content-Type': 'application/json' },
458
- body: JSON.stringify({ enabledModels }),
456
+ body: JSON.stringify({ disabledModels }),
459
457
  });
460
458
  if (!res.ok) {
461
459
  const body = await res.json().catch(() => ({}));
@@ -2,12 +2,21 @@
2
2
 
3
3
  // ── Analytics ─────────────────────────────────────────────────────────────
4
4
 
5
+ let _analyticsRange = 30;
6
+
7
+ function setAnalyticsRange(days, btn) {
8
+ _analyticsRange = days;
9
+ document.querySelectorAll('.range-btn').forEach((b) => b.classList.remove('active'));
10
+ if (btn) btn.classList.add('active');
11
+ loadAnalytics();
12
+ }
13
+
5
14
  async function loadAnalytics() {
6
15
  const el = document.getElementById('analytics-content');
7
16
  if (!el) return;
8
17
  el.innerHTML = '<div class="empty"><span class="spinner"></span></div>';
9
18
  try {
10
- const data = await api('/admin/api/analytics').then((r) => r.json());
19
+ const data = await api(`/admin/api/analytics?range=${_analyticsRange}`).then((r) => r.json());
11
20
  renderAnalytics(el, data);
12
21
  setTs('analytics-ts', 'Updated');
13
22
  } catch (err) {
@@ -15,89 +24,285 @@ async function loadAnalytics() {
15
24
  }
16
25
  }
17
26
 
18
- function renderAnalytics(el, { stats, topUsers, recentRuns }) {
27
+ function renderAnalytics(el, { stats, runsByDay, usersByDay, modelBreakdown, statusBreakdown, topUsers, recentRuns }) {
19
28
  const s = stats || {};
20
29
  el.innerHTML = `
21
- <div class="stat-grid">
22
- <div class="stat-card">
23
- <div class="stat-value">${fmtNum(s.totalUsers)}</div>
24
- <div class="stat-label">Total Users</div>
25
- </div>
26
- <div class="stat-card">
27
- <div class="stat-value">${fmtNum(s.activeToday)}</div>
28
- <div class="stat-label">Active Today</div>
29
- </div>
30
- <div class="stat-card">
31
- <div class="stat-value">${fmtNum(s.newThisWeek)}</div>
32
- <div class="stat-label">New This Week</div>
33
- </div>
34
- <div class="stat-card">
35
- <div class="stat-value">${fmtNum(s.totalRuns)}</div>
36
- <div class="stat-label">Total Runs</div>
30
+ ${renderStatGrid(s)}
31
+ ${renderCharts(runsByDay || [], usersByDay || [], _analyticsRange)}
32
+ ${renderBreakdowns(modelBreakdown || [], statusBreakdown || [])}
33
+ ${renderTopUsers(topUsers || [])}
34
+ ${renderRecentRuns(recentRuns || [])}
35
+ `;
36
+ }
37
+
38
+ // ── Stat cards ─────────────────────────────────────────────────────────────
39
+
40
+ function renderStatGrid(s) {
41
+ const cards = [
42
+ { label: 'Total Users', value: fmtNum(s.totalUsers), sub: null },
43
+ { label: 'Active Today', value: fmtNum(s.activeToday), sub: null },
44
+ { label: 'New This Week', value: fmtNum(s.newThisWeek), sub: null },
45
+ { label: 'Active Sessions', value: fmtNum(s.activeSessions), sub: null },
46
+ { label: 'Total Runs', value: fmtNum(s.totalRuns), sub: null },
47
+ { label: 'Runs Today', value: fmtNum(s.runsToday), sub: null },
48
+ { label: 'Runs This Week', value: fmtNum(s.runsThisWeek), sub: null },
49
+ { label: 'Success Rate', value: (s.successRate ?? '—') + (s.successRate != null ? '%' : ''), sub: null },
50
+ { label: 'Total Tokens', value: fmtTokens(s.totalTokens), sub: null },
51
+ { label: 'Tokens Today', value: fmtTokens(s.tokensToday), sub: null },
52
+ { label: 'Avg Tokens/Run', value: fmtTokens(s.avgTokensPerRun), sub: null },
53
+ { label: 'Artifact Storage',value: fmtBytes(s.totalStorage), sub: null },
54
+ ];
55
+ return `<div class="stat-grid">${cards.map((c) => `
56
+ <div class="stat-card">
57
+ <div class="stat-value">${c.value}</div>
58
+ <div class="stat-label">${c.label}</div>
59
+ </div>`).join('')}
60
+ </div>`;
61
+ }
62
+
63
+ // ── SVG Charts ─────────────────────────────────────────────────────────────
64
+
65
+ function renderCharts(runsByDay, usersByDay, range) {
66
+ return `
67
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:20px;">
68
+ <div class="card">
69
+ <div class="card-title" style="margin-bottom:12px;">Runs per Day</div>
70
+ ${buildBarChart(fillDates(runsByDay, range, 'runs'), 'runs', 'tokens')}
37
71
  </div>
38
- <div class="stat-card">
39
- <div class="stat-value">${fmtNum(s.runsToday)}</div>
40
- <div class="stat-label">Runs Today</div>
72
+ <div class="card">
73
+ <div class="card-title" style="margin-bottom:12px;">Tokens per Day</div>
74
+ ${buildAreaChart(fillDates(runsByDay, range, 'tokens'), 'tokens')}
41
75
  </div>
42
- <div class="stat-card">
43
- <div class="stat-value">${fmtTokens(s.totalTokens)}</div>
44
- <div class="stat-label">Total Tokens</div>
76
+ </div>`;
77
+ }
78
+
79
+ function fillDates(data, range, _key) {
80
+ const map = new Map((data || []).map((d) => [d.date, d]));
81
+ const out = [];
82
+ for (let i = range - 1; i >= 0; i--) {
83
+ const d = new Date(Date.now() - i * 86_400_000);
84
+ const key = d.toISOString().slice(0, 10);
85
+ out.push(map.get(key) || { date: key, runs: 0, tokens: 0 });
86
+ }
87
+ return out;
88
+ }
89
+
90
+ function buildBarChart(data, key, altKey) {
91
+ const W = 560, H = 110, pad = { top: 8, bottom: 22, left: 4, right: 4 };
92
+ const vals = data.map((d) => d[key] || 0);
93
+ const max = Math.max(...vals, 1);
94
+ const n = data.length;
95
+ const gap = 2;
96
+ const barW = Math.max(1, Math.floor((W - pad.left - pad.right - (n - 1) * gap) / n));
97
+ const innerH = H - pad.top - pad.bottom;
98
+
99
+ const accentColor = 'var(--accent)';
100
+ const mutedColor = 'rgba(126,210,126,0.18)';
101
+
102
+ const bars = data.map((d, i) => {
103
+ const v = d[key] || 0;
104
+ const bh = v === 0 ? 1 : Math.max(2, Math.round((v / max) * innerH));
105
+ const x = pad.left + i * (barW + gap);
106
+ const y = pad.top + innerH - bh;
107
+ const altV = altKey ? (d[altKey] || 0) : 0;
108
+ const label = altKey ? `${d.date}\n${key}: ${fmtNum(v)}\n${altKey}: ${fmtTokens(altV)}` : `${d.date}: ${fmtNum(v)}`;
109
+ return `<rect x="${x}" y="${y}" width="${barW}" height="${bh}"
110
+ fill="${v > 0 ? accentColor : mutedColor}" rx="1">
111
+ <title>${esc(label)}</title></rect>`;
112
+ });
113
+
114
+ // X-axis date labels — only first, middle, last
115
+ const labelIdxs = [0, Math.floor(n / 2), n - 1];
116
+ const labels = labelIdxs.map((i) => {
117
+ const x = pad.left + i * (barW + gap) + barW / 2;
118
+ const y = H - 4;
119
+ return `<text x="${x}" y="${y}" text-anchor="middle" font-size="9" fill="var(--text-muted)">${esc(data[i]?.date?.slice(5) || '')}</text>`;
120
+ });
121
+
122
+ return `<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:${H}px;display:block;">${bars.join('')}${labels.join('')}</svg>`;
123
+ }
124
+
125
+ function buildAreaChart(data, key) {
126
+ const W = 560, H = 110, pad = { top: 8, bottom: 22, left: 4, right: 4 };
127
+ const vals = data.map((d) => d[key] || 0);
128
+ const max = Math.max(...vals, 1);
129
+ const n = data.length;
130
+ const innerW = W - pad.left - pad.right;
131
+ const innerH = H - pad.top - pad.bottom;
132
+
133
+ const pts = data.map((d, i) => {
134
+ const x = pad.left + (n <= 1 ? innerW / 2 : (i / (n - 1)) * innerW);
135
+ const v = d[key] || 0;
136
+ const y = pad.top + innerH - Math.max(0, (v / max) * innerH);
137
+ return [x, y, d.date, v];
138
+ });
139
+
140
+ if (!pts.length) return `<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:${H}px;display:block;"></svg>`;
141
+
142
+ const linePts = pts.map(([x, y]) => `${x},${y}`).join(' ');
143
+ const bottomY = pad.top + innerH;
144
+ const areaPath = `M ${pts[0][0]},${bottomY} L ${pts.map(([x, y]) => `${x},${y}`).join(' L ')} L ${pts[pts.length - 1][0]},${bottomY} Z`;
145
+
146
+ const dots = pts.map(([x, y, date, v]) =>
147
+ `<circle cx="${x}" cy="${y}" r="2.5" fill="var(--accent)" opacity="0.85">
148
+ <title>${esc(date)}: ${fmtTokens(v)}</title>
149
+ </circle>`);
150
+
151
+ const labelIdxs = [0, Math.floor(n / 2), n - 1];
152
+ const labels = labelIdxs.map((i) => {
153
+ const [x, , date] = pts[i] || [];
154
+ if (x == null) return '';
155
+ return `<text x="${x}" y="${H - 4}" text-anchor="middle" font-size="9" fill="var(--text-muted)">${esc((date || '').slice(5))}</text>`;
156
+ });
157
+
158
+ return `<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:${H}px;display:block;">
159
+ <defs>
160
+ <linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
161
+ <stop offset="0%" stop-color="var(--accent)" stop-opacity="0.28"/>
162
+ <stop offset="100%" stop-color="var(--accent)" stop-opacity="0.03"/>
163
+ </linearGradient>
164
+ </defs>
165
+ <path d="${areaPath}" fill="url(#areaGrad)"/>
166
+ <polyline points="${linePts}" fill="none" stroke="var(--accent)" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>
167
+ ${dots.join('')}
168
+ ${labels.join('')}
169
+ </svg>`;
170
+ }
171
+
172
+ // ── Breakdowns ─────────────────────────────────────────────────────────────
173
+
174
+ function renderBreakdowns(modelBreakdown, statusBreakdown) {
175
+ return `
176
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px;">
177
+ ${renderModelBreakdown(modelBreakdown)}
178
+ ${renderStatusBreakdown(statusBreakdown)}
179
+ </div>`;
180
+ }
181
+
182
+ function renderModelBreakdown(models) {
183
+ if (!models.length) return '<div class="card"><div class="card-title">Model Usage</div><div class="empty" style="padding:20px 0;">No data</div></div>';
184
+ const maxRuns = Math.max(...models.map((m) => m.runs), 1);
185
+ return `
186
+ <div class="card">
187
+ <div class="card-title" style="margin-bottom:12px;">Model Usage (selected period)</div>
188
+ <table class="analytics-table">
189
+ <thead><tr><th>Model</th><th style="text-align:right;">Runs</th><th style="text-align:right;">Tokens</th><th style="width:80px;"></th></tr></thead>
190
+ <tbody>${models.map((m) => `
191
+ <tr>
192
+ <td style="font-family:var(--font-mono);font-size:11px;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(m.model)}</td>
193
+ <td style="text-align:right;">${fmtNum(m.runs)}</td>
194
+ <td style="text-align:right;">${fmtTokens(m.tokens)}</td>
195
+ <td><div class="mini-bar"><div class="mini-bar-fill" style="width:${Math.round((m.runs / maxRuns) * 100)}%"></div></div></td>
196
+ </tr>`).join('')}
197
+ </tbody>
198
+ </table>
199
+ </div>`;
200
+ }
201
+
202
+ const STATUS_COLORS = {
203
+ completed: 'var(--success)',
204
+ error: 'var(--danger)',
205
+ running: 'var(--accent)',
206
+ pending: 'var(--text-muted)',
207
+ };
208
+
209
+ function renderStatusBreakdown(statuses) {
210
+ if (!statuses.length) return '<div class="card"><div class="card-title">Run Status</div><div class="empty" style="padding:20px 0;">No data</div></div>';
211
+ const total = statuses.reduce((a, s) => a + s.count, 0);
212
+ const pills = statuses.map((s) => {
213
+ const pct = total > 0 ? Math.round((s.count / total) * 100) : 0;
214
+ const color = STATUS_COLORS[s.status] || 'var(--text-muted)';
215
+ return `<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--border);">
216
+ <div style="display:flex;align-items:center;gap:10px;">
217
+ <div style="width:10px;height:10px;border-radius:50%;background:${color};flex-shrink:0;"></div>
218
+ <span style="font-size:13px;text-transform:capitalize;">${esc(s.status)}</span>
45
219
  </div>
46
- <div class="stat-card">
47
- <div class="stat-value">${fmtNum(s.activeSessions)}</div>
48
- <div class="stat-label">Active Sessions</div>
220
+ <div style="display:flex;align-items:center;gap:12px;">
221
+ <span style="font-family:var(--font-mono);font-size:13px;font-weight:600;">${fmtNum(s.count)}</span>
222
+ <span style="font-size:11px;color:var(--text-muted);width:34px;text-align:right;">${pct}%</span>
49
223
  </div>
50
- <div class="stat-card">
51
- <div class="stat-value">${fmtBytes(s.totalStorage)}</div>
52
- <div class="stat-label">Artifact Storage</div>
224
+ </div>`;
225
+ });
226
+
227
+ // Mini donut from status percentages using SVG
228
+ const donut = buildDonut(statuses, total);
229
+
230
+ return `
231
+ <div class="card">
232
+ <div class="card-title" style="margin-bottom:12px;">Run Status (all time)</div>
233
+ <div style="display:flex;gap:20px;align-items:flex-start;">
234
+ <div style="flex:1;">${pills.join('')}</div>
235
+ <div style="flex-shrink:0;margin-top:4px;">${donut}</div>
53
236
  </div>
54
- </div>
237
+ </div>`;
238
+ }
55
239
 
56
- ${renderTopUsers(topUsers || [])}
57
- ${renderRecentRuns(recentRuns || [])}
58
- `;
240
+ function buildDonut(statuses, total) {
241
+ const R = 36, cx = 44, cy = 44, strokeW = 12;
242
+ const circum = 2 * Math.PI * R;
243
+ let offset = 0;
244
+ const slices = statuses.map((s) => {
245
+ const pct = total > 0 ? s.count / total : 0;
246
+ const dash = pct * circum;
247
+ const gap = circum - dash;
248
+ const color = STATUS_COLORS[s.status] || 'var(--text-muted)';
249
+ const slice = `<circle cx="${cx}" cy="${cy}" r="${R}" fill="none"
250
+ stroke="${color}" stroke-width="${strokeW}"
251
+ stroke-dasharray="${dash} ${gap}"
252
+ stroke-dashoffset="${-offset}"
253
+ transform="rotate(-90 ${cx} ${cy})"/>`;
254
+ offset += dash;
255
+ return slice;
256
+ });
257
+ return `<svg width="88" height="88" viewBox="0 0 88 88">
258
+ <circle cx="${cx}" cy="${cy}" r="${R}" fill="none" stroke="var(--border)" stroke-width="${strokeW}"/>
259
+ ${slices.join('')}
260
+ </svg>`;
59
261
  }
60
262
 
263
+ // ── Top Users ───────────────────────────────────────────────────────────────
264
+
61
265
  function renderTopUsers(users) {
62
266
  if (!users.length) return '';
63
- const maxRuns = Math.max(...users.map((u) => u.runs), 1);
267
+ const maxTokens = Math.max(...users.map((u) => u.tokens), 1);
64
268
  return `
65
- <div class="card" style="margin-top:20px;">
66
- <div class="card-title">Top Users by Runs</div>
269
+ <div class="card" style="margin-top:16px;">
270
+ <div class="card-title" style="margin-bottom:12px;">Top Users by Token Usage</div>
67
271
  <table class="analytics-table">
68
272
  <thead><tr>
69
- <th>User</th><th>Runs</th><th>Tokens</th><th>Storage</th><th>Activity</th>
273
+ <th>User</th><th style="text-align:right;">Runs</th><th style="text-align:right;">Tokens</th><th style="text-align:right;">Storage</th><th style="width:100px;"></th>
70
274
  </tr></thead>
71
275
  <tbody>${users.map((u) => `
72
276
  <tr>
73
277
  <td><span class="user-avatar">${esc((u.display_name || u.username || '?')[0]).toUpperCase()}</span> ${esc(u.display_name || u.username)}</td>
74
- <td>${fmtNum(u.runs)}</td>
75
- <td>${fmtTokens(u.tokens)}</td>
76
- <td>${fmtBytes(u.storage)}</td>
77
- <td>
78
- <div class="mini-bar"><div class="mini-bar-fill" style="width:${Math.round((u.runs / maxRuns) * 100)}%"></div></div>
79
- </td>
278
+ <td style="text-align:right;">${fmtNum(u.runs)}</td>
279
+ <td style="text-align:right;">${fmtTokens(u.tokens)}</td>
280
+ <td style="text-align:right;">${fmtBytes(u.storage)}</td>
281
+ <td><div class="mini-bar"><div class="mini-bar-fill" style="width:${Math.round((u.tokens / maxTokens) * 100)}%"></div></div></td>
80
282
  </tr>`).join('')}
81
283
  </tbody>
82
284
  </table>
83
285
  </div>`;
84
286
  }
85
287
 
288
+ // ── Recent Runs ─────────────────────────────────────────────────────────────
289
+
86
290
  function renderRecentRuns(runs) {
87
291
  if (!runs.length) return '';
88
292
  return `
89
293
  <div class="card" style="margin-top:16px;">
90
- <div class="card-title">Recent Runs</div>
294
+ <div class="card-title" style="margin-bottom:12px;">Recent Runs</div>
91
295
  <table class="analytics-table">
92
296
  <thead><tr>
93
- <th>User</th><th>Title</th><th>Status</th><th>Tokens</th><th>Started</th>
297
+ <th>User</th><th>Title</th><th>Model</th><th>Status</th><th style="text-align:right;">Tokens</th><th>Started</th>
94
298
  </tr></thead>
95
299
  <tbody>${runs.map((r) => `
96
300
  <tr>
97
301
  <td>${esc(r.username)}</td>
98
302
  <td class="cell-truncate">${esc(r.title || '(untitled)')}</td>
303
+ <td style="font-family:var(--font-mono);font-size:11px;color:var(--text-muted);">${esc(r.model || '—')}</td>
99
304
  <td><span class="run-badge run-${esc(r.status)}">${esc(r.status)}</span></td>
100
- <td>${fmtTokens(r.total_tokens)}</td>
305
+ <td style="text-align:right;">${fmtTokens(r.total_tokens)}</td>
101
306
  <td>${fmtTime(r.created_at)}</td>
102
307
  </tr>`).join('')}
103
308
  </tbody>
@@ -105,7 +310,7 @@ function renderRecentRuns(runs) {
105
310
  </div>`;
106
311
  }
107
312
 
108
- // ── Shared number helpers (also used by other modules) ────────────────────
313
+ // ── Shared number helpers ──────────────────────────────────────────────────
109
314
 
110
315
  function fmtNum(n) {
111
316
  if (n == null) return '—';
@@ -114,6 +319,7 @@ function fmtNum(n) {
114
319
 
115
320
  function fmtTokens(n) {
116
321
  if (!n) return '0';
322
+ if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(1) + 'B';
117
323
  if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
118
324
  if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
119
325
  return String(n);
@@ -915,9 +915,16 @@
915
915
  <div class="page-header">
916
916
  <div>
917
917
  <h1 class="page-title">Analytics</h1>
918
- <p class="page-subtitle">Usage statistics, top users, and recent activity</p>
918
+ <p class="page-subtitle">Usage statistics, model breakdown, and recent activity</p>
919
+ </div>
920
+ <div style="display:flex;align-items:center;gap:10px;">
921
+ <div style="display:flex;gap:4px;">
922
+ <button class="range-btn" onclick="setAnalyticsRange(7,this)">7d</button>
923
+ <button class="range-btn active" onclick="setAnalyticsRange(30,this)">30d</button>
924
+ <button class="range-btn" onclick="setAnalyticsRange(90,this)">90d</button>
925
+ </div>
926
+ <span class="refresh-hint" id="analytics-ts" aria-live="polite"></span>
919
927
  </div>
920
- <span class="refresh-hint" id="analytics-ts" aria-live="polite"></span>
921
928
  </div>
922
929
  <div class="content">
923
930
  <div id="analytics-content"><div class="empty"><span class="spinner"></span></div></div>
@@ -1 +1 @@
1
- 4783d0646db3c87d4fbb189103797925
1
+ 15d41acc002ea2eaaa9deb2490573dd5
@@ -1 +1 @@
1
-
1
+
@@ -1 +1 @@
1
- "DQ4HIWFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl8xMDI0LnBuZwwBDQEHBWFzc2V0ByFhc3NldHMvYnJhbmRpbmcvYXBwX2ljb25fMTAyNC5wbmcHIGFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl8yNTYucG5nDAENAQcFYXNzZXQHIGFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl8yNTYucG5nByBhc3NldHMvYnJhbmRpbmcvYXBwX2ljb25fNTEyLnBuZwwBDQEHBWFzc2V0ByBhc3NldHMvYnJhbmRpbmcvYXBwX2ljb25fNTEyLnBuZwcnYXNzZXRzL2JyYW5kaW5nL2FwcF9pY29uX2xpZ2h0XzEwMjQucG5nDAENAQcFYXNzZXQHJ2Fzc2V0cy9icmFuZGluZy9hcHBfaWNvbl9saWdodF8xMDI0LnBuZwcmYXNzZXRzL2JyYW5kaW5nL2FwcF9pY29uX2xpZ2h0XzI1Ni5wbmcMAQ0BBwVhc3NldAcmYXNzZXRzL2JyYW5kaW5nL2FwcF9pY29uX2xpZ2h0XzI1Ni5wbmcHJmFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl9saWdodF81MTIucG5nDAENAQcFYXNzZXQHJmFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl9saWdodF81MTIucG5nByRhc3NldHMvYnJhbmRpbmcvb25ib2FyZGluZ19pbnRyby5tcDQMAQ0BBwVhc3NldAckYXNzZXRzL2JyYW5kaW5nL29uYm9hcmRpbmdfaW50cm8ubXA0Byxhc3NldHMvYnJhbmRpbmcvdHJheV9pY29uX2xpZ2h0X3RlbXBsYXRlLnBuZwwBDQEHBWFzc2V0Byxhc3NldHMvYnJhbmRpbmcvdHJheV9pY29uX2xpZ2h0X3RlbXBsYXRlLnBuZwcmYXNzZXRzL2JyYW5kaW5nL3RyYXlfaWNvbl90ZW1wbGF0ZS5wbmcMAQ0BBwVhc3NldAcmYXNzZXRzL2JyYW5kaW5nL3RyYXlfaWNvbl90ZW1wbGF0ZS5wbmcHMnBhY2thZ2VzL2N1cGVydGlub19pY29ucy9hc3NldHMvQ3VwZXJ0aW5vSWNvbnMudHRmDAENAQcFYXNzZXQHMnBhY2thZ2VzL2N1cGVydGlub19pY29ucy9hc3NldHMvQ3VwZXJ0aW5vSWNvbnMudHRmByxwYWNrYWdlcy9taXhwYW5lbF9mbHV0dGVyL2Fzc2V0cy9taXhwYW5lbC5qcwwBDQEHBWFzc2V0ByxwYWNrYWdlcy9taXhwYW5lbF9mbHV0dGVyL2Fzc2V0cy9taXhwYW5lbC5qcwc3cGFja2FnZXMvcmVjb3JkX3dlYi9hc3NldHMvanMvcmVjb3JkLmZpeHdlYm1kdXJhdGlvbi5qcwwBDQEHBWFzc2V0BzdwYWNrYWdlcy9yZWNvcmRfd2ViL2Fzc2V0cy9qcy9yZWNvcmQuZml4d2VibWR1cmF0aW9uLmpzBy9wYWNrYWdlcy9yZWNvcmRfd2ViL2Fzc2V0cy9qcy9yZWNvcmQud29ya2xldC5qcwwBDQEHBWFzc2V0By9wYWNrYWdlcy9yZWNvcmRfd2ViL2Fzc2V0cy9qcy9yZWNvcmQud29ya2xldC5qcwcWd2ViL2ljb25zL0ljb24tMTkyLnBuZwwBDQEHBWFzc2V0BxZ3ZWIvaWNvbnMvSWNvbi0xOTIucG5n"
1
+ "DQ0HIWFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl8xMDI0LnBuZwwBDQEHBWFzc2V0ByFhc3NldHMvYnJhbmRpbmcvYXBwX2ljb25fMTAyNC5wbmcHIGFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl8yNTYucG5nDAENAQcFYXNzZXQHIGFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl8yNTYucG5nByBhc3NldHMvYnJhbmRpbmcvYXBwX2ljb25fNTEyLnBuZwwBDQEHBWFzc2V0ByBhc3NldHMvYnJhbmRpbmcvYXBwX2ljb25fNTEyLnBuZwcnYXNzZXRzL2JyYW5kaW5nL2FwcF9pY29uX2xpZ2h0XzEwMjQucG5nDAENAQcFYXNzZXQHJ2Fzc2V0cy9icmFuZGluZy9hcHBfaWNvbl9saWdodF8xMDI0LnBuZwcmYXNzZXRzL2JyYW5kaW5nL2FwcF9pY29uX2xpZ2h0XzI1Ni5wbmcMAQ0BBwVhc3NldAcmYXNzZXRzL2JyYW5kaW5nL2FwcF9pY29uX2xpZ2h0XzI1Ni5wbmcHJmFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl9saWdodF81MTIucG5nDAENAQcFYXNzZXQHJmFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl9saWdodF81MTIucG5nByRhc3NldHMvYnJhbmRpbmcvb25ib2FyZGluZ19pbnRyby5tcDQMAQ0BBwVhc3NldAckYXNzZXRzL2JyYW5kaW5nL29uYm9hcmRpbmdfaW50cm8ubXA0Byxhc3NldHMvYnJhbmRpbmcvdHJheV9pY29uX2xpZ2h0X3RlbXBsYXRlLnBuZwwBDQEHBWFzc2V0Byxhc3NldHMvYnJhbmRpbmcvdHJheV9pY29uX2xpZ2h0X3RlbXBsYXRlLnBuZwcmYXNzZXRzL2JyYW5kaW5nL3RyYXlfaWNvbl90ZW1wbGF0ZS5wbmcMAQ0BBwVhc3NldAcmYXNzZXRzL2JyYW5kaW5nL3RyYXlfaWNvbl90ZW1wbGF0ZS5wbmcHMnBhY2thZ2VzL2N1cGVydGlub19pY29ucy9hc3NldHMvQ3VwZXJ0aW5vSWNvbnMudHRmDAENAQcFYXNzZXQHMnBhY2thZ2VzL2N1cGVydGlub19pY29ucy9hc3NldHMvQ3VwZXJ0aW5vSWNvbnMudHRmBzdwYWNrYWdlcy9yZWNvcmRfd2ViL2Fzc2V0cy9qcy9yZWNvcmQuZml4d2VibWR1cmF0aW9uLmpzDAENAQcFYXNzZXQHN3BhY2thZ2VzL3JlY29yZF93ZWIvYXNzZXRzL2pzL3JlY29yZC5maXh3ZWJtZHVyYXRpb24uanMHL3BhY2thZ2VzL3JlY29yZF93ZWIvYXNzZXRzL2pzL3JlY29yZC53b3JrbGV0LmpzDAENAQcFYXNzZXQHL3BhY2thZ2VzL3JlY29yZF93ZWIvYXNzZXRzL2pzL3JlY29yZC53b3JrbGV0LmpzBxZ3ZWIvaWNvbnMvSWNvbi0xOTIucG5nDAENAQcFYXNzZXQHFndlYi9pY29ucy9JY29uLTE5Mi5wbmc="