neoagent 2.4.2-beta.2 → 2.4.2-beta.4
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/flutter_app/lib/main_account_settings.dart +57 -16
- package/flutter_app/lib/main_models.dart +6 -0
- package/landing/index.html +1 -1
- package/package.json +1 -1
- package/server/admin/admin.js +80 -21
- package/server/admin/index.html +27 -0
- package/server/admin/users.js +94 -37
- package/server/public/.last_build_id +1 -1
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +4164 -4141
- package/server/routes/account.js +11 -2
- package/server/routes/admin.js +33 -0
- package/server/services/ai/engine.js +13 -11
- package/server/services/ai/models.js +7 -1
- package/server/services/ai/providers/ollama.js +4 -1
|
@@ -639,64 +639,105 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
|
|
|
639
639
|
);
|
|
640
640
|
}
|
|
641
641
|
|
|
642
|
-
Widget buildStatBox(String label, int current, int? limit) {
|
|
642
|
+
Widget buildStatBox(String label, int current, int? limit, {bool isCustom = false}) {
|
|
643
643
|
final double progress = limit != null
|
|
644
644
|
? (limit <= 0 ? 1.0 : (current / limit).clamp(0.0, 1.0))
|
|
645
645
|
: 0.0;
|
|
646
646
|
final bool nearLimit = progress > 0.8;
|
|
647
|
-
|
|
647
|
+
final bool atLimit = progress >= 1.0;
|
|
648
|
+
|
|
648
649
|
return Container(
|
|
649
650
|
width: double.infinity,
|
|
650
651
|
padding: const EdgeInsets.all(20),
|
|
651
652
|
decoration: BoxDecoration(
|
|
652
653
|
color: _bgSecondary,
|
|
653
654
|
borderRadius: BorderRadius.circular(16),
|
|
654
|
-
border: Border.all(
|
|
655
|
+
border: Border.all(
|
|
656
|
+
color: atLimit
|
|
657
|
+
? _danger.withValues(alpha: 0.6)
|
|
658
|
+
: nearLimit
|
|
659
|
+
? _warning.withValues(alpha: 0.5)
|
|
660
|
+
: _borderLight,
|
|
661
|
+
),
|
|
655
662
|
),
|
|
656
663
|
child: Column(
|
|
657
664
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
658
665
|
children: <Widget>[
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
666
|
+
Row(
|
|
667
|
+
children: <Widget>[
|
|
668
|
+
Text(label, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
|
669
|
+
const Spacer(),
|
|
670
|
+
if (limit != null)
|
|
671
|
+
Container(
|
|
672
|
+
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
|
673
|
+
decoration: BoxDecoration(
|
|
674
|
+
color: isCustom
|
|
675
|
+
? _warning.withValues(alpha: 0.12)
|
|
676
|
+
: _accent.withValues(alpha: 0.10),
|
|
677
|
+
borderRadius: BorderRadius.circular(20),
|
|
678
|
+
),
|
|
679
|
+
child: Text(
|
|
680
|
+
isCustom ? 'custom' : 'default',
|
|
681
|
+
style: TextStyle(
|
|
682
|
+
fontSize: 11,
|
|
683
|
+
fontWeight: FontWeight.w600,
|
|
684
|
+
color: isCustom ? _warning : _accent,
|
|
685
|
+
),
|
|
686
|
+
),
|
|
687
|
+
),
|
|
688
|
+
],
|
|
662
689
|
),
|
|
663
690
|
const SizedBox(height: 12),
|
|
664
691
|
Row(
|
|
692
|
+
crossAxisAlignment: CrossAxisAlignment.end,
|
|
665
693
|
children: <Widget>[
|
|
666
694
|
Text(
|
|
667
695
|
_formatTokens(current),
|
|
668
696
|
style: TextStyle(
|
|
669
697
|
fontSize: 28,
|
|
670
698
|
fontWeight: FontWeight.w800,
|
|
671
|
-
color: nearLimit ? _warning : null,
|
|
699
|
+
color: atLimit ? _danger : nearLimit ? _warning : null,
|
|
672
700
|
),
|
|
673
701
|
),
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
702
|
+
Padding(
|
|
703
|
+
padding: const EdgeInsets.only(bottom: 3),
|
|
704
|
+
child: Text(
|
|
705
|
+
' tokens used',
|
|
706
|
+
style: TextStyle(color: _textSecondary, fontSize: 14),
|
|
707
|
+
),
|
|
677
708
|
),
|
|
678
709
|
const Spacer(),
|
|
679
710
|
if (limit != null)
|
|
680
711
|
Text(
|
|
681
|
-
'
|
|
712
|
+
'of ${_formatTokens(limit)}',
|
|
682
713
|
style: TextStyle(color: _textMuted, fontSize: 13),
|
|
683
714
|
)
|
|
684
715
|
else
|
|
685
716
|
Text(
|
|
686
|
-
'No limit
|
|
717
|
+
'No limit',
|
|
687
718
|
style: TextStyle(color: _textMuted, fontSize: 13),
|
|
688
719
|
),
|
|
689
720
|
],
|
|
690
721
|
),
|
|
691
722
|
if (limit != null) ...<Widget>[
|
|
692
|
-
const SizedBox(height:
|
|
723
|
+
const SizedBox(height: 14),
|
|
693
724
|
ClipRRect(
|
|
694
725
|
borderRadius: BorderRadius.circular(999),
|
|
695
726
|
child: LinearProgressIndicator(
|
|
696
727
|
minHeight: 8,
|
|
697
728
|
value: progress,
|
|
698
729
|
backgroundColor: _border,
|
|
699
|
-
valueColor: AlwaysStoppedAnimation<Color>(
|
|
730
|
+
valueColor: AlwaysStoppedAnimation<Color>(
|
|
731
|
+
atLimit ? _danger : nearLimit ? _warning : _accent,
|
|
732
|
+
),
|
|
733
|
+
),
|
|
734
|
+
),
|
|
735
|
+
const SizedBox(height: 6),
|
|
736
|
+
Text(
|
|
737
|
+
'${(progress * 100).toStringAsFixed(0)}% used',
|
|
738
|
+
style: TextStyle(
|
|
739
|
+
fontSize: 11,
|
|
740
|
+
color: atLimit ? _danger : nearLimit ? _warning : _textMuted,
|
|
700
741
|
),
|
|
701
742
|
),
|
|
702
743
|
],
|
|
@@ -715,9 +756,9 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
|
|
|
715
756
|
style: TextStyle(color: _textSecondary, height: 1.4),
|
|
716
757
|
),
|
|
717
758
|
const SizedBox(height: 24),
|
|
718
|
-
buildStatBox('Recent Usage (4 Hours)', usage.fourHourUsage, usage.fourHourLimit),
|
|
759
|
+
buildStatBox('Recent Usage (4 Hours)', usage.fourHourUsage, usage.fourHourLimit, isCustom: usage.fourHourIsCustom),
|
|
719
760
|
const SizedBox(height: 16),
|
|
720
|
-
buildStatBox('Weekly Usage', usage.weeklyUsage, usage.weeklyLimit),
|
|
761
|
+
buildStatBox('Weekly Usage', usage.weeklyUsage, usage.weeklyLimit, isCustom: usage.weeklyIsCustom),
|
|
721
762
|
],
|
|
722
763
|
);
|
|
723
764
|
}
|
|
@@ -4083,6 +4083,8 @@ class AccountUsageAndLimits {
|
|
|
4083
4083
|
this.weeklyLimit,
|
|
4084
4084
|
this.fourHourUsage = 0,
|
|
4085
4085
|
this.weeklyUsage = 0,
|
|
4086
|
+
this.fourHourIsCustom = false,
|
|
4087
|
+
this.weeklyIsCustom = false,
|
|
4086
4088
|
});
|
|
4087
4089
|
|
|
4088
4090
|
factory AccountUsageAndLimits.fromJson(Map<String, dynamic> json) {
|
|
@@ -4093,6 +4095,8 @@ class AccountUsageAndLimits {
|
|
|
4093
4095
|
weeklyLimit: int.tryParse(limits['weekly']?.toString() ?? ''),
|
|
4094
4096
|
fourHourUsage: _asInt(usage['fourHour']),
|
|
4095
4097
|
weeklyUsage: _asInt(usage['weekly']),
|
|
4098
|
+
fourHourIsCustom: limits['fourHourIsCustom'] == true,
|
|
4099
|
+
weeklyIsCustom: limits['weeklyIsCustom'] == true,
|
|
4096
4100
|
);
|
|
4097
4101
|
}
|
|
4098
4102
|
|
|
@@ -4100,4 +4104,6 @@ class AccountUsageAndLimits {
|
|
|
4100
4104
|
final int? weeklyLimit;
|
|
4101
4105
|
final int fourHourUsage;
|
|
4102
4106
|
final int weeklyUsage;
|
|
4107
|
+
final bool fourHourIsCustom;
|
|
4108
|
+
final bool weeklyIsCustom;
|
|
4103
4109
|
}
|
package/landing/index.html
CHANGED
|
@@ -1015,7 +1015,7 @@ p { margin: 0; text-wrap: pretty; }
|
|
|
1015
1015
|
<div class="frame">
|
|
1016
1016
|
<div class="frame-bar">
|
|
1017
1017
|
<span class="dot" style="background:#e26e61"></span><span class="dot" style="background:#e0b24a"></span><span class="dot" style="background:#4bbf85"></span>
|
|
1018
|
-
<span class="url">
|
|
1018
|
+
<span class="url">control surface</span>
|
|
1019
1019
|
</div>
|
|
1020
1020
|
<div class="frame-body shot-pair">
|
|
1021
1021
|
<img class="lm" src="images/dashboard-light.png" alt="NeoAgent dashboard" loading="eager">
|
package/package.json
CHANGED
package/server/admin/admin.js
CHANGED
|
@@ -328,6 +328,27 @@ async function clearProvider(key, btn) {
|
|
|
328
328
|
|
|
329
329
|
// ── Models ─────────────────────────────────────────────────────────────────
|
|
330
330
|
|
|
331
|
+
function fmtModelPrice(inputCostPerM) {
|
|
332
|
+
if (inputCostPerM === null || inputCostPerM === undefined) return '—';
|
|
333
|
+
if (inputCostPerM === 0) return 'Free';
|
|
334
|
+
if (inputCostPerM < 0.01) return `$${inputCostPerM.toFixed(4)}/M`;
|
|
335
|
+
if (inputCostPerM < 1) return `$${inputCostPerM.toFixed(3)}/M`;
|
|
336
|
+
return `$${inputCostPerM.toFixed(2)}/M`;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function priceTierClass(tier) {
|
|
340
|
+
if (tier === 'free') return 'badge-ok';
|
|
341
|
+
if (tier === 'cheap') return 'badge-ok';
|
|
342
|
+
if (tier === 'medium') return 'badge-warn';
|
|
343
|
+
if (tier === 'expensive') return 'badge-err';
|
|
344
|
+
return 'badge-idle';
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function purposeIcon(purpose) {
|
|
348
|
+
const icons = { fast: '⚡', planning: '🧠', coding: '💻', general: '✦' };
|
|
349
|
+
return icons[purpose] || '✦';
|
|
350
|
+
}
|
|
351
|
+
|
|
331
352
|
async function loadModels() {
|
|
332
353
|
const el = document.getElementById('models-content');
|
|
333
354
|
if (!el) return;
|
|
@@ -335,43 +356,61 @@ async function loadModels() {
|
|
|
335
356
|
const data = await api('/admin/api/models').then((r) => r.json());
|
|
336
357
|
const models = data.models || [];
|
|
337
358
|
const enabledModels = data.enabledModels || [];
|
|
338
|
-
|
|
359
|
+
|
|
339
360
|
if (!models.length) { el.innerHTML = '<div class="empty">No models found</div>'; return; }
|
|
340
|
-
|
|
341
|
-
//
|
|
361
|
+
|
|
362
|
+
// Group by provider for better readability
|
|
342
363
|
models.sort((a, b) => {
|
|
343
364
|
if (a.provider !== b.provider) return a.provider.localeCompare(b.provider);
|
|
344
|
-
|
|
365
|
+
const ac = a.inputCostPerM ?? Infinity;
|
|
366
|
+
const bc = b.inputCostPerM ?? Infinity;
|
|
367
|
+
return ac - bc;
|
|
345
368
|
});
|
|
346
|
-
|
|
347
|
-
|
|
369
|
+
|
|
370
|
+
const enabledCount = enabledModels.length === 0 ? models.length : enabledModels.length;
|
|
371
|
+
|
|
372
|
+
let html = `
|
|
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>
|
|
376
|
+
<span style="margin-left:4px;font-size:12px;color:var(--text-muted);" id="model-count-label">${enabledCount} of ${models.length} enabled</span>
|
|
377
|
+
</div>
|
|
378
|
+
<table class="users-table">
|
|
348
379
|
<thead><tr>
|
|
349
|
-
<th style="width:40px;">
|
|
350
|
-
<th>Model
|
|
380
|
+
<th style="width:40px;">On</th>
|
|
381
|
+
<th>Model</th>
|
|
351
382
|
<th>Provider</th>
|
|
352
|
-
<th>Purpose</th>
|
|
353
|
-
<th>
|
|
383
|
+
<th style="width:90px;">Purpose</th>
|
|
384
|
+
<th style="width:80px;">Tier</th>
|
|
385
|
+
<th style="width:110px;text-align:right;">Input / 1M tokens</th>
|
|
354
386
|
</tr></thead>
|
|
355
387
|
<tbody>`;
|
|
356
|
-
|
|
388
|
+
|
|
357
389
|
for (const m of models) {
|
|
358
|
-
// Checked if it's in the enabledModels list, OR if the list is empty (all enabled)
|
|
359
390
|
const isChecked = enabledModels.length === 0 || enabledModels.includes(m.id);
|
|
360
|
-
const rowOpacity = isChecked ? '1' : '0.
|
|
391
|
+
const rowOpacity = isChecked ? '1' : '0.45';
|
|
392
|
+
const priceStr = fmtModelPrice(m.inputCostPerM);
|
|
393
|
+
const tierCls = priceTierClass(m.priceTier);
|
|
394
|
+
const icon = purposeIcon(m.purpose);
|
|
361
395
|
html += `
|
|
362
|
-
<tr style="opacity
|
|
396
|
+
<tr style="opacity:${rowOpacity}" id="model-row-${esc(m.id)}">
|
|
363
397
|
<td style="text-align:center;">
|
|
364
|
-
<input type="checkbox" class="model-cb" value="${esc(m.id)}" ${isChecked ? 'checked' : ''}
|
|
398
|
+
<input type="checkbox" class="model-cb" value="${esc(m.id)}" ${isChecked ? 'checked' : ''}
|
|
399
|
+
onchange="onModelToggle(this)">
|
|
365
400
|
</td>
|
|
366
|
-
<td
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
401
|
+
<td>
|
|
402
|
+
<div style="font-weight:600;color:var(--text);font-size:13px;">${esc(m.label)}</div>
|
|
403
|
+
<div style="font-size:11px;color:var(--text-muted);font-family:var(--font-mono);margin-top:2px;">${esc(m.id)}</div>
|
|
404
|
+
</td>
|
|
405
|
+
<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>
|
|
407
|
+
<td><span class="badge ${tierCls}">${esc(m.priceTier ?? '?')}</span></td>
|
|
408
|
+
<td style="text-align:right;font-family:var(--font-mono);font-size:13px;font-weight:600;color:var(--text);">${priceStr}</td>
|
|
370
409
|
</tr>
|
|
371
410
|
`;
|
|
372
411
|
}
|
|
373
|
-
|
|
374
|
-
html +=
|
|
412
|
+
|
|
413
|
+
html += `</tbody></table>`;
|
|
375
414
|
el.innerHTML = html;
|
|
376
415
|
} catch (err) {
|
|
377
416
|
if (err.message !== 'unauthorized') {
|
|
@@ -380,6 +419,26 @@ async function loadModels() {
|
|
|
380
419
|
}
|
|
381
420
|
}
|
|
382
421
|
|
|
422
|
+
function onModelToggle(cb) {
|
|
423
|
+
const row = cb.closest('tr');
|
|
424
|
+
if (row) row.style.opacity = cb.checked ? '1' : '0.45';
|
|
425
|
+
const all = document.querySelectorAll('.model-cb');
|
|
426
|
+
const checked = Array.from(all).filter(c => c.checked).length;
|
|
427
|
+
const label = document.getElementById('model-count-label');
|
|
428
|
+
if (label) label.textContent = `${checked} of ${all.length} enabled`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function toggleAllModels(enable) {
|
|
432
|
+
document.querySelectorAll('.model-cb').forEach(cb => {
|
|
433
|
+
cb.checked = enable;
|
|
434
|
+
const row = cb.closest('tr');
|
|
435
|
+
if (row) row.style.opacity = enable ? '1' : '0.45';
|
|
436
|
+
});
|
|
437
|
+
const all = document.querySelectorAll('.model-cb');
|
|
438
|
+
const label = document.getElementById('model-count-label');
|
|
439
|
+
if (label) label.textContent = `${enable ? all.length : 0} of ${all.length} enabled`;
|
|
440
|
+
}
|
|
441
|
+
|
|
383
442
|
async function saveEnabledModels(btn) {
|
|
384
443
|
const cbs = document.querySelectorAll('.model-cb');
|
|
385
444
|
if (!cbs.length) return;
|
package/server/admin/index.html
CHANGED
|
@@ -934,6 +934,33 @@
|
|
|
934
934
|
<span class="toolbar-count" id="users-count" aria-live="polite" style="padding-top:6px;"></span>
|
|
935
935
|
</div>
|
|
936
936
|
<div class="content">
|
|
937
|
+
|
|
938
|
+
<!-- Default rate limits card -->
|
|
939
|
+
<div class="card" style="margin-bottom:20px;">
|
|
940
|
+
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:16px;flex-wrap:wrap;">
|
|
941
|
+
<div>
|
|
942
|
+
<div style="font-size:15px;font-weight:700;color:var(--text);margin-bottom:4px;">Default Rate Limits</div>
|
|
943
|
+
<div style="font-size:12px;color:var(--text-muted);max-width:480px;">
|
|
944
|
+
Applied to every user unless they have a custom override. Leave empty for no limit.
|
|
945
|
+
</div>
|
|
946
|
+
</div>
|
|
947
|
+
<button class="btn btn-primary" style="flex-shrink:0;" onclick="saveDefaultRateLimits(this)">Save Defaults</button>
|
|
948
|
+
</div>
|
|
949
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:18px;" id="default-limits-fields">
|
|
950
|
+
<div>
|
|
951
|
+
<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>
|
|
952
|
+
<input type="number" min="0" step="1" id="default-limit-4h" placeholder="e.g. 500000 — empty = no limit"
|
|
953
|
+
style="width:100%;padding:9px 12px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;">
|
|
954
|
+
</div>
|
|
955
|
+
<div>
|
|
956
|
+
<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>
|
|
957
|
+
<input type="number" min="0" step="1" id="default-limit-weekly" placeholder="e.g. 2000000 — empty = no limit"
|
|
958
|
+
style="width:100%;padding:9px 12px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;">
|
|
959
|
+
</div>
|
|
960
|
+
</div>
|
|
961
|
+
</div>
|
|
962
|
+
|
|
963
|
+
<!-- Users table card -->
|
|
937
964
|
<div class="card">
|
|
938
965
|
<div class="toolbar" style="margin-bottom:14px;">
|
|
939
966
|
<input type="search" id="user-search" placeholder="Search by username or email…"
|
package/server/admin/users.js
CHANGED
|
@@ -5,8 +5,37 @@
|
|
|
5
5
|
let _userSearchTimer = null;
|
|
6
6
|
|
|
7
7
|
async function loadUsers() {
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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:
|
|
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:
|
|
167
|
-
<div style="font-size:13px;color:var(--text-muted);margin-bottom:20px;">
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
271
|
+
bSave.textContent = 'Save';
|
|
215
272
|
}
|
|
216
273
|
};
|
|
217
|
-
} catch (
|
|
274
|
+
} catch (_) {
|
|
218
275
|
alert('Failed to fetch rate limits');
|
|
219
276
|
}
|
|
220
277
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
4783d0646db3c87d4fbb189103797925
|
|
@@ -37,6 +37,6 @@ _flutter.buildConfig = {"engineRevision":"c416acfeb8126e097f758c664aaa3da929e27d
|
|
|
37
37
|
|
|
38
38
|
_flutter.loader.load({
|
|
39
39
|
serviceWorkerSettings: {
|
|
40
|
-
serviceWorkerVersion: "
|
|
40
|
+
serviceWorkerVersion: "2939524330" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
|
|
41
41
|
}
|
|
42
42
|
});
|