neoagent 2.4.2-beta.3 → 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.
Files changed (33) hide show
  1. package/.env.example +0 -9
  2. package/docs/configuration.md +0 -10
  3. package/flutter_app/lib/main.dart +0 -1
  4. package/flutter_app/lib/main_account_settings.dart +57 -16
  5. package/flutter_app/lib/main_controller.dart +0 -160
  6. package/flutter_app/lib/main_models.dart +6 -0
  7. package/flutter_app/lib/main_operations.dart +17 -1
  8. package/flutter_app/lib/main_runtime.dart +0 -22
  9. package/flutter_app/lib/main_shared.dart +0 -161
  10. package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +0 -2
  11. package/flutter_app/pubspec.lock +0 -8
  12. package/flutter_app/pubspec.yaml +0 -1
  13. package/landing/index.html +1 -1
  14. package/package.json +1 -1
  15. package/server/admin/admin.css +23 -0
  16. package/server/admin/admin.js +89 -32
  17. package/server/admin/analytics.js +254 -48
  18. package/server/admin/index.html +36 -2
  19. package/server/admin/users.js +94 -37
  20. package/server/public/.last_build_id +1 -1
  21. package/server/public/assets/AssetManifest.bin +1 -1
  22. package/server/public/assets/AssetManifest.bin.json +1 -1
  23. package/server/public/assets/NOTICES +0 -183
  24. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  25. package/server/public/flutter_bootstrap.js +1 -1
  26. package/server/public/main.dart.js +77607 -78350
  27. package/server/routes/account.js +11 -2
  28. package/server/routes/admin.js +106 -24
  29. package/server/routes/runtime.js +1 -4
  30. package/server/services/ai/engine.js +13 -11
  31. package/server/services/ai/models.js +10 -4
  32. package/flutter_app/lib/src/analytics_service.dart +0 -294
  33. package/server/config/analytics.js +0 -30
@@ -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>
@@ -934,6 +941,33 @@
934
941
  <span class="toolbar-count" id="users-count" aria-live="polite" style="padding-top:6px;"></span>
935
942
  </div>
936
943
  <div class="content">
944
+
945
+ <!-- Default rate limits card -->
946
+ <div class="card" style="margin-bottom:20px;">
947
+ <div style="display:flex;align-items:flex-start;justify-content:space-between;gap:16px;flex-wrap:wrap;">
948
+ <div>
949
+ <div style="font-size:15px;font-weight:700;color:var(--text);margin-bottom:4px;">Default Rate Limits</div>
950
+ <div style="font-size:12px;color:var(--text-muted);max-width:480px;">
951
+ Applied to every user unless they have a custom override. Leave empty for no limit.
952
+ </div>
953
+ </div>
954
+ <button class="btn btn-primary" style="flex-shrink:0;" onclick="saveDefaultRateLimits(this)">Save Defaults</button>
955
+ </div>
956
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:18px;" id="default-limits-fields">
957
+ <div>
958
+ <label style="display:block;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:var(--text-muted);margin-bottom:6px;">4-Hour Limit (tokens)</label>
959
+ <input type="number" min="0" step="1" id="default-limit-4h" placeholder="e.g. 500000 — empty = no limit"
960
+ style="width:100%;padding:9px 12px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;">
961
+ </div>
962
+ <div>
963
+ <label style="display:block;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:var(--text-muted);margin-bottom:6px;">Weekly Limit (tokens)</label>
964
+ <input type="number" min="0" step="1" id="default-limit-weekly" placeholder="e.g. 2000000 — empty = no limit"
965
+ style="width:100%;padding:9px 12px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;">
966
+ </div>
967
+ </div>
968
+ </div>
969
+
970
+ <!-- Users table card -->
937
971
  <div class="card">
938
972
  <div class="toolbar" style="margin-bottom:14px;">
939
973
  <input type="search" id="user-search" placeholder="Search by username or email…"
@@ -5,8 +5,37 @@
5
5
  let _userSearchTimer = null;
6
6
 
7
7
  async function loadUsers() {
8
- const q = document.getElementById('user-search')?.value?.trim() || '';
9
- await fetchUsers(q);
8
+ await Promise.all([loadDefaultRateLimits(), fetchUsers(document.getElementById('user-search')?.value?.trim() || '')]);
9
+ }
10
+
11
+ async function loadDefaultRateLimits() {
12
+ try {
13
+ const data = await api('/admin/api/config/rate-limits').then(r => r.json());
14
+ const f4h = document.getElementById('default-limit-4h');
15
+ const fw = document.getElementById('default-limit-weekly');
16
+ if (f4h) f4h.value = data.rate_limit_4h ?? '';
17
+ if (fw) fw.value = data.rate_limit_weekly ?? '';
18
+ } catch (_) {}
19
+ }
20
+
21
+ async function saveDefaultRateLimits(btn) {
22
+ const v4h = document.getElementById('default-limit-4h')?.value;
23
+ const vw = document.getElementById('default-limit-weekly')?.value;
24
+ const parse = (v) => (v !== '' && v != null) ? parseInt(v, 10) : null;
25
+ btn.disabled = true;
26
+ const orig = btn.textContent;
27
+ btn.textContent = 'Saving…';
28
+ try {
29
+ const res = await api('/admin/api/config/rate-limits', {
30
+ method: 'PUT',
31
+ headers: { 'Content-Type': 'application/json' },
32
+ body: JSON.stringify({ rate_limit_4h: parse(v4h), rate_limit_weekly: parse(vw) }),
33
+ });
34
+ if (!res.ok) { const b = await res.json().catch(() => ({})); alert(b.error || 'Failed'); }
35
+ else { btn.textContent = 'Saved!'; setTimeout(() => { btn.textContent = orig; btn.disabled = false; }, 2000); return; }
36
+ } catch (_) { alert('Network error'); }
37
+ btn.disabled = false;
38
+ btn.textContent = orig;
10
39
  }
11
40
 
12
41
  async function fetchUsers(q = '') {
@@ -46,6 +75,7 @@ function renderUsersTable(el, users) {
46
75
  <th>Last Login</th>
47
76
  <th>Runs</th>
48
77
  <th>Storage</th>
78
+ <th>Rate Limits</th>
49
79
  <th style="text-align:right;">Actions</th>
50
80
  </tr></thead>
51
81
  <tbody>${users.map((u) => `
@@ -69,6 +99,14 @@ function renderUsersTable(el, users) {
69
99
  <td style="font-size:12px;color:var(--text-muted);">${u.last_login ? fmtDate(u.last_login) : '—'}</td>
70
100
  <td style="font-family:var(--font-mono);font-size:12px;">${fmtNum(u.run_count)}</td>
71
101
  <td style="font-family:var(--font-mono);font-size:12px;">${fmtBytes(u.storage_bytes)}</td>
102
+ <td style="font-size:11px;">
103
+ ${u.rate_limit_4h || u.rate_limit_weekly
104
+ ? `<div style="display:flex;flex-direction:column;gap:3px;">
105
+ ${u.rate_limit_4h ? `<span class="badge badge-idle" style="font-size:10px;">4h: ${fmtTokens(u.rate_limit_4h)}</span>` : ''}
106
+ ${u.rate_limit_weekly ? `<span class="badge badge-idle" style="font-size:10px;">7d: ${fmtTokens(u.rate_limit_weekly)}</span>` : ''}
107
+ </div>`
108
+ : `<span style="color:var(--text-muted);">none</span>`}
109
+ </td>
72
110
  <td>
73
111
  <div style="display:flex;gap:6px;justify-content:flex-end;">
74
112
  <button class="btn btn-ghost" style="padding:5px 10px;font-size:11px;"
@@ -143,6 +181,13 @@ async function deleteUser(id, displayName, btn) {
143
181
  }
144
182
  }
145
183
 
184
+ function fmtTokens(n) {
185
+ if (!n) return '—';
186
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
187
+ if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
188
+ return String(n);
189
+ }
190
+
146
191
  function fmtDate(iso) {
147
192
  if (!iso) return '—';
148
193
  try {
@@ -152,69 +197,81 @@ function fmtDate(iso) {
152
197
 
153
198
  async function editRateLimits(id, username) {
154
199
  try {
155
- const res = await api(`/admin/api/users/${id}/rate-limits`).then(r => r.json());
156
- const limits = res.limits || {};
157
- const limit4h = limits.rate_limit_4h || '';
158
- const limitWeekly = limits.rate_limit_weekly || '';
159
-
200
+ const [userRes, globalRes] = await Promise.all([
201
+ api(`/admin/api/users/${id}/rate-limits`).then(r => r.json()),
202
+ api('/admin/api/config/rate-limits').then(r => r.json()).catch(() => ({})),
203
+ ]);
204
+ const limits = userRes.limits || {};
205
+ const custom4h = limits.rate_limit_4h ?? null;
206
+ const customWeekly = limits.rate_limit_weekly ?? null;
207
+ const global4h = globalRes.rate_limit_4h ?? null;
208
+ const globalWeekly = globalRes.rate_limit_weekly ?? null;
209
+
210
+ const hint4h = global4h ? `Default: ${fmtTokens(global4h)} — empty inherits default` : 'Empty = no limit';
211
+ const hintWeekly = globalWeekly ? `Default: ${fmtTokens(globalWeekly)} — empty inherits default` : 'Empty = no limit';
212
+
160
213
  const overlay = document.createElement('div');
161
214
  overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:9999;backdrop-filter:blur(2px);';
162
215
  const modal = document.createElement('div');
163
216
  modal.className = 'card';
164
- modal.style.cssText = 'width:420px;background:var(--bg-primary);box-shadow:0 10px 40px rgba(0,0,0,0.5);border:1px solid var(--border);border-radius:12px;padding:24px;';
217
+ modal.style.cssText = 'width:440px;background:var(--bg-primary);box-shadow:0 10px 40px rgba(0,0,0,0.5);border:1px solid var(--border);border-radius:12px;padding:24px;';
165
218
  modal.innerHTML = `
166
- <div style="font-size:16px;font-weight:700;color:var(--text);margin-bottom:6px;">Rate Limits</div>
167
- <div style="font-size:13px;color:var(--text-muted);margin-bottom:20px;">Configure token limits for <span style="color:var(--text);font-weight:600;">@${esc(username)}</span>.</div>
168
-
169
- <div>
170
- <label style="display:block;font-size:12px;font-weight:600;margin-bottom:8px;color:var(--text);text-transform:uppercase;letter-spacing:0.05em;">4-Hour Limit (Tokens)</label>
171
- <input type="number" min="0" step="1" id="limit-4h" value="${limit4h}" placeholder="e.g. 500000" style="width:100%;padding:10px 12px;margin-bottom:6px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);">
172
- <div style="font-size:11px;color:var(--text-muted);margin-bottom:20px;">Leave empty for no limit</div>
173
-
174
- <label style="display:block;font-size:12px;font-weight:600;margin-bottom:8px;color:var(--text);text-transform:uppercase;letter-spacing:0.05em;">Weekly Limit (Tokens)</label>
175
- <input type="number" min="0" step="1" id="limit-weekly" value="${limitWeekly}" placeholder="e.g. 2000000" style="width:100%;padding:10px 12px;margin-bottom:6px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);">
176
- <div style="font-size:11px;color:var(--text-muted);margin-bottom:24px;">Leave empty for no limit</div>
219
+ <div style="font-size:16px;font-weight:700;color:var(--text);margin-bottom:4px;">Rate Limits</div>
220
+ <div style="font-size:13px;color:var(--text-muted);margin-bottom:20px;">
221
+ Custom overrides for <span style="color:var(--text);font-weight:600;">@${esc(username)}</span>.
222
+ Leave empty to inherit the global default.
177
223
  </div>
178
-
224
+
225
+ <div style="margin-bottom:18px;">
226
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">
227
+ <label style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:var(--text-muted);">4-Hour Limit (tokens)</label>
228
+ ${custom4h !== null ? '<span class="badge badge-warn" style="font-size:10px;">custom</span>' : (global4h ? '<span class="badge badge-idle" style="font-size:10px;">inheriting default</span>' : '<span class="badge badge-idle" style="font-size:10px;">no limit</span>')}
229
+ </div>
230
+ <input type="number" min="0" step="1" id="limit-4h" value="${custom4h ?? ''}" placeholder="${hint4h}"
231
+ style="width:100%;padding:10px 12px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;">
232
+ </div>
233
+
234
+ <div style="margin-bottom:24px;">
235
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">
236
+ <label style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:var(--text-muted);">Weekly Limit (tokens)</label>
237
+ ${customWeekly !== null ? '<span class="badge badge-warn" style="font-size:10px;">custom</span>' : (globalWeekly ? '<span class="badge badge-idle" style="font-size:10px;">inheriting default</span>' : '<span class="badge badge-idle" style="font-size:10px;">no limit</span>')}
238
+ </div>
239
+ <input type="number" min="0" step="1" id="limit-weekly" value="${customWeekly ?? ''}" placeholder="${hintWeekly}"
240
+ style="width:100%;padding:10px 12px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;">
241
+ </div>
242
+
179
243
  <div style="display:flex;gap:12px;justify-content:flex-end;">
180
244
  <button class="btn btn-ghost" id="btn-cancel" style="padding:8px 16px;">Cancel</button>
181
- <button class="btn btn-primary" id="btn-save" style="padding:8px 16px;">Save Limits</button>
245
+ <button class="btn btn-primary" id="btn-save" style="padding:8px 16px;">Save</button>
182
246
  </div>
183
247
  `;
184
248
  overlay.appendChild(modal);
185
249
  document.body.appendChild(overlay);
186
-
250
+
187
251
  document.getElementById('btn-cancel').onclick = () => overlay.remove();
188
252
  document.getElementById('btn-save').onclick = async () => {
189
253
  const v4 = document.getElementById('limit-4h').value;
190
254
  const vw = document.getElementById('limit-weekly').value;
191
- const parseLimit = (v) => {
192
- if (!v) return null;
193
- const n = Number(v);
194
- return Number.isInteger(n) && n >= 0 ? n : null;
195
- };
255
+ const parseLimit = (v) => { if (!v) return null; const n = Number(v); return Number.isInteger(n) && n >= 0 ? n : null; };
196
256
  const bSave = document.getElementById('btn-save');
197
257
  bSave.disabled = true;
198
- bSave.textContent = 'Saving...';
258
+ bSave.textContent = 'Saving';
199
259
  try {
200
- const payload = {
201
- rate_limit_4h: parseLimit(v4),
202
- rate_limit_weekly: parseLimit(vw)
203
- };
204
260
  const putRes = await api(`/admin/api/users/${id}/rate-limits`, {
205
261
  method: 'PUT',
206
- headers: {'Content-Type': 'application/json'},
207
- body: JSON.stringify(payload)
262
+ headers: { 'Content-Type': 'application/json' },
263
+ body: JSON.stringify({ rate_limit_4h: parseLimit(v4), rate_limit_weekly: parseLimit(vw) }),
208
264
  });
209
265
  if (!putRes.ok) throw new Error('Save failed');
210
266
  overlay.remove();
211
- } catch (err) {
267
+ await fetchUsers(document.getElementById('user-search')?.value?.trim() || '');
268
+ } catch (_) {
212
269
  alert('Failed to save rate limits');
213
270
  bSave.disabled = false;
214
- bSave.textContent = 'Save Limits';
271
+ bSave.textContent = 'Save';
215
272
  }
216
273
  };
217
- } catch (err) {
274
+ } catch (_) {
218
275
  alert('Failed to fetch rate limits');
219
276
  }
220
277
  }
@@ -1 +1 @@
1
- 376fa02eacafdcb41cb88d165edcb99a
1
+ 15d41acc002ea2eaaa9deb2490573dd5
@@ -1 +1 @@
1
-
1
+
@@ -1 +1 @@
1
- "DQ4HIWFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl8xMDI0LnBuZwwBDQEHBWFzc2V0ByFhc3NldHMvYnJhbmRpbmcvYXBwX2ljb25fMTAyNC5wbmcHIGFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl8yNTYucG5nDAENAQcFYXNzZXQHIGFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl8yNTYucG5nByBhc3NldHMvYnJhbmRpbmcvYXBwX2ljb25fNTEyLnBuZwwBDQEHBWFzc2V0ByBhc3NldHMvYnJhbmRpbmcvYXBwX2ljb25fNTEyLnBuZwcnYXNzZXRzL2JyYW5kaW5nL2FwcF9pY29uX2xpZ2h0XzEwMjQucG5nDAENAQcFYXNzZXQHJ2Fzc2V0cy9icmFuZGluZy9hcHBfaWNvbl9saWdodF8xMDI0LnBuZwcmYXNzZXRzL2JyYW5kaW5nL2FwcF9pY29uX2xpZ2h0XzI1Ni5wbmcMAQ0BBwVhc3NldAcmYXNzZXRzL2JyYW5kaW5nL2FwcF9pY29uX2xpZ2h0XzI1Ni5wbmcHJmFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl9saWdodF81MTIucG5nDAENAQcFYXNzZXQHJmFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl9saWdodF81MTIucG5nByRhc3NldHMvYnJhbmRpbmcvb25ib2FyZGluZ19pbnRyby5tcDQMAQ0BBwVhc3NldAckYXNzZXRzL2JyYW5kaW5nL29uYm9hcmRpbmdfaW50cm8ubXA0Byxhc3NldHMvYnJhbmRpbmcvdHJheV9pY29uX2xpZ2h0X3RlbXBsYXRlLnBuZwwBDQEHBWFzc2V0Byxhc3NldHMvYnJhbmRpbmcvdHJheV9pY29uX2xpZ2h0X3RlbXBsYXRlLnBuZwcmYXNzZXRzL2JyYW5kaW5nL3RyYXlfaWNvbl90ZW1wbGF0ZS5wbmcMAQ0BBwVhc3NldAcmYXNzZXRzL2JyYW5kaW5nL3RyYXlfaWNvbl90ZW1wbGF0ZS5wbmcHMnBhY2thZ2VzL2N1cGVydGlub19pY29ucy9hc3NldHMvQ3VwZXJ0aW5vSWNvbnMudHRmDAENAQcFYXNzZXQHMnBhY2thZ2VzL2N1cGVydGlub19pY29ucy9hc3NldHMvQ3VwZXJ0aW5vSWNvbnMudHRmByxwYWNrYWdlcy9taXhwYW5lbF9mbHV0dGVyL2Fzc2V0cy9taXhwYW5lbC5qcwwBDQEHBWFzc2V0ByxwYWNrYWdlcy9taXhwYW5lbF9mbHV0dGVyL2Fzc2V0cy9taXhwYW5lbC5qcwc3cGFja2FnZXMvcmVjb3JkX3dlYi9hc3NldHMvanMvcmVjb3JkLmZpeHdlYm1kdXJhdGlvbi5qcwwBDQEHBWFzc2V0BzdwYWNrYWdlcy9yZWNvcmRfd2ViL2Fzc2V0cy9qcy9yZWNvcmQuZml4d2VibWR1cmF0aW9uLmpzBy9wYWNrYWdlcy9yZWNvcmRfd2ViL2Fzc2V0cy9qcy9yZWNvcmQud29ya2xldC5qcwwBDQEHBWFzc2V0By9wYWNrYWdlcy9yZWNvcmRfd2ViL2Fzc2V0cy9qcy9yZWNvcmQud29ya2xldC5qcwcWd2ViL2ljb25zL0ljb24tMTkyLnBuZwwBDQEHBWFzc2V0BxZ3ZWIvaWNvbnMvSWNvbi0xOTIucG5n"
1
+ "DQ0HIWFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl8xMDI0LnBuZwwBDQEHBWFzc2V0ByFhc3NldHMvYnJhbmRpbmcvYXBwX2ljb25fMTAyNC5wbmcHIGFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl8yNTYucG5nDAENAQcFYXNzZXQHIGFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl8yNTYucG5nByBhc3NldHMvYnJhbmRpbmcvYXBwX2ljb25fNTEyLnBuZwwBDQEHBWFzc2V0ByBhc3NldHMvYnJhbmRpbmcvYXBwX2ljb25fNTEyLnBuZwcnYXNzZXRzL2JyYW5kaW5nL2FwcF9pY29uX2xpZ2h0XzEwMjQucG5nDAENAQcFYXNzZXQHJ2Fzc2V0cy9icmFuZGluZy9hcHBfaWNvbl9saWdodF8xMDI0LnBuZwcmYXNzZXRzL2JyYW5kaW5nL2FwcF9pY29uX2xpZ2h0XzI1Ni5wbmcMAQ0BBwVhc3NldAcmYXNzZXRzL2JyYW5kaW5nL2FwcF9pY29uX2xpZ2h0XzI1Ni5wbmcHJmFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl9saWdodF81MTIucG5nDAENAQcFYXNzZXQHJmFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl9saWdodF81MTIucG5nByRhc3NldHMvYnJhbmRpbmcvb25ib2FyZGluZ19pbnRyby5tcDQMAQ0BBwVhc3NldAckYXNzZXRzL2JyYW5kaW5nL29uYm9hcmRpbmdfaW50cm8ubXA0Byxhc3NldHMvYnJhbmRpbmcvdHJheV9pY29uX2xpZ2h0X3RlbXBsYXRlLnBuZwwBDQEHBWFzc2V0Byxhc3NldHMvYnJhbmRpbmcvdHJheV9pY29uX2xpZ2h0X3RlbXBsYXRlLnBuZwcmYXNzZXRzL2JyYW5kaW5nL3RyYXlfaWNvbl90ZW1wbGF0ZS5wbmcMAQ0BBwVhc3NldAcmYXNzZXRzL2JyYW5kaW5nL3RyYXlfaWNvbl90ZW1wbGF0ZS5wbmcHMnBhY2thZ2VzL2N1cGVydGlub19pY29ucy9hc3NldHMvQ3VwZXJ0aW5vSWNvbnMudHRmDAENAQcFYXNzZXQHMnBhY2thZ2VzL2N1cGVydGlub19pY29ucy9hc3NldHMvQ3VwZXJ0aW5vSWNvbnMudHRmBzdwYWNrYWdlcy9yZWNvcmRfd2ViL2Fzc2V0cy9qcy9yZWNvcmQuZml4d2VibWR1cmF0aW9uLmpzDAENAQcFYXNzZXQHN3BhY2thZ2VzL3JlY29yZF93ZWIvYXNzZXRzL2pzL3JlY29yZC5maXh3ZWJtZHVyYXRpb24uanMHL3BhY2thZ2VzL3JlY29yZF93ZWIvYXNzZXRzL2pzL3JlY29yZC53b3JrbGV0LmpzDAENAQcFYXNzZXQHL3BhY2thZ2VzL3JlY29yZF93ZWIvYXNzZXRzL2pzL3JlY29yZC53b3JrbGV0LmpzBxZ3ZWIvaWNvbnMvSWNvbi0xOTIucG5nDAENAQcFYXNzZXQHFndlYi9pY29ucy9JY29uLTE5Mi5wbmc="