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.
- package/.env.example +0 -9
- package/docs/configuration.md +0 -10
- package/flutter_app/lib/main.dart +0 -1
- package/flutter_app/lib/main_controller.dart +0 -160
- package/flutter_app/lib/main_operations.dart +17 -1
- package/flutter_app/lib/main_runtime.dart +0 -22
- package/flutter_app/lib/main_shared.dart +0 -161
- package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +0 -2
- package/flutter_app/pubspec.lock +0 -8
- package/flutter_app/pubspec.yaml +0 -1
- package/package.json +1 -1
- package/server/admin/admin.css +23 -0
- package/server/admin/admin.js +17 -19
- package/server/admin/analytics.js +254 -48
- package/server/admin/index.html +9 -2
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/AssetManifest.bin +1 -1
- package/server/public/assets/AssetManifest.bin.json +1 -1
- package/server/public/assets/NOTICES +0 -183
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +77809 -78575
- package/server/routes/admin.js +73 -24
- package/server/routes/runtime.js +1 -4
- package/server/services/ai/models.js +4 -4
- package/flutter_app/lib/src/analytics_service.dart +0 -294
- package/server/config/analytics.js +0 -30
package/server/admin/admin.css
CHANGED
|
@@ -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
|
}
|
package/server/admin/admin.js
CHANGED
|
@@ -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
|
|
358
|
+
const disabledSet = new Set(data.disabledModels || []);
|
|
359
359
|
|
|
360
|
-
if (!models.length) { el.innerHTML = '<div class="empty">No models found
|
|
360
|
+
if (!models.length) { el.innerHTML = '<div class="empty">No models found — configure providers first.</div>'; return; }
|
|
361
361
|
|
|
362
|
-
//
|
|
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 =
|
|
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)">
|
|
375
|
-
<button class="btn btn-ghost" style="padding:5px 12px;font-size:12px;" onclick="toggleAllModels(false)">
|
|
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
|
|
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:${
|
|
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)}" ${
|
|
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"
|
|
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
|
-
//
|
|
447
|
-
const
|
|
448
|
-
|
|
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/
|
|
453
|
+
const res = await api('/admin/api/models/config', {
|
|
456
454
|
method: 'PUT',
|
|
457
455
|
headers: { 'Content-Type': 'application/json' },
|
|
458
|
-
body: JSON.stringify({
|
|
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(
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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="
|
|
39
|
-
<div class="
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
47
|
-
<
|
|
48
|
-
<
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
|
267
|
+
const maxTokens = Math.max(...users.map((u) => u.tokens), 1);
|
|
64
268
|
return `
|
|
65
|
-
<div class="card" style="margin-top:
|
|
66
|
-
<div class="card-title">Top Users by
|
|
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
|
|
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
|
|
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);
|
package/server/admin/index.html
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
1
|
+
15d41acc002ea2eaaa9deb2490573dd5
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
|
|
@@ -1 +1 @@
|
|
|
1
|
-
"
|
|
1
|
+
"DQ0HIWFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl8xMDI0LnBuZwwBDQEHBWFzc2V0ByFhc3NldHMvYnJhbmRpbmcvYXBwX2ljb25fMTAyNC5wbmcHIGFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl8yNTYucG5nDAENAQcFYXNzZXQHIGFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl8yNTYucG5nByBhc3NldHMvYnJhbmRpbmcvYXBwX2ljb25fNTEyLnBuZwwBDQEHBWFzc2V0ByBhc3NldHMvYnJhbmRpbmcvYXBwX2ljb25fNTEyLnBuZwcnYXNzZXRzL2JyYW5kaW5nL2FwcF9pY29uX2xpZ2h0XzEwMjQucG5nDAENAQcFYXNzZXQHJ2Fzc2V0cy9icmFuZGluZy9hcHBfaWNvbl9saWdodF8xMDI0LnBuZwcmYXNzZXRzL2JyYW5kaW5nL2FwcF9pY29uX2xpZ2h0XzI1Ni5wbmcMAQ0BBwVhc3NldAcmYXNzZXRzL2JyYW5kaW5nL2FwcF9pY29uX2xpZ2h0XzI1Ni5wbmcHJmFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl9saWdodF81MTIucG5nDAENAQcFYXNzZXQHJmFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl9saWdodF81MTIucG5nByRhc3NldHMvYnJhbmRpbmcvb25ib2FyZGluZ19pbnRyby5tcDQMAQ0BBwVhc3NldAckYXNzZXRzL2JyYW5kaW5nL29uYm9hcmRpbmdfaW50cm8ubXA0Byxhc3NldHMvYnJhbmRpbmcvdHJheV9pY29uX2xpZ2h0X3RlbXBsYXRlLnBuZwwBDQEHBWFzc2V0Byxhc3NldHMvYnJhbmRpbmcvdHJheV9pY29uX2xpZ2h0X3RlbXBsYXRlLnBuZwcmYXNzZXRzL2JyYW5kaW5nL3RyYXlfaWNvbl90ZW1wbGF0ZS5wbmcMAQ0BBwVhc3NldAcmYXNzZXRzL2JyYW5kaW5nL3RyYXlfaWNvbl90ZW1wbGF0ZS5wbmcHMnBhY2thZ2VzL2N1cGVydGlub19pY29ucy9hc3NldHMvQ3VwZXJ0aW5vSWNvbnMudHRmDAENAQcFYXNzZXQHMnBhY2thZ2VzL2N1cGVydGlub19pY29ucy9hc3NldHMvQ3VwZXJ0aW5vSWNvbnMudHRmBzdwYWNrYWdlcy9yZWNvcmRfd2ViL2Fzc2V0cy9qcy9yZWNvcmQuZml4d2VibWR1cmF0aW9uLmpzDAENAQcFYXNzZXQHN3BhY2thZ2VzL3JlY29yZF93ZWIvYXNzZXRzL2pzL3JlY29yZC5maXh3ZWJtZHVyYXRpb24uanMHL3BhY2thZ2VzL3JlY29yZF93ZWIvYXNzZXRzL2pzL3JlY29yZC53b3JrbGV0LmpzDAENAQcFYXNzZXQHL3BhY2thZ2VzL3JlY29yZF93ZWIvYXNzZXRzL2pzL3JlY29yZC53b3JrbGV0LmpzBxZ3ZWIvaWNvbnMvSWNvbi0xOTIucG5nDAENAQcFYXNzZXQHFndlYi9pY29ucy9JY29uLTE5Mi5wbmc="
|