claude-code-limiter-server 1.0.0
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/Caddyfile +12 -0
- package/Dockerfile +57 -0
- package/LICENSE +21 -0
- package/bin/server.js +51 -0
- package/docker-compose.yml +46 -0
- package/package.json +27 -0
- package/src/dashboard/css/style.css +1374 -0
- package/src/dashboard/index.html +118 -0
- package/src/dashboard/js/app.js +1650 -0
- package/src/dashboard/js/charts.js +388 -0
- package/src/dashboard/js/ws.js +172 -0
- package/src/server/db.js +484 -0
- package/src/server/index.js +100 -0
- package/src/server/routes/admin-api.js +386 -0
- package/src/server/routes/hook-api.js +312 -0
- package/src/server/services/auth.js +174 -0
- package/src/server/services/limiter.js +226 -0
- package/src/server/services/usage.js +87 -0
- package/src/server/ws.js +74 -0
|
@@ -0,0 +1,1650 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* app.js — Main SPA logic for the Claude Code Limiter dashboard.
|
|
3
|
+
* Hash-based routing, API client, page renderers, event delegation.
|
|
4
|
+
*/
|
|
5
|
+
(function () {
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
/* ================================================================
|
|
9
|
+
STATE
|
|
10
|
+
================================================================ */
|
|
11
|
+
var state = {
|
|
12
|
+
token: localStorage.getItem('clm_token') || null,
|
|
13
|
+
team: null,
|
|
14
|
+
users: [],
|
|
15
|
+
events: [], // live feed events
|
|
16
|
+
refreshTimer: null,
|
|
17
|
+
currentRoute: null,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/* ================================================================
|
|
21
|
+
API CLIENT
|
|
22
|
+
================================================================ */
|
|
23
|
+
var API = {
|
|
24
|
+
/** @param {string} path @param {object} [opts] @returns {Promise<object>} */
|
|
25
|
+
_fetch: function (path, opts) {
|
|
26
|
+
opts = opts || {};
|
|
27
|
+
var headers = { 'Content-Type': 'application/json' };
|
|
28
|
+
if (state.token) headers['Authorization'] = 'Bearer ' + state.token;
|
|
29
|
+
return fetch(path, {
|
|
30
|
+
method: opts.method || 'GET',
|
|
31
|
+
headers: headers,
|
|
32
|
+
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
33
|
+
}).then(function (res) {
|
|
34
|
+
if (res.status === 401) {
|
|
35
|
+
state.token = null;
|
|
36
|
+
localStorage.removeItem('clm_token');
|
|
37
|
+
navigate('login');
|
|
38
|
+
return Promise.reject(new Error('Session expired'));
|
|
39
|
+
}
|
|
40
|
+
return res.json().then(function (data) {
|
|
41
|
+
if (!res.ok) return Promise.reject(new Error(data.error || 'Request failed'));
|
|
42
|
+
return data;
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
login: function (password) {
|
|
48
|
+
return API._fetch('/api/admin/login', { method: 'POST', body: { password: password } });
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
getUsers: function () {
|
|
52
|
+
return API._fetch('/api/admin/users');
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
getUser: function (id) {
|
|
56
|
+
return API._fetch('/api/admin/users').then(function (data) {
|
|
57
|
+
var user = null;
|
|
58
|
+
for (var i = 0; i < data.users.length; i++) {
|
|
59
|
+
if (data.users[i].id === id) { user = data.users[i]; break; }
|
|
60
|
+
}
|
|
61
|
+
if (!user) return Promise.reject(new Error('User not found'));
|
|
62
|
+
return user;
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
createUser: function (body) {
|
|
67
|
+
return API._fetch('/api/admin/users', { method: 'POST', body: body });
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
updateUser: function (id, body) {
|
|
71
|
+
return API._fetch('/api/admin/users/' + id, { method: 'PUT', body: body });
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
deleteUser: function (id) {
|
|
75
|
+
return API._fetch('/api/admin/users/' + id, { method: 'DELETE' });
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
getUsage: function (params) {
|
|
79
|
+
var qs = '';
|
|
80
|
+
if (params) {
|
|
81
|
+
var parts = [];
|
|
82
|
+
for (var k in params) {
|
|
83
|
+
if (params[k] != null) parts.push(k + '=' + encodeURIComponent(params[k]));
|
|
84
|
+
}
|
|
85
|
+
qs = '?' + parts.join('&');
|
|
86
|
+
}
|
|
87
|
+
return API._fetch('/api/admin/usage' + qs);
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
getEvents: function (params) {
|
|
91
|
+
var qs = '';
|
|
92
|
+
if (params) {
|
|
93
|
+
var parts = [];
|
|
94
|
+
for (var k in params) {
|
|
95
|
+
if (params[k] != null) parts.push(k + '=' + encodeURIComponent(params[k]));
|
|
96
|
+
}
|
|
97
|
+
qs = '?' + parts.join('&');
|
|
98
|
+
}
|
|
99
|
+
return API._fetch('/api/admin/events' + qs);
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
updateSettings: function (body) {
|
|
103
|
+
return API._fetch('/api/admin/settings', { method: 'PUT', body: body });
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/* ================================================================
|
|
108
|
+
TOAST NOTIFICATIONS
|
|
109
|
+
================================================================ */
|
|
110
|
+
var toastCounter = 0;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Show a toast notification.
|
|
114
|
+
* @param {string} title
|
|
115
|
+
* @param {string} message
|
|
116
|
+
* @param {'info'|'success'|'warning'|'error'} [type]
|
|
117
|
+
* @param {number} [duration] - ms, default 5000
|
|
118
|
+
*/
|
|
119
|
+
function showToast(title, message, type, duration) {
|
|
120
|
+
type = type || 'info';
|
|
121
|
+
duration = duration || 5000;
|
|
122
|
+
var id = 'toast-' + (++toastCounter);
|
|
123
|
+
var icons = { info: '\u2139\uFE0F', success: '\u2705', warning: '\u26A0\uFE0F', error: '\u274C' };
|
|
124
|
+
|
|
125
|
+
var container = document.getElementById('toast-container');
|
|
126
|
+
var el = document.createElement('div');
|
|
127
|
+
el.className = 'toast toast-' + type;
|
|
128
|
+
el.id = id;
|
|
129
|
+
el.innerHTML =
|
|
130
|
+
'<span class="toast-icon">' + (icons[type] || '') + '</span>' +
|
|
131
|
+
'<div class="toast-body">' +
|
|
132
|
+
'<div class="toast-title">' + escapeHtml(title) + '</div>' +
|
|
133
|
+
'<div class="toast-message">' + escapeHtml(message) + '</div>' +
|
|
134
|
+
'</div>' +
|
|
135
|
+
'<button class="toast-close" data-toast-close="' + id + '">×</button>';
|
|
136
|
+
container.appendChild(el);
|
|
137
|
+
|
|
138
|
+
var removeTimer = setTimeout(function () { removeToast(id); }, duration);
|
|
139
|
+
|
|
140
|
+
el.querySelector('.toast-close').addEventListener('click', function () {
|
|
141
|
+
clearTimeout(removeTimer);
|
|
142
|
+
removeToast(id);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function removeToast(id) {
|
|
147
|
+
var el = document.getElementById(id);
|
|
148
|
+
if (!el) return;
|
|
149
|
+
el.classList.add('removing');
|
|
150
|
+
setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 250);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* ================================================================
|
|
154
|
+
CONFIRM DIALOG
|
|
155
|
+
================================================================ */
|
|
156
|
+
/**
|
|
157
|
+
* Show a confirm dialog. Returns a Promise that resolves true/false.
|
|
158
|
+
* @param {string} title
|
|
159
|
+
* @param {string} message
|
|
160
|
+
* @param {string} [okLabel]
|
|
161
|
+
* @returns {Promise<boolean>}
|
|
162
|
+
*/
|
|
163
|
+
function confirmDialog(title, message, okLabel) {
|
|
164
|
+
return new Promise(function (resolve) {
|
|
165
|
+
var overlay = document.getElementById('confirm-overlay');
|
|
166
|
+
document.getElementById('confirm-title').textContent = title;
|
|
167
|
+
document.getElementById('confirm-message').textContent = message;
|
|
168
|
+
var okBtn = document.getElementById('confirm-ok');
|
|
169
|
+
okBtn.textContent = okLabel || 'Confirm';
|
|
170
|
+
overlay.classList.remove('hidden');
|
|
171
|
+
|
|
172
|
+
function cleanup(result) {
|
|
173
|
+
overlay.classList.add('hidden');
|
|
174
|
+
document.getElementById('confirm-cancel').removeEventListener('click', onCancel);
|
|
175
|
+
okBtn.removeEventListener('click', onOk);
|
|
176
|
+
resolve(result);
|
|
177
|
+
}
|
|
178
|
+
function onCancel() { cleanup(false); }
|
|
179
|
+
function onOk() { cleanup(true); }
|
|
180
|
+
|
|
181
|
+
document.getElementById('confirm-cancel').addEventListener('click', onCancel);
|
|
182
|
+
okBtn.addEventListener('click', onOk);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/* ================================================================
|
|
187
|
+
MODAL HELPERS
|
|
188
|
+
================================================================ */
|
|
189
|
+
function openModal(html) {
|
|
190
|
+
var overlay = document.getElementById('modal-overlay');
|
|
191
|
+
document.getElementById('modal-content').innerHTML = html;
|
|
192
|
+
overlay.classList.remove('hidden');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function closeModal() {
|
|
196
|
+
document.getElementById('modal-overlay').classList.add('hidden');
|
|
197
|
+
document.getElementById('modal-content').innerHTML = '';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/* ================================================================
|
|
201
|
+
COPY TO CLIPBOARD
|
|
202
|
+
================================================================ */
|
|
203
|
+
function copyToClipboard(text) {
|
|
204
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
205
|
+
navigator.clipboard.writeText(text).then(function () {
|
|
206
|
+
showToast('Copied', 'Command copied to clipboard', 'success', 2500);
|
|
207
|
+
}).catch(function () {
|
|
208
|
+
fallbackCopy(text);
|
|
209
|
+
});
|
|
210
|
+
} else {
|
|
211
|
+
fallbackCopy(text);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function fallbackCopy(text) {
|
|
216
|
+
var ta = document.createElement('textarea');
|
|
217
|
+
ta.value = text;
|
|
218
|
+
ta.style.position = 'fixed';
|
|
219
|
+
ta.style.left = '-9999px';
|
|
220
|
+
document.body.appendChild(ta);
|
|
221
|
+
ta.select();
|
|
222
|
+
try {
|
|
223
|
+
document.execCommand('copy');
|
|
224
|
+
showToast('Copied', 'Command copied to clipboard', 'success', 2500);
|
|
225
|
+
} catch (e) {
|
|
226
|
+
showToast('Error', 'Could not copy to clipboard', 'error');
|
|
227
|
+
}
|
|
228
|
+
document.body.removeChild(ta);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/* ================================================================
|
|
232
|
+
UTILITY HELPERS
|
|
233
|
+
================================================================ */
|
|
234
|
+
function escapeHtml(str) {
|
|
235
|
+
if (!str) return '';
|
|
236
|
+
return String(str)
|
|
237
|
+
.replace(/&/g, '&')
|
|
238
|
+
.replace(/</g, '<')
|
|
239
|
+
.replace(/>/g, '>')
|
|
240
|
+
.replace(/"/g, '"');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function timeAgo(dateStr) {
|
|
244
|
+
if (!dateStr) return 'never';
|
|
245
|
+
var d = new Date(dateStr);
|
|
246
|
+
var now = Date.now();
|
|
247
|
+
var diff = now - d.getTime();
|
|
248
|
+
if (diff < 0) return 'just now';
|
|
249
|
+
var seconds = Math.floor(diff / 1000);
|
|
250
|
+
if (seconds < 60) return seconds + 's ago';
|
|
251
|
+
var minutes = Math.floor(seconds / 60);
|
|
252
|
+
if (minutes < 60) return minutes + 'm ago';
|
|
253
|
+
var hours = Math.floor(minutes / 60);
|
|
254
|
+
if (hours < 24) return hours + 'h ago';
|
|
255
|
+
var days = Math.floor(hours / 24);
|
|
256
|
+
return days + 'd ago';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function formatTime(dateStr) {
|
|
260
|
+
if (!dateStr) return '';
|
|
261
|
+
var d = new Date(dateStr);
|
|
262
|
+
var h = d.getHours();
|
|
263
|
+
var m = d.getMinutes();
|
|
264
|
+
var s = d.getSeconds();
|
|
265
|
+
return String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Generate a deterministic color from a string (for avatars). */
|
|
269
|
+
function hashColor(str) {
|
|
270
|
+
var hash = 0;
|
|
271
|
+
for (var i = 0; i < str.length; i++) {
|
|
272
|
+
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
|
273
|
+
}
|
|
274
|
+
var hues = [210, 150, 340, 40, 280, 180, 20, 100, 300, 60];
|
|
275
|
+
var hue = hues[Math.abs(hash) % hues.length];
|
|
276
|
+
return 'hsl(' + hue + ', 60%, 55%)';
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function initials(name) {
|
|
280
|
+
if (!name) return '?';
|
|
281
|
+
var parts = name.trim().split(/\s+/);
|
|
282
|
+
if (parts.length === 1) return parts[0].charAt(0).toUpperCase();
|
|
283
|
+
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function statusBadge(status) {
|
|
287
|
+
var cls = 'badge badge-' + (status || 'active');
|
|
288
|
+
return '<span class="' + cls + '">' + escapeHtml(status || 'active') + '</span>';
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Get a usage percentage for progress bars. */
|
|
292
|
+
function usagePct(used, limit) {
|
|
293
|
+
if (limit <= 0) return 0;
|
|
294
|
+
var p = used / limit;
|
|
295
|
+
return p > 1 ? 1 : p;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function progressClass(pct) {
|
|
299
|
+
if (pct >= 0.9) return 'progress-fill-danger';
|
|
300
|
+
if (pct >= 0.7) return 'progress-fill-warn';
|
|
301
|
+
return 'progress-fill-ok';
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function renderProgressBar(label, used, limit, large) {
|
|
305
|
+
var pct = limit > 0 ? usagePct(used, limit) : 0;
|
|
306
|
+
var cls = progressClass(pct);
|
|
307
|
+
var barCls = large ? 'progress-bar progress-bar-lg' : 'progress-bar';
|
|
308
|
+
var valStr = used + (limit > 0 ? ' / ' + limit : (limit === -1 ? ' / unlimited' : ''));
|
|
309
|
+
return (
|
|
310
|
+
'<div class="progress-row">' +
|
|
311
|
+
'<div class="progress-label">' +
|
|
312
|
+
'<span class="progress-label-name">' + escapeHtml(label) + '</span>' +
|
|
313
|
+
'<span class="progress-label-value">' + valStr + '</span>' +
|
|
314
|
+
'</div>' +
|
|
315
|
+
'<div class="' + barCls + '">' +
|
|
316
|
+
'<div class="progress-fill ' + cls + '" style="width:' + (pct * 100).toFixed(1) + '%"></div>' +
|
|
317
|
+
'</div>' +
|
|
318
|
+
'</div>'
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Extract the per-model limit value for a given model and window from a user's limits array. */
|
|
323
|
+
function getModelLimit(limits, model, windowType) {
|
|
324
|
+
windowType = windowType || 'daily';
|
|
325
|
+
for (var i = 0; i < limits.length; i++) {
|
|
326
|
+
var r = limits[i];
|
|
327
|
+
if (r.type === 'per_model' && r.model === model && r.window === windowType) return r.value;
|
|
328
|
+
}
|
|
329
|
+
return -1; // unlimited
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function getCreditRule(limits) {
|
|
333
|
+
for (var i = 0; i < limits.length; i++) {
|
|
334
|
+
if (limits[i].type === 'credits') return limits[i];
|
|
335
|
+
}
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/* ================================================================
|
|
340
|
+
ROUTER
|
|
341
|
+
================================================================ */
|
|
342
|
+
function getRoute() {
|
|
343
|
+
var hash = location.hash.replace(/^#\/?/, '');
|
|
344
|
+
if (!hash) return { page: 'overview', params: {} };
|
|
345
|
+
var parts = hash.split('/');
|
|
346
|
+
var page = parts[0];
|
|
347
|
+
var params = {};
|
|
348
|
+
if (page === 'user' && parts[1]) params.id = parts[1];
|
|
349
|
+
return { page: page, params: params };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function navigate(page, params) {
|
|
353
|
+
var hash = '#' + page;
|
|
354
|
+
if (params && params.id) hash += '/' + params.id;
|
|
355
|
+
location.hash = hash;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function handleRoute() {
|
|
359
|
+
var route = getRoute();
|
|
360
|
+
state.currentRoute = route;
|
|
361
|
+
|
|
362
|
+
// Auth gate
|
|
363
|
+
if (!state.token && route.page !== 'login') {
|
|
364
|
+
navigate('login');
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (state.token && route.page === 'login') {
|
|
368
|
+
navigate('overview');
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Show/hide screens
|
|
373
|
+
var loginScreen = document.getElementById('login-screen');
|
|
374
|
+
var appShell = document.getElementById('app-shell');
|
|
375
|
+
|
|
376
|
+
if (route.page === 'login') {
|
|
377
|
+
loginScreen.classList.remove('hidden');
|
|
378
|
+
appShell.classList.add('hidden');
|
|
379
|
+
stopAutoRefresh();
|
|
380
|
+
WS.disconnect();
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
loginScreen.classList.add('hidden');
|
|
385
|
+
appShell.classList.remove('hidden');
|
|
386
|
+
|
|
387
|
+
// Connect WS if not connected
|
|
388
|
+
if (!WS.isConnected()) WS.connect();
|
|
389
|
+
|
|
390
|
+
// Update sidebar active state
|
|
391
|
+
var links = document.querySelectorAll('.nav-link');
|
|
392
|
+
for (var i = 0; i < links.length; i++) {
|
|
393
|
+
var linkPage = links[i].getAttribute('data-page');
|
|
394
|
+
if (linkPage === route.page || (linkPage === 'users' && route.page === 'user')) {
|
|
395
|
+
links[i].classList.add('active');
|
|
396
|
+
} else {
|
|
397
|
+
links[i].classList.remove('active');
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Close mobile sidebar
|
|
402
|
+
closeMobileSidebar();
|
|
403
|
+
|
|
404
|
+
// Render the page
|
|
405
|
+
renderPage(route);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function renderPage(route) {
|
|
409
|
+
var content = document.getElementById('content');
|
|
410
|
+
content.innerHTML = '<div class="spinner"></div>';
|
|
411
|
+
|
|
412
|
+
switch (route.page) {
|
|
413
|
+
case 'overview':
|
|
414
|
+
case 'users':
|
|
415
|
+
renderOverview(content);
|
|
416
|
+
break;
|
|
417
|
+
case 'user':
|
|
418
|
+
renderUserDetail(content, route.params.id);
|
|
419
|
+
break;
|
|
420
|
+
case 'settings':
|
|
421
|
+
renderSettings(content);
|
|
422
|
+
break;
|
|
423
|
+
default:
|
|
424
|
+
content.innerHTML = '<div class="empty-state"><p>Page not found</p></div>';
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/* ================================================================
|
|
429
|
+
PAGE: OVERVIEW / USERS
|
|
430
|
+
================================================================ */
|
|
431
|
+
function renderOverview(container) {
|
|
432
|
+
Promise.all([API.getUsers(), API.getEvents({ limit: 30 })])
|
|
433
|
+
.then(function (results) {
|
|
434
|
+
var usersData = results[0];
|
|
435
|
+
var eventsData = results[1];
|
|
436
|
+
state.users = usersData.users;
|
|
437
|
+
|
|
438
|
+
var users = usersData.users;
|
|
439
|
+
var events = eventsData.events || [];
|
|
440
|
+
|
|
441
|
+
// Stats
|
|
442
|
+
var totalUsers = users.length;
|
|
443
|
+
var activeUsers = users.filter(function (u) { return u.status === 'active'; }).length;
|
|
444
|
+
var pausedUsers = users.filter(function (u) { return u.status === 'paused'; }).length;
|
|
445
|
+
var killedUsers = users.filter(function (u) { return u.status === 'killed'; }).length;
|
|
446
|
+
var totalCreditsUsed = 0;
|
|
447
|
+
var totalCreditBudget = 0;
|
|
448
|
+
for (var ci = 0; ci < users.length; ci++) {
|
|
449
|
+
if (users[ci].credit_budget && users[ci].credit_budget > 0) {
|
|
450
|
+
totalCreditsUsed += (users[ci].credit_budget - (users[ci].credit_balance || 0));
|
|
451
|
+
totalCreditBudget += users[ci].credit_budget;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
var html = '';
|
|
456
|
+
html += '<div class="page-header">';
|
|
457
|
+
html += ' <h2>Overview</h2>';
|
|
458
|
+
html += ' <div class="page-header-actions">';
|
|
459
|
+
html += ' <button class="btn btn-primary" data-action="add-user">+ Add User</button>';
|
|
460
|
+
html += ' </div>';
|
|
461
|
+
html += '</div>';
|
|
462
|
+
|
|
463
|
+
// Stats row
|
|
464
|
+
html += '<div class="stats-row">';
|
|
465
|
+
html += statCard('Total Users', totalUsers, '');
|
|
466
|
+
html += statCard('Active', activeUsers, '', 'text-green');
|
|
467
|
+
html += statCard('Paused', pausedUsers, '', 'text-yellow');
|
|
468
|
+
html += statCard('Killed', killedUsers, '', 'text-red');
|
|
469
|
+
html += '</div>';
|
|
470
|
+
|
|
471
|
+
// Two column: user cards + live feed
|
|
472
|
+
html += '<div class="two-col">';
|
|
473
|
+
|
|
474
|
+
// User cards
|
|
475
|
+
html += '<div>';
|
|
476
|
+
html += '<div class="card mb-2"><div class="card-header"><h3>Users</h3></div>';
|
|
477
|
+
if (users.length === 0) {
|
|
478
|
+
html += '<div class="card-body"><div class="empty-state"><p>No users yet. Click "Add User" to get started.</p></div></div>';
|
|
479
|
+
} else {
|
|
480
|
+
html += '<div class="card-body"><div class="user-grid" style="margin-bottom:0">';
|
|
481
|
+
for (var i = 0; i < users.length; i++) {
|
|
482
|
+
html += renderUserCard(users[i]);
|
|
483
|
+
}
|
|
484
|
+
html += '</div></div>';
|
|
485
|
+
}
|
|
486
|
+
html += '</div></div>';
|
|
487
|
+
|
|
488
|
+
// Live feed
|
|
489
|
+
html += '<div>';
|
|
490
|
+
html += '<div class="card"><div class="card-header"><h3>Live Feed</h3></div>';
|
|
491
|
+
html += '<div class="card-body-flush"><div class="live-feed" id="live-feed">';
|
|
492
|
+
if (events.length === 0) {
|
|
493
|
+
html += '<div class="feed-empty">No events yet. Activity will appear here in real time.</div>';
|
|
494
|
+
} else {
|
|
495
|
+
for (var j = 0; j < events.length; j++) {
|
|
496
|
+
html += renderFeedItem(eventToFeed(events[j]));
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
html += '</div></div></div>';
|
|
500
|
+
html += '</div>';
|
|
501
|
+
|
|
502
|
+
html += '</div>'; // .two-col
|
|
503
|
+
|
|
504
|
+
container.innerHTML = html;
|
|
505
|
+
startAutoRefresh();
|
|
506
|
+
})
|
|
507
|
+
.catch(function (err) {
|
|
508
|
+
container.innerHTML = '<div class="empty-state"><p>Failed to load data: ' + escapeHtml(err.message) + '</p></div>';
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function statCard(label, value, sub, colorClass) {
|
|
513
|
+
return (
|
|
514
|
+
'<div class="stat-card">' +
|
|
515
|
+
'<div class="stat-label">' + escapeHtml(label) + '</div>' +
|
|
516
|
+
'<div class="stat-value ' + (colorClass || '') + '">' + escapeHtml(String(value)) + '</div>' +
|
|
517
|
+
(sub ? '<div class="stat-sub">' + escapeHtml(sub) + '</div>' : '') +
|
|
518
|
+
'</div>'
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function renderUserCard(user) {
|
|
523
|
+
var limits = user.limits || [];
|
|
524
|
+
var usage = user.usage || {};
|
|
525
|
+
var dailyUsage = (usage.daily && usage.daily.counts) ? usage.daily.counts : {};
|
|
526
|
+
|
|
527
|
+
// Primary progress: credit budget or total daily prompts
|
|
528
|
+
var progressHtml = '';
|
|
529
|
+
var creditRule = getCreditRule(limits);
|
|
530
|
+
if (creditRule && user.credit_budget > 0) {
|
|
531
|
+
var creditUsed = user.credit_budget - (user.credit_balance || 0);
|
|
532
|
+
progressHtml += renderProgressBar('Credits', creditUsed, user.credit_budget, false);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Per-model bars
|
|
536
|
+
var models = ['opus', 'sonnet', 'haiku'];
|
|
537
|
+
for (var mi = 0; mi < models.length; mi++) {
|
|
538
|
+
var m = models[mi];
|
|
539
|
+
var mLimit = getModelLimit(limits, m);
|
|
540
|
+
var mUsed = dailyUsage[m] || 0;
|
|
541
|
+
if (mLimit > 0 || mUsed > 0) {
|
|
542
|
+
progressHtml += renderProgressBar(m, mUsed, mLimit > 0 ? mLimit : 0, false);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (!progressHtml) {
|
|
546
|
+
progressHtml = '<div class="text-muted text-sm">No limits configured</div>';
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
var color = hashColor(user.name || user.slug);
|
|
550
|
+
|
|
551
|
+
return (
|
|
552
|
+
'<div class="user-card" data-action="view-user" data-user-id="' + user.id + '">' +
|
|
553
|
+
'<div class="user-card-head">' +
|
|
554
|
+
'<div class="user-card-info">' +
|
|
555
|
+
'<div class="user-avatar" style="background:' + color + '">' + initials(user.name) + '</div>' +
|
|
556
|
+
'<div>' +
|
|
557
|
+
'<div class="user-card-name">' + escapeHtml(user.name) + '</div>' +
|
|
558
|
+
'<div class="user-card-slug">@' + escapeHtml(user.slug) + '</div>' +
|
|
559
|
+
'</div>' +
|
|
560
|
+
'</div>' +
|
|
561
|
+
statusBadge(user.status) +
|
|
562
|
+
'</div>' +
|
|
563
|
+
'<div class="user-card-usage">' + progressHtml + '</div>' +
|
|
564
|
+
'<div class="user-card-actions">' +
|
|
565
|
+
(user.status === 'active'
|
|
566
|
+
? '<button class="btn btn-sm btn-warning" data-action="pause-user" data-user-id="' + user.id + '" data-user-name="' + escapeHtml(user.name) + '">Pause</button>'
|
|
567
|
+
+ '<button class="btn btn-sm btn-danger" data-action="kill-user" data-user-id="' + user.id + '" data-user-name="' + escapeHtml(user.name) + '">Kill</button>'
|
|
568
|
+
: user.status === 'paused'
|
|
569
|
+
? '<button class="btn btn-sm btn-success" data-action="reinstate-user" data-user-id="' + user.id + '" data-user-name="' + escapeHtml(user.name) + '">Reinstate</button>'
|
|
570
|
+
+ '<button class="btn btn-sm btn-danger" data-action="kill-user" data-user-id="' + user.id + '" data-user-name="' + escapeHtml(user.name) + '">Kill</button>'
|
|
571
|
+
: '<button class="btn btn-sm btn-success" data-action="reinstate-user" data-user-id="' + user.id + '" data-user-name="' + escapeHtml(user.name) + '">Reinstate</button>'
|
|
572
|
+
) +
|
|
573
|
+
'</div>' +
|
|
574
|
+
'</div>'
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/* ================================================================
|
|
579
|
+
LIVE FEED HELPERS
|
|
580
|
+
================================================================ */
|
|
581
|
+
/** Convert a usage_event DB row into a feed display object. */
|
|
582
|
+
function eventToFeed(evt) {
|
|
583
|
+
return {
|
|
584
|
+
type: 'counted',
|
|
585
|
+
user: evt.user_name || evt.userName || 'Unknown',
|
|
586
|
+
detail: evt.model + ' (+' + evt.credit_cost + ' credits)',
|
|
587
|
+
time: evt.timestamp,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Convert a WebSocket event into a feed display object. */
|
|
592
|
+
function wsEventToFeed(evt) {
|
|
593
|
+
switch (evt.type) {
|
|
594
|
+
case 'user_check':
|
|
595
|
+
return { type: 'check', user: evt.userName, detail: 'Checking ' + (evt.model || ''), time: evt.timestamp };
|
|
596
|
+
case 'user_blocked':
|
|
597
|
+
return { type: 'blocked', user: evt.userName, detail: 'Blocked on ' + (evt.model || '') + (evt.reason ? ': ' + evt.reason.split('\n')[0] : ''), time: evt.timestamp };
|
|
598
|
+
case 'user_counted':
|
|
599
|
+
return { type: 'counted', user: evt.userName, detail: (evt.model || '') + ' (+' + (evt.creditCost || 0) + ' credits)', time: evt.timestamp };
|
|
600
|
+
case 'user_status_change':
|
|
601
|
+
case 'user_killed':
|
|
602
|
+
return { type: 'status', user: evt.userName, detail: (evt.oldStatus || '?') + ' -> ' + (evt.newStatus || '?'), time: evt.timestamp };
|
|
603
|
+
case 'user_status':
|
|
604
|
+
return { type: 'check', user: evt.userName, detail: 'Session start (' + (evt.model || '') + ')', time: evt.timestamp };
|
|
605
|
+
default:
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function renderFeedItem(feed) {
|
|
611
|
+
if (!feed) return '';
|
|
612
|
+
var dotCls = 'feed-dot feed-dot-' + (feed.type || 'system');
|
|
613
|
+
return (
|
|
614
|
+
'<div class="feed-item">' +
|
|
615
|
+
'<span class="' + dotCls + '"></span>' +
|
|
616
|
+
'<div class="feed-content">' +
|
|
617
|
+
'<span class="feed-user">' + escapeHtml(feed.user) + '</span> ' +
|
|
618
|
+
'<span class="feed-detail">' + escapeHtml(feed.detail) + '</span>' +
|
|
619
|
+
'</div>' +
|
|
620
|
+
'<span class="feed-time">' + formatTime(feed.time) + '</span>' +
|
|
621
|
+
'</div>'
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function addLiveFeedItem(feed) {
|
|
626
|
+
var feedEl = document.getElementById('live-feed');
|
|
627
|
+
if (!feedEl) return;
|
|
628
|
+
|
|
629
|
+
// Remove empty state message if present
|
|
630
|
+
var empty = feedEl.querySelector('.feed-empty');
|
|
631
|
+
if (empty) empty.remove();
|
|
632
|
+
|
|
633
|
+
var div = document.createElement('div');
|
|
634
|
+
div.innerHTML = renderFeedItem(feed);
|
|
635
|
+
var newItem = div.firstElementChild;
|
|
636
|
+
if (newItem) {
|
|
637
|
+
feedEl.insertBefore(newItem, feedEl.firstChild);
|
|
638
|
+
// Keep feed to 50 items max
|
|
639
|
+
while (feedEl.children.length > 50) {
|
|
640
|
+
feedEl.removeChild(feedEl.lastChild);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/* ================================================================
|
|
646
|
+
PAGE: USER DETAIL
|
|
647
|
+
================================================================ */
|
|
648
|
+
function renderUserDetail(container, userId) {
|
|
649
|
+
Promise.all([API.getUser(userId), API.getUsage({ user_id: userId, days: 30 }), API.getEvents({ user_id: userId, limit: 20 })])
|
|
650
|
+
.then(function (results) {
|
|
651
|
+
var user = results[0];
|
|
652
|
+
var usageData = results[1];
|
|
653
|
+
var eventsData = results[2];
|
|
654
|
+
var limits = user.limits || [];
|
|
655
|
+
var usage = user.usage || {};
|
|
656
|
+
var dailyUsage = (usage.daily && usage.daily.counts) ? usage.daily.counts : {};
|
|
657
|
+
var color = hashColor(user.name || user.slug);
|
|
658
|
+
|
|
659
|
+
var html = '';
|
|
660
|
+
// Breadcrumb
|
|
661
|
+
html += '<div class="breadcrumb"><a href="#overview">Overview</a> / <span>' + escapeHtml(user.name) + '</span></div>';
|
|
662
|
+
html += '<div class="page-header"><h2>' + escapeHtml(user.name) + '</h2>';
|
|
663
|
+
html += '<div class="page-header-actions">';
|
|
664
|
+
html += '<button class="btn btn-sm" data-action="edit-limits" data-user-id="' + user.id + '">Edit Limits</button>';
|
|
665
|
+
html += '<button class="btn btn-sm btn-danger" data-action="delete-user" data-user-id="' + user.id + '" data-user-name="' + escapeHtml(user.name) + '">Delete User</button>';
|
|
666
|
+
html += '</div></div>';
|
|
667
|
+
|
|
668
|
+
html += '<div class="detail-grid">';
|
|
669
|
+
|
|
670
|
+
// Left sidebar card
|
|
671
|
+
html += '<div class="card"><div class="card-body detail-sidebar-card">';
|
|
672
|
+
html += '<div class="detail-avatar" style="background:' + color + '">' + initials(user.name) + '</div>';
|
|
673
|
+
html += '<div class="detail-name">' + escapeHtml(user.name) + '</div>';
|
|
674
|
+
html += '<div class="detail-meta">@' + escapeHtml(user.slug) + '</div>';
|
|
675
|
+
html += statusBadge(user.status);
|
|
676
|
+
html += '<div class="detail-meta">Last seen: ' + timeAgo(user.last_seen) + '</div>';
|
|
677
|
+
html += '<div class="detail-meta">Created: ' + (user.created_at ? new Date(user.created_at).toLocaleDateString() : 'N/A') + '</div>';
|
|
678
|
+
html += '<div class="detail-actions">';
|
|
679
|
+
if (user.status === 'active') {
|
|
680
|
+
html += '<button class="btn btn-sm btn-warning btn-block" data-action="pause-user" data-user-id="' + user.id + '" data-user-name="' + escapeHtml(user.name) + '">Pause User</button>';
|
|
681
|
+
html += '<button class="btn btn-sm btn-danger btn-block" data-action="kill-user" data-user-id="' + user.id + '" data-user-name="' + escapeHtml(user.name) + '">Kill User</button>';
|
|
682
|
+
} else if (user.status === 'paused') {
|
|
683
|
+
html += '<button class="btn btn-sm btn-success btn-block" data-action="reinstate-user" data-user-id="' + user.id + '" data-user-name="' + escapeHtml(user.name) + '">Reinstate</button>';
|
|
684
|
+
html += '<button class="btn btn-sm btn-danger btn-block" data-action="kill-user" data-user-id="' + user.id + '" data-user-name="' + escapeHtml(user.name) + '">Kill User</button>';
|
|
685
|
+
} else {
|
|
686
|
+
html += '<button class="btn btn-sm btn-success btn-block" data-action="reinstate-user" data-user-id="' + user.id + '" data-user-name="' + escapeHtml(user.name) + '">Reinstate</button>';
|
|
687
|
+
}
|
|
688
|
+
html += '</div>';
|
|
689
|
+
|
|
690
|
+
// Credit gauge
|
|
691
|
+
var creditRule = getCreditRule(limits);
|
|
692
|
+
if (creditRule && user.credit_budget > 0) {
|
|
693
|
+
html += '<div class="gauge-wrap"><canvas id="credit-gauge" width="180" height="180"></canvas></div>';
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
html += '</div></div>'; // card
|
|
697
|
+
|
|
698
|
+
// Right content area
|
|
699
|
+
html += '<div>';
|
|
700
|
+
|
|
701
|
+
// Per-model usage bars
|
|
702
|
+
html += '<div class="card mb-2"><div class="card-header"><h3>Per-Model Usage (Daily)</h3></div>';
|
|
703
|
+
html += '<div class="card-body"><div class="chart-container"><canvas id="model-bars"></canvas></div></div>';
|
|
704
|
+
html += '</div>';
|
|
705
|
+
|
|
706
|
+
// Active limits
|
|
707
|
+
html += '<div class="card mb-2"><div class="card-header"><h3>Active Limits</h3>';
|
|
708
|
+
html += '<button class="btn btn-sm" data-action="edit-limits" data-user-id="' + user.id + '">Edit</button>';
|
|
709
|
+
html += '</div>';
|
|
710
|
+
html += '<div class="card-body">';
|
|
711
|
+
if (limits.length === 0) {
|
|
712
|
+
html += '<div class="text-muted text-sm">No limits configured. This user has unlimited access.</div>';
|
|
713
|
+
} else {
|
|
714
|
+
html += '<ul class="limits-list">';
|
|
715
|
+
for (var li = 0; li < limits.length; li++) {
|
|
716
|
+
html += renderLimitItem(limits[li]);
|
|
717
|
+
}
|
|
718
|
+
html += '</ul>';
|
|
719
|
+
}
|
|
720
|
+
html += '</div></div>';
|
|
721
|
+
|
|
722
|
+
// 30-day trend
|
|
723
|
+
html += '<div class="card mb-2"><div class="card-header"><h3>30-Day Usage Trend</h3></div>';
|
|
724
|
+
html += '<div class="card-body"><div class="chart-container"><canvas id="trend-chart"></canvas></div></div>';
|
|
725
|
+
html += '</div>';
|
|
726
|
+
|
|
727
|
+
// Recent events
|
|
728
|
+
html += '<div class="card"><div class="card-header"><h3>Recent Activity</h3></div>';
|
|
729
|
+
html += '<div class="card-body-flush"><div class="table-wrap"><table>';
|
|
730
|
+
html += '<thead><tr><th>Time</th><th>Model</th><th>Credits</th></tr></thead><tbody>';
|
|
731
|
+
var events = eventsData.events || [];
|
|
732
|
+
if (events.length === 0) {
|
|
733
|
+
html += '<tr><td colspan="3" class="text-center text-muted">No recent activity</td></tr>';
|
|
734
|
+
} else {
|
|
735
|
+
for (var ei = 0; ei < events.length; ei++) {
|
|
736
|
+
var ev = events[ei];
|
|
737
|
+
html += '<tr>';
|
|
738
|
+
html += '<td class="text-mono text-sm">' + timeAgo(ev.timestamp) + '</td>';
|
|
739
|
+
html += '<td><span class="badge badge-model">' + escapeHtml(ev.model) + '</span></td>';
|
|
740
|
+
html += '<td class="text-mono">' + ev.credit_cost + '</td>';
|
|
741
|
+
html += '</tr>';
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
html += '</tbody></table></div></div></div>';
|
|
745
|
+
|
|
746
|
+
html += '</div>'; // right column
|
|
747
|
+
html += '</div>'; // detail-grid
|
|
748
|
+
|
|
749
|
+
container.innerHTML = html;
|
|
750
|
+
|
|
751
|
+
// Render charts after DOM is ready
|
|
752
|
+
requestAnimationFrame(function () {
|
|
753
|
+
// Credit gauge
|
|
754
|
+
if (creditRule && user.credit_budget > 0) {
|
|
755
|
+
var gaugeCanvas = document.getElementById('credit-gauge');
|
|
756
|
+
if (gaugeCanvas) {
|
|
757
|
+
var creditUsed = user.credit_budget - (user.credit_balance || 0);
|
|
758
|
+
Charts.creditGauge(gaugeCanvas, creditUsed, user.credit_budget);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Horizontal bars
|
|
763
|
+
var barsCanvas = document.getElementById('model-bars');
|
|
764
|
+
if (barsCanvas) {
|
|
765
|
+
var barData = [];
|
|
766
|
+
var models = ['opus', 'sonnet', 'haiku'];
|
|
767
|
+
for (var b = 0; b < models.length; b++) {
|
|
768
|
+
var mdl = models[b];
|
|
769
|
+
var used = dailyUsage[mdl] || 0;
|
|
770
|
+
var lim = getModelLimit(limits, mdl);
|
|
771
|
+
barData.push({ label: mdl.charAt(0).toUpperCase() + mdl.slice(1), value: used, limit: lim > 0 ? lim : 0 });
|
|
772
|
+
}
|
|
773
|
+
Charts.horizontalBar(barsCanvas, barData);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Trend line
|
|
777
|
+
var trendCanvas = document.getElementById('trend-chart');
|
|
778
|
+
if (trendCanvas && usageData.daily) {
|
|
779
|
+
var dayMap = {};
|
|
780
|
+
for (var di = 0; di < usageData.daily.length; di++) {
|
|
781
|
+
var row = usageData.daily[di];
|
|
782
|
+
dayMap[row.day] = (dayMap[row.day] || 0) + row.count;
|
|
783
|
+
}
|
|
784
|
+
var trendPts = [];
|
|
785
|
+
var today = new Date();
|
|
786
|
+
for (var td = 29; td >= 0; td--) {
|
|
787
|
+
var d = new Date(today);
|
|
788
|
+
d.setDate(d.getDate() - td);
|
|
789
|
+
var key = d.toISOString().split('T')[0];
|
|
790
|
+
trendPts.push({ day: key, value: dayMap[key] || 0 });
|
|
791
|
+
}
|
|
792
|
+
Charts.trendLine(trendCanvas, trendPts);
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
startAutoRefresh();
|
|
797
|
+
})
|
|
798
|
+
.catch(function (err) {
|
|
799
|
+
container.innerHTML = '<div class="empty-state"><p>Failed to load user: ' + escapeHtml(err.message) + '</p><a href="#overview">Back to overview</a></div>';
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function renderLimitItem(rule) {
|
|
804
|
+
var desc = '';
|
|
805
|
+
if (rule.type === 'credits') {
|
|
806
|
+
desc = 'Credit Budget (' + (rule.window || 'daily') + ')';
|
|
807
|
+
} else if (rule.type === 'per_model') {
|
|
808
|
+
desc = (rule.model || 'all models') + ' (' + (rule.window || 'daily') + ')';
|
|
809
|
+
} else if (rule.type === 'time_of_day') {
|
|
810
|
+
desc = (rule.model || 'all') + ' time restriction';
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
var valStr = '';
|
|
814
|
+
if (rule.type === 'time_of_day') {
|
|
815
|
+
valStr = (rule.schedule_start || '?') + ' - ' + (rule.schedule_end || '?') + ' ' + (rule.schedule_tz || '');
|
|
816
|
+
} else {
|
|
817
|
+
valStr = rule.value === -1 ? 'unlimited' : (rule.value === 0 ? 'blocked' : String(rule.value));
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
return (
|
|
821
|
+
'<li class="limit-item">' +
|
|
822
|
+
'<div class="limit-type">' +
|
|
823
|
+
'<span class="badge badge-model">' + escapeHtml(rule.type) + '</span>' +
|
|
824
|
+
'<span>' + escapeHtml(desc) + '</span>' +
|
|
825
|
+
'</div>' +
|
|
826
|
+
'<span class="limit-value">' + escapeHtml(valStr) + '</span>' +
|
|
827
|
+
'</li>'
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/* ================================================================
|
|
832
|
+
PAGE: SETTINGS
|
|
833
|
+
================================================================ */
|
|
834
|
+
function renderSettings(container) {
|
|
835
|
+
// We need team info. Fetch users to get the team context, or read from state.
|
|
836
|
+
// The login response stores team info in state.team.
|
|
837
|
+
if (!state.team) {
|
|
838
|
+
container.innerHTML = '<div class="spinner"></div>';
|
|
839
|
+
// Try getting users to refresh state
|
|
840
|
+
API.getUsers().then(function () {
|
|
841
|
+
renderSettingsInner(container);
|
|
842
|
+
}).catch(function (err) {
|
|
843
|
+
container.innerHTML = '<div class="empty-state"><p>Failed to load settings: ' + escapeHtml(err.message) + '</p></div>';
|
|
844
|
+
});
|
|
845
|
+
} else {
|
|
846
|
+
renderSettingsInner(container);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function renderSettingsInner(container) {
|
|
851
|
+
var team = state.team || { name: 'Team', credit_weights: { opus: 10, sonnet: 3, haiku: 1 } };
|
|
852
|
+
var cw = team.credit_weights || { opus: 10, sonnet: 3, haiku: 1 };
|
|
853
|
+
|
|
854
|
+
var html = '';
|
|
855
|
+
html += '<div class="page-header"><h2>Settings</h2></div>';
|
|
856
|
+
|
|
857
|
+
// Team name
|
|
858
|
+
html += '<div class="card mb-2"><div class="card-body">';
|
|
859
|
+
html += '<div class="settings-section">';
|
|
860
|
+
html += '<h3>Team Name</h3>';
|
|
861
|
+
html += '<div class="form-group">';
|
|
862
|
+
html += '<input type="text" id="settings-team-name" value="' + escapeHtml(team.name) + '" placeholder="Team name">';
|
|
863
|
+
html += '</div>';
|
|
864
|
+
html += '<button class="btn btn-primary btn-sm" data-action="save-team-name">Save Name</button>';
|
|
865
|
+
html += '</div>';
|
|
866
|
+
html += '</div></div>';
|
|
867
|
+
|
|
868
|
+
// Credit Weights
|
|
869
|
+
html += '<div class="card mb-2"><div class="card-body">';
|
|
870
|
+
html += '<div class="settings-section">';
|
|
871
|
+
html += '<h3>Credit Weights</h3>';
|
|
872
|
+
html += '<p class="text-muted text-sm mb-2">Define how many credits each model costs per turn. Higher weight = more expensive.</p>';
|
|
873
|
+
html += '<div class="weight-grid">';
|
|
874
|
+
var models = ['opus', 'sonnet', 'haiku'];
|
|
875
|
+
for (var i = 0; i < models.length; i++) {
|
|
876
|
+
var m = models[i];
|
|
877
|
+
html += '<div class="weight-card">';
|
|
878
|
+
html += '<div class="weight-model">' + m + '</div>';
|
|
879
|
+
html += '<input type="number" id="weight-' + m + '" min="0" value="' + (cw[m] || 0) + '">';
|
|
880
|
+
html += '</div>';
|
|
881
|
+
}
|
|
882
|
+
html += '</div>';
|
|
883
|
+
html += '<div class="mt-2"><button class="btn btn-primary btn-sm" data-action="save-weights">Save Weights</button></div>';
|
|
884
|
+
html += '</div>';
|
|
885
|
+
html += '</div></div>';
|
|
886
|
+
|
|
887
|
+
// Change Password
|
|
888
|
+
html += '<div class="card mb-2"><div class="card-body">';
|
|
889
|
+
html += '<div class="settings-section">';
|
|
890
|
+
html += '<h3>Change Admin Password</h3>';
|
|
891
|
+
html += '<div class="form-row">';
|
|
892
|
+
html += '<div class="form-group"><label for="settings-new-pw">New Password</label>';
|
|
893
|
+
html += '<input type="password" id="settings-new-pw" placeholder="New password"></div>';
|
|
894
|
+
html += '<div class="form-group"><label for="settings-confirm-pw">Confirm Password</label>';
|
|
895
|
+
html += '<input type="password" id="settings-confirm-pw" placeholder="Confirm password"></div>';
|
|
896
|
+
html += '</div>';
|
|
897
|
+
html += '<button class="btn btn-danger btn-sm" data-action="change-password">Change Password</button>';
|
|
898
|
+
html += '</div>';
|
|
899
|
+
html += '</div></div>';
|
|
900
|
+
|
|
901
|
+
container.innerHTML = html;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/* ================================================================
|
|
905
|
+
MODAL: ADD USER
|
|
906
|
+
================================================================ */
|
|
907
|
+
function openAddUserModal() {
|
|
908
|
+
var html = '';
|
|
909
|
+
html += '<div class="modal-header"><h3>Add User</h3><button class="modal-close" data-action="close-modal">×</button></div>';
|
|
910
|
+
html += '<div class="modal-body">';
|
|
911
|
+
|
|
912
|
+
html += '<div class="form-group"><label for="new-user-name">Name</label>';
|
|
913
|
+
html += '<input type="text" id="new-user-name" placeholder="e.g. Alice, Dev Team, Intern"></div>';
|
|
914
|
+
|
|
915
|
+
html += '<div class="form-group"><label for="new-user-slug">Slug (username)</label>';
|
|
916
|
+
html += '<input type="text" id="new-user-slug" placeholder="e.g. alice, dev-team, intern">';
|
|
917
|
+
html += '<p class="form-hint">Lowercase, no spaces. Used in install commands and logs.</p></div>';
|
|
918
|
+
|
|
919
|
+
html += '<hr class="section-divider">';
|
|
920
|
+
html += '<h4 class="mb-1">Limits Preset</h4>';
|
|
921
|
+
html += '<div class="presets-row">';
|
|
922
|
+
html += '<button class="preset-btn selected" data-preset="light">Light</button>';
|
|
923
|
+
html += '<button class="preset-btn" data-preset="medium">Medium</button>';
|
|
924
|
+
html += '<button class="preset-btn" data-preset="heavy">Heavy</button>';
|
|
925
|
+
html += '<button class="preset-btn" data-preset="unlimited">Unlimited</button>';
|
|
926
|
+
html += '<button class="preset-btn" data-preset="custom">Custom</button>';
|
|
927
|
+
html += '</div>';
|
|
928
|
+
|
|
929
|
+
html += '<div id="preset-description" class="text-muted text-sm mb-2">50 credits/day. Opus: 3, Sonnet: 10, Haiku: 30.</div>';
|
|
930
|
+
|
|
931
|
+
// Custom limits form (hidden by default)
|
|
932
|
+
html += '<div id="custom-limits-form" class="hidden">';
|
|
933
|
+
html += '<div class="form-group"><label>Credit Budget (daily)</label>';
|
|
934
|
+
html += '<input type="number" id="custom-credits" min="-1" value="100" placeholder="-1 for unlimited"></div>';
|
|
935
|
+
html += '<div class="form-row-3">';
|
|
936
|
+
html += '<div class="form-group"><label>Opus (daily)</label>';
|
|
937
|
+
html += '<input type="number" id="custom-opus" min="-1" value="5"></div>';
|
|
938
|
+
html += '<div class="form-group"><label>Sonnet (daily)</label>';
|
|
939
|
+
html += '<input type="number" id="custom-sonnet" min="-1" value="20"></div>';
|
|
940
|
+
html += '<div class="form-group"><label>Haiku (daily)</label>';
|
|
941
|
+
html += '<input type="number" id="custom-haiku" min="-1" value="50"></div>';
|
|
942
|
+
html += '</div>';
|
|
943
|
+
html += '</div>';
|
|
944
|
+
|
|
945
|
+
html += '</div>'; // modal-body
|
|
946
|
+
html += '<div class="modal-footer">';
|
|
947
|
+
html += '<button class="btn btn-ghost" data-action="close-modal">Cancel</button>';
|
|
948
|
+
html += '<button class="btn btn-primary" data-action="submit-add-user">Create User</button>';
|
|
949
|
+
html += '</div>';
|
|
950
|
+
|
|
951
|
+
openModal(html);
|
|
952
|
+
|
|
953
|
+
// Auto-generate slug from name
|
|
954
|
+
var nameInput = document.getElementById('new-user-name');
|
|
955
|
+
var slugInput = document.getElementById('new-user-slug');
|
|
956
|
+
if (nameInput && slugInput) {
|
|
957
|
+
nameInput.addEventListener('input', function () {
|
|
958
|
+
slugInput.value = nameInput.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
var PRESETS = {
|
|
964
|
+
light: { credits: 50, opus: 3, sonnet: 10, haiku: 30, desc: '50 credits/day. Opus: 3, Sonnet: 10, Haiku: 30.' },
|
|
965
|
+
medium: { credits: 100, opus: 5, sonnet: 20, haiku: 50, desc: '100 credits/day. Opus: 5, Sonnet: 20, Haiku: 50.' },
|
|
966
|
+
heavy: { credits: 200, opus: 10, sonnet: 40, haiku: 100, desc: '200 credits/day. Opus: 10, Sonnet: 40, Haiku: 100.' },
|
|
967
|
+
unlimited: { credits: -1, opus: -1, sonnet: -1, haiku: -1, desc: 'No limits. User has full unrestricted access.' },
|
|
968
|
+
};
|
|
969
|
+
|
|
970
|
+
var selectedPreset = 'light';
|
|
971
|
+
|
|
972
|
+
function selectPreset(preset) {
|
|
973
|
+
selectedPreset = preset;
|
|
974
|
+
|
|
975
|
+
// Update button states
|
|
976
|
+
var btns = document.querySelectorAll('.preset-btn');
|
|
977
|
+
for (var i = 0; i < btns.length; i++) {
|
|
978
|
+
btns[i].classList.toggle('selected', btns[i].getAttribute('data-preset') === preset);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
var descEl = document.getElementById('preset-description');
|
|
982
|
+
var customForm = document.getElementById('custom-limits-form');
|
|
983
|
+
|
|
984
|
+
if (preset === 'custom') {
|
|
985
|
+
if (descEl) descEl.textContent = 'Set your own limits below.';
|
|
986
|
+
if (customForm) customForm.classList.remove('hidden');
|
|
987
|
+
} else {
|
|
988
|
+
var p = PRESETS[preset];
|
|
989
|
+
if (descEl) descEl.textContent = p.desc;
|
|
990
|
+
if (customForm) customForm.classList.add('hidden');
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function buildLimitsFromPreset() {
|
|
995
|
+
var p;
|
|
996
|
+
if (selectedPreset === 'custom') {
|
|
997
|
+
p = {
|
|
998
|
+
credits: parseInt(document.getElementById('custom-credits').value, 10),
|
|
999
|
+
opus: parseInt(document.getElementById('custom-opus').value, 10),
|
|
1000
|
+
sonnet: parseInt(document.getElementById('custom-sonnet').value, 10),
|
|
1001
|
+
haiku: parseInt(document.getElementById('custom-haiku').value, 10),
|
|
1002
|
+
};
|
|
1003
|
+
} else {
|
|
1004
|
+
p = PRESETS[selectedPreset];
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
var limits = [];
|
|
1008
|
+
if (p.credits !== -1) {
|
|
1009
|
+
limits.push({ type: 'credits', window: 'daily', value: p.credits });
|
|
1010
|
+
}
|
|
1011
|
+
if (p.opus !== -1) {
|
|
1012
|
+
limits.push({ type: 'per_model', model: 'opus', window: 'daily', value: p.opus });
|
|
1013
|
+
}
|
|
1014
|
+
if (p.sonnet !== -1) {
|
|
1015
|
+
limits.push({ type: 'per_model', model: 'sonnet', window: 'daily', value: p.sonnet });
|
|
1016
|
+
}
|
|
1017
|
+
if (p.haiku !== -1) {
|
|
1018
|
+
limits.push({ type: 'per_model', model: 'haiku', window: 'daily', value: p.haiku });
|
|
1019
|
+
}
|
|
1020
|
+
return limits;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function submitAddUser() {
|
|
1024
|
+
var name = (document.getElementById('new-user-name').value || '').trim();
|
|
1025
|
+
var slug = (document.getElementById('new-user-slug').value || '').trim();
|
|
1026
|
+
|
|
1027
|
+
if (!name) { showToast('Error', 'Name is required', 'error'); return; }
|
|
1028
|
+
if (!slug) { showToast('Error', 'Slug is required', 'error'); return; }
|
|
1029
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) { showToast('Error', 'Slug must be lowercase letters, numbers, and hyphens', 'error'); return; }
|
|
1030
|
+
|
|
1031
|
+
var limits = buildLimitsFromPreset();
|
|
1032
|
+
|
|
1033
|
+
API.createUser({ name: name, slug: slug, limits: limits })
|
|
1034
|
+
.then(function (result) {
|
|
1035
|
+
closeModal();
|
|
1036
|
+
showToast('User Created', name + ' has been added', 'success');
|
|
1037
|
+
|
|
1038
|
+
// Show install command modal
|
|
1039
|
+
var installCode = result.install_code;
|
|
1040
|
+
var serverUrl = location.protocol + '//' + location.host;
|
|
1041
|
+
var installCmd = 'sudo npx claude-code-limiter setup --code ' + installCode + ' --server ' + serverUrl;
|
|
1042
|
+
|
|
1043
|
+
var installHtml = '';
|
|
1044
|
+
installHtml += '<div class="modal-header"><h3>Install Command</h3><button class="modal-close" data-action="close-modal">×</button></div>';
|
|
1045
|
+
installHtml += '<div class="modal-body">';
|
|
1046
|
+
installHtml += '<p class="mb-2">Run this command on <strong>' + escapeHtml(name) + '</strong>\'s machine to set up rate limiting:</p>';
|
|
1047
|
+
installHtml += '<div class="install-box">';
|
|
1048
|
+
installHtml += '<code>' + escapeHtml(installCmd) + '</code>';
|
|
1049
|
+
installHtml += '<button class="btn btn-sm btn-primary" data-action="copy-install" data-command="' + escapeHtml(installCmd) + '">Copy</button>';
|
|
1050
|
+
installHtml += '</div>';
|
|
1051
|
+
installHtml += '<p class="form-hint mt-2">This code can only be used once. Generate a new one from the user detail page if needed.</p>';
|
|
1052
|
+
installHtml += '</div>';
|
|
1053
|
+
installHtml += '<div class="modal-footer"><button class="btn btn-primary" data-action="close-modal">Done</button></div>';
|
|
1054
|
+
|
|
1055
|
+
openModal(installHtml);
|
|
1056
|
+
|
|
1057
|
+
// Refresh overview
|
|
1058
|
+
var route = getRoute();
|
|
1059
|
+
if (route.page === 'overview' || route.page === 'users') {
|
|
1060
|
+
renderPage(route);
|
|
1061
|
+
}
|
|
1062
|
+
})
|
|
1063
|
+
.catch(function (err) {
|
|
1064
|
+
showToast('Error', err.message, 'error');
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/* ================================================================
|
|
1069
|
+
MODAL: EDIT LIMITS
|
|
1070
|
+
================================================================ */
|
|
1071
|
+
function openEditLimitsModal(userId) {
|
|
1072
|
+
API.getUser(userId).then(function (user) {
|
|
1073
|
+
var limits = user.limits || [];
|
|
1074
|
+
var creditRule = getCreditRule(limits);
|
|
1075
|
+
var creditVal = creditRule ? creditRule.value : '';
|
|
1076
|
+
var creditWindow = creditRule ? creditRule.window : 'daily';
|
|
1077
|
+
|
|
1078
|
+
var html = '';
|
|
1079
|
+
html += '<div class="modal-header"><h3>Edit Limits: ' + escapeHtml(user.name) + '</h3><button class="modal-close" data-action="close-modal">×</button></div>';
|
|
1080
|
+
html += '<div class="modal-body">';
|
|
1081
|
+
|
|
1082
|
+
// Credit budget
|
|
1083
|
+
html += '<div class="form-group"><label>Credit Budget</label>';
|
|
1084
|
+
html += '<div class="form-row">';
|
|
1085
|
+
html += '<div class="form-group"><label class="text-sm">Value (-1 = unlimited, 0 = blocked)</label>';
|
|
1086
|
+
html += '<input type="number" id="edit-credits" min="-1" value="' + (creditVal !== '' ? creditVal : '-1') + '"></div>';
|
|
1087
|
+
html += '<div class="form-group"><label class="text-sm">Window</label>';
|
|
1088
|
+
html += '<select id="edit-credits-window">';
|
|
1089
|
+
var windows = ['daily', 'weekly', 'monthly', 'sliding_24h'];
|
|
1090
|
+
for (var wi = 0; wi < windows.length; wi++) {
|
|
1091
|
+
html += '<option value="' + windows[wi] + '"' + (creditWindow === windows[wi] ? ' selected' : '') + '>' + windows[wi] + '</option>';
|
|
1092
|
+
}
|
|
1093
|
+
html += '</select></div>';
|
|
1094
|
+
html += '</div></div>';
|
|
1095
|
+
|
|
1096
|
+
html += '<hr class="section-divider">';
|
|
1097
|
+
|
|
1098
|
+
// Per-model limits
|
|
1099
|
+
html += '<h4 class="mb-1">Per-Model Limits (Daily)</h4>';
|
|
1100
|
+
html += '<p class="form-hint mb-2">-1 = unlimited, 0 = blocked</p>';
|
|
1101
|
+
var models = ['opus', 'sonnet', 'haiku'];
|
|
1102
|
+
html += '<div class="form-row-3">';
|
|
1103
|
+
for (var mi = 0; mi < models.length; mi++) {
|
|
1104
|
+
var m = models[mi];
|
|
1105
|
+
var mVal = getModelLimit(limits, m, 'daily');
|
|
1106
|
+
html += '<div class="form-group"><label>' + m.charAt(0).toUpperCase() + m.slice(1) + '</label>';
|
|
1107
|
+
html += '<input type="number" id="edit-' + m + '" min="-1" value="' + mVal + '"></div>';
|
|
1108
|
+
}
|
|
1109
|
+
html += '</div>';
|
|
1110
|
+
|
|
1111
|
+
// Time-of-day rules
|
|
1112
|
+
html += '<hr class="section-divider">';
|
|
1113
|
+
html += '<h4 class="mb-1">Time-of-Day Restrictions</h4>';
|
|
1114
|
+
var timeRules = limits.filter(function (r) { return r.type === 'time_of_day'; });
|
|
1115
|
+
html += '<div id="time-rules-list">';
|
|
1116
|
+
if (timeRules.length > 0) {
|
|
1117
|
+
for (var ti = 0; ti < timeRules.length; ti++) {
|
|
1118
|
+
html += renderTimeRuleRow(ti, timeRules[ti]);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
html += '</div>';
|
|
1122
|
+
html += '<button class="btn btn-sm mt-1" data-action="add-time-rule">+ Add Time Rule</button>';
|
|
1123
|
+
|
|
1124
|
+
html += '</div>'; // modal-body
|
|
1125
|
+
html += '<div class="modal-footer">';
|
|
1126
|
+
html += '<button class="btn btn-ghost" data-action="close-modal">Cancel</button>';
|
|
1127
|
+
html += '<button class="btn btn-primary" data-action="submit-edit-limits" data-user-id="' + userId + '">Save Limits</button>';
|
|
1128
|
+
html += '</div>';
|
|
1129
|
+
|
|
1130
|
+
openModal(html);
|
|
1131
|
+
}).catch(function (err) {
|
|
1132
|
+
showToast('Error', err.message, 'error');
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
var timeRuleIndex = 0;
|
|
1137
|
+
|
|
1138
|
+
function renderTimeRuleRow(idx, rule) {
|
|
1139
|
+
rule = rule || {};
|
|
1140
|
+
return (
|
|
1141
|
+
'<div class="form-row-3 mb-1" id="time-rule-' + idx + '">' +
|
|
1142
|
+
'<div class="form-group"><label class="text-sm">Model</label>' +
|
|
1143
|
+
'<select class="time-rule-model">' +
|
|
1144
|
+
'<option value="opus"' + (rule.model === 'opus' ? ' selected' : '') + '>Opus</option>' +
|
|
1145
|
+
'<option value="sonnet"' + (rule.model === 'sonnet' ? ' selected' : '') + '>Sonnet</option>' +
|
|
1146
|
+
'<option value="haiku"' + (rule.model === 'haiku' ? ' selected' : '') + '>Haiku</option>' +
|
|
1147
|
+
'</select></div>' +
|
|
1148
|
+
'<div class="form-group"><label class="text-sm">Start - End</label>' +
|
|
1149
|
+
'<div style="display:flex;gap:0.3rem;align-items:center">' +
|
|
1150
|
+
'<input type="time" class="time-rule-start" value="' + (rule.schedule_start || '09:00') + '" style="flex:1">' +
|
|
1151
|
+
'<span class="text-muted">-</span>' +
|
|
1152
|
+
'<input type="time" class="time-rule-end" value="' + (rule.schedule_end || '18:00') + '" style="flex:1">' +
|
|
1153
|
+
'</div></div>' +
|
|
1154
|
+
'<div class="form-group"><label class="text-sm">Timezone</label>' +
|
|
1155
|
+
'<input type="text" class="time-rule-tz" value="' + escapeHtml(rule.schedule_tz || Intl.DateTimeFormat().resolvedOptions().timeZone) + '" placeholder="America/New_York"></div>' +
|
|
1156
|
+
'</div>'
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function addTimeRuleRow() {
|
|
1161
|
+
var list = document.getElementById('time-rules-list');
|
|
1162
|
+
if (!list) return;
|
|
1163
|
+
timeRuleIndex++;
|
|
1164
|
+
var div = document.createElement('div');
|
|
1165
|
+
div.innerHTML = renderTimeRuleRow(timeRuleIndex, {});
|
|
1166
|
+
list.appendChild(div.firstElementChild);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function submitEditLimits(userId) {
|
|
1170
|
+
var limits = [];
|
|
1171
|
+
|
|
1172
|
+
// Credits
|
|
1173
|
+
var creditsVal = parseInt(document.getElementById('edit-credits').value, 10);
|
|
1174
|
+
var creditsWindow = document.getElementById('edit-credits-window').value;
|
|
1175
|
+
if (!isNaN(creditsVal) && creditsVal !== -1) {
|
|
1176
|
+
limits.push({ type: 'credits', window: creditsWindow, value: creditsVal });
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// Per-model
|
|
1180
|
+
var models = ['opus', 'sonnet', 'haiku'];
|
|
1181
|
+
for (var i = 0; i < models.length; i++) {
|
|
1182
|
+
var val = parseInt(document.getElementById('edit-' + models[i]).value, 10);
|
|
1183
|
+
if (!isNaN(val) && val !== -1) {
|
|
1184
|
+
limits.push({ type: 'per_model', model: models[i], window: 'daily', value: val });
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// Time-of-day rules
|
|
1189
|
+
var modelEls = document.querySelectorAll('.time-rule-model');
|
|
1190
|
+
var startEls = document.querySelectorAll('.time-rule-start');
|
|
1191
|
+
var endEls = document.querySelectorAll('.time-rule-end');
|
|
1192
|
+
var tzEls = document.querySelectorAll('.time-rule-tz');
|
|
1193
|
+
for (var t = 0; t < modelEls.length; t++) {
|
|
1194
|
+
limits.push({
|
|
1195
|
+
type: 'time_of_day',
|
|
1196
|
+
model: modelEls[t].value,
|
|
1197
|
+
schedule_start: startEls[t].value,
|
|
1198
|
+
schedule_end: endEls[t].value,
|
|
1199
|
+
schedule_tz: tzEls[t].value,
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
API.updateUser(userId, { limits: limits })
|
|
1204
|
+
.then(function () {
|
|
1205
|
+
closeModal();
|
|
1206
|
+
showToast('Limits Updated', 'Limits have been saved', 'success');
|
|
1207
|
+
renderPage(getRoute());
|
|
1208
|
+
})
|
|
1209
|
+
.catch(function (err) {
|
|
1210
|
+
showToast('Error', err.message, 'error');
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/* ================================================================
|
|
1215
|
+
USER ACTIONS
|
|
1216
|
+
================================================================ */
|
|
1217
|
+
function pauseUser(userId, userName) {
|
|
1218
|
+
confirmDialog('Pause User', 'Pause ' + userName + '? They will not be able to use Claude Code until reinstated.', 'Pause')
|
|
1219
|
+
.then(function (ok) {
|
|
1220
|
+
if (!ok) return;
|
|
1221
|
+
return API.updateUser(userId, { status: 'paused' }).then(function () {
|
|
1222
|
+
showToast('User Paused', userName + ' has been paused', 'warning');
|
|
1223
|
+
renderPage(getRoute());
|
|
1224
|
+
});
|
|
1225
|
+
})
|
|
1226
|
+
.catch(function (err) { if (err) showToast('Error', err.message, 'error'); });
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
function killUser(userId, userName) {
|
|
1230
|
+
confirmDialog('Kill User', 'Kill ' + userName + '? This will revoke their Claude Code access and log them out. They will need manual re-setup to restore access.', 'Kill')
|
|
1231
|
+
.then(function (ok) {
|
|
1232
|
+
if (!ok) return;
|
|
1233
|
+
return API.updateUser(userId, { status: 'killed' }).then(function () {
|
|
1234
|
+
showToast('User Killed', userName + ' has been killed. Their session will be revoked.', 'error');
|
|
1235
|
+
renderPage(getRoute());
|
|
1236
|
+
});
|
|
1237
|
+
})
|
|
1238
|
+
.catch(function (err) { if (err) showToast('Error', err.message, 'error'); });
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
function reinstateUser(userId, userName) {
|
|
1242
|
+
API.updateUser(userId, { status: 'active' })
|
|
1243
|
+
.then(function () {
|
|
1244
|
+
showToast('User Reinstated', userName + ' is now active', 'success');
|
|
1245
|
+
renderPage(getRoute());
|
|
1246
|
+
})
|
|
1247
|
+
.catch(function (err) { showToast('Error', err.message, 'error'); });
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
function deleteUser(userId, userName) {
|
|
1251
|
+
confirmDialog('Delete User', 'Permanently delete ' + userName + '? This removes all their usage data and cannot be undone.', 'Delete')
|
|
1252
|
+
.then(function (ok) {
|
|
1253
|
+
if (!ok) return;
|
|
1254
|
+
return API.deleteUser(userId).then(function () {
|
|
1255
|
+
showToast('User Deleted', userName + ' has been removed', 'success');
|
|
1256
|
+
navigate('overview');
|
|
1257
|
+
});
|
|
1258
|
+
})
|
|
1259
|
+
.catch(function (err) { if (err) showToast('Error', err.message, 'error'); });
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/* ================================================================
|
|
1263
|
+
SETTINGS ACTIONS
|
|
1264
|
+
================================================================ */
|
|
1265
|
+
function saveTeamName() {
|
|
1266
|
+
var name = (document.getElementById('settings-team-name').value || '').trim();
|
|
1267
|
+
if (!name) { showToast('Error', 'Team name cannot be empty', 'error'); return; }
|
|
1268
|
+
API.updateSettings({ name: name })
|
|
1269
|
+
.then(function (data) {
|
|
1270
|
+
state.team = data.team;
|
|
1271
|
+
updateTeamNameDisplay();
|
|
1272
|
+
showToast('Saved', 'Team name updated', 'success');
|
|
1273
|
+
})
|
|
1274
|
+
.catch(function (err) { showToast('Error', err.message, 'error'); });
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
function saveWeights() {
|
|
1278
|
+
var cw = {};
|
|
1279
|
+
var models = ['opus', 'sonnet', 'haiku'];
|
|
1280
|
+
for (var i = 0; i < models.length; i++) {
|
|
1281
|
+
var el = document.getElementById('weight-' + models[i]);
|
|
1282
|
+
cw[models[i]] = parseInt(el.value, 10) || 0;
|
|
1283
|
+
}
|
|
1284
|
+
API.updateSettings({ credit_weights: cw })
|
|
1285
|
+
.then(function (data) {
|
|
1286
|
+
state.team = data.team;
|
|
1287
|
+
showToast('Saved', 'Credit weights updated', 'success');
|
|
1288
|
+
})
|
|
1289
|
+
.catch(function (err) { showToast('Error', err.message, 'error'); });
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function changePassword() {
|
|
1293
|
+
var newPw = (document.getElementById('settings-new-pw').value || '');
|
|
1294
|
+
var confirmPw = (document.getElementById('settings-confirm-pw').value || '');
|
|
1295
|
+
if (!newPw) { showToast('Error', 'Password cannot be empty', 'error'); return; }
|
|
1296
|
+
if (newPw !== confirmPw) { showToast('Error', 'Passwords do not match', 'error'); return; }
|
|
1297
|
+
if (newPw.length < 6) { showToast('Error', 'Password must be at least 6 characters', 'error'); return; }
|
|
1298
|
+
|
|
1299
|
+
API.updateSettings({ admin_password: newPw })
|
|
1300
|
+
.then(function () {
|
|
1301
|
+
showToast('Saved', 'Admin password changed. You will need to log in again.', 'success');
|
|
1302
|
+
document.getElementById('settings-new-pw').value = '';
|
|
1303
|
+
document.getElementById('settings-confirm-pw').value = '';
|
|
1304
|
+
})
|
|
1305
|
+
.catch(function (err) { showToast('Error', err.message, 'error'); });
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/* ================================================================
|
|
1309
|
+
AUTO-REFRESH
|
|
1310
|
+
================================================================ */
|
|
1311
|
+
function startAutoRefresh() {
|
|
1312
|
+
stopAutoRefresh();
|
|
1313
|
+
state.refreshTimer = setInterval(function () {
|
|
1314
|
+
var route = getRoute();
|
|
1315
|
+
if (route.page === 'overview' || route.page === 'users') {
|
|
1316
|
+
silentRefreshOverview();
|
|
1317
|
+
}
|
|
1318
|
+
}, 30000);
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
function stopAutoRefresh() {
|
|
1322
|
+
if (state.refreshTimer) {
|
|
1323
|
+
clearInterval(state.refreshTimer);
|
|
1324
|
+
state.refreshTimer = null;
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
/** Refresh overview data without full re-render (avoids flicker). */
|
|
1329
|
+
function silentRefreshOverview() {
|
|
1330
|
+
API.getUsers().then(function (data) {
|
|
1331
|
+
state.users = data.users;
|
|
1332
|
+
// Only re-render user cards if we are on overview
|
|
1333
|
+
var route = getRoute();
|
|
1334
|
+
if (route.page === 'overview' || route.page === 'users') {
|
|
1335
|
+
var grid = document.querySelector('.user-grid');
|
|
1336
|
+
if (grid) {
|
|
1337
|
+
var html = '';
|
|
1338
|
+
for (var i = 0; i < data.users.length; i++) {
|
|
1339
|
+
html += renderUserCard(data.users[i]);
|
|
1340
|
+
}
|
|
1341
|
+
grid.innerHTML = html;
|
|
1342
|
+
}
|
|
1343
|
+
// Update stats
|
|
1344
|
+
updateOverviewStats(data.users);
|
|
1345
|
+
}
|
|
1346
|
+
}).catch(function () {
|
|
1347
|
+
// Silently ignore refresh errors
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function updateOverviewStats(users) {
|
|
1352
|
+
var statCards = document.querySelectorAll('.stat-card');
|
|
1353
|
+
if (statCards.length < 4) return;
|
|
1354
|
+
var totalUsers = users.length;
|
|
1355
|
+
var activeUsers = users.filter(function (u) { return u.status === 'active'; }).length;
|
|
1356
|
+
var pausedUsers = users.filter(function (u) { return u.status === 'paused'; }).length;
|
|
1357
|
+
var killedUsers = users.filter(function (u) { return u.status === 'killed'; }).length;
|
|
1358
|
+
|
|
1359
|
+
var vals = statCards[0].querySelector('.stat-value');
|
|
1360
|
+
if (vals) vals.textContent = totalUsers;
|
|
1361
|
+
vals = statCards[1].querySelector('.stat-value');
|
|
1362
|
+
if (vals) vals.textContent = activeUsers;
|
|
1363
|
+
vals = statCards[2].querySelector('.stat-value');
|
|
1364
|
+
if (vals) vals.textContent = pausedUsers;
|
|
1365
|
+
vals = statCards[3].querySelector('.stat-value');
|
|
1366
|
+
if (vals) vals.textContent = killedUsers;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
function updateTeamNameDisplay() {
|
|
1370
|
+
var el = document.getElementById('sidebar-team-name');
|
|
1371
|
+
if (el && state.team) el.textContent = state.team.name;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/* ================================================================
|
|
1375
|
+
LOGIN
|
|
1376
|
+
================================================================ */
|
|
1377
|
+
function handleLogin(e) {
|
|
1378
|
+
e.preventDefault();
|
|
1379
|
+
var pw = document.getElementById('login-password').value;
|
|
1380
|
+
var errEl = document.getElementById('login-error');
|
|
1381
|
+
var btn = document.getElementById('login-btn');
|
|
1382
|
+
|
|
1383
|
+
if (!pw) { errEl.textContent = 'Password is required'; errEl.classList.remove('hidden'); return; }
|
|
1384
|
+
|
|
1385
|
+
btn.disabled = true;
|
|
1386
|
+
btn.textContent = 'Signing in...';
|
|
1387
|
+
errEl.classList.add('hidden');
|
|
1388
|
+
|
|
1389
|
+
API.login(pw)
|
|
1390
|
+
.then(function (data) {
|
|
1391
|
+
state.token = data.token;
|
|
1392
|
+
state.team = data.team;
|
|
1393
|
+
localStorage.setItem('clm_token', data.token);
|
|
1394
|
+
updateTeamNameDisplay();
|
|
1395
|
+
navigate('overview');
|
|
1396
|
+
})
|
|
1397
|
+
.catch(function (err) {
|
|
1398
|
+
errEl.textContent = err.message || 'Login failed';
|
|
1399
|
+
errEl.classList.remove('hidden');
|
|
1400
|
+
})
|
|
1401
|
+
.finally(function () {
|
|
1402
|
+
btn.disabled = false;
|
|
1403
|
+
btn.textContent = 'Sign In';
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
function handleLogout() {
|
|
1408
|
+
state.token = null;
|
|
1409
|
+
state.team = null;
|
|
1410
|
+
localStorage.removeItem('clm_token');
|
|
1411
|
+
stopAutoRefresh();
|
|
1412
|
+
WS.disconnect();
|
|
1413
|
+
navigate('login');
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
/* ================================================================
|
|
1417
|
+
MOBILE SIDEBAR
|
|
1418
|
+
================================================================ */
|
|
1419
|
+
function openMobileSidebar() {
|
|
1420
|
+
document.getElementById('sidebar').classList.add('open');
|
|
1421
|
+
document.getElementById('sidebar-overlay').classList.remove('hidden');
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
function closeMobileSidebar() {
|
|
1425
|
+
document.getElementById('sidebar').classList.remove('open');
|
|
1426
|
+
document.getElementById('sidebar-overlay').classList.add('hidden');
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
/* ================================================================
|
|
1430
|
+
EVENT DELEGATION
|
|
1431
|
+
================================================================ */
|
|
1432
|
+
function setupEventDelegation() {
|
|
1433
|
+
document.addEventListener('click', function (e) {
|
|
1434
|
+
var target = e.target.closest('[data-action]');
|
|
1435
|
+
if (!target) return;
|
|
1436
|
+
|
|
1437
|
+
var action = target.getAttribute('data-action');
|
|
1438
|
+
var userId = target.getAttribute('data-user-id');
|
|
1439
|
+
var userName = target.getAttribute('data-user-name');
|
|
1440
|
+
|
|
1441
|
+
switch (action) {
|
|
1442
|
+
case 'view-user':
|
|
1443
|
+
e.preventDefault();
|
|
1444
|
+
if (userId) navigate('user', { id: userId });
|
|
1445
|
+
break;
|
|
1446
|
+
|
|
1447
|
+
case 'add-user':
|
|
1448
|
+
openAddUserModal();
|
|
1449
|
+
break;
|
|
1450
|
+
|
|
1451
|
+
case 'submit-add-user':
|
|
1452
|
+
submitAddUser();
|
|
1453
|
+
break;
|
|
1454
|
+
|
|
1455
|
+
case 'pause-user':
|
|
1456
|
+
e.stopPropagation();
|
|
1457
|
+
if (userId) pauseUser(userId, userName);
|
|
1458
|
+
break;
|
|
1459
|
+
|
|
1460
|
+
case 'kill-user':
|
|
1461
|
+
e.stopPropagation();
|
|
1462
|
+
if (userId) killUser(userId, userName);
|
|
1463
|
+
break;
|
|
1464
|
+
|
|
1465
|
+
case 'reinstate-user':
|
|
1466
|
+
e.stopPropagation();
|
|
1467
|
+
if (userId) reinstateUser(userId, userName);
|
|
1468
|
+
break;
|
|
1469
|
+
|
|
1470
|
+
case 'delete-user':
|
|
1471
|
+
if (userId) deleteUser(userId, userName);
|
|
1472
|
+
break;
|
|
1473
|
+
|
|
1474
|
+
case 'edit-limits':
|
|
1475
|
+
if (userId) openEditLimitsModal(userId);
|
|
1476
|
+
break;
|
|
1477
|
+
|
|
1478
|
+
case 'submit-edit-limits':
|
|
1479
|
+
submitEditLimits(userId);
|
|
1480
|
+
break;
|
|
1481
|
+
|
|
1482
|
+
case 'add-time-rule':
|
|
1483
|
+
addTimeRuleRow();
|
|
1484
|
+
break;
|
|
1485
|
+
|
|
1486
|
+
case 'close-modal':
|
|
1487
|
+
closeModal();
|
|
1488
|
+
break;
|
|
1489
|
+
|
|
1490
|
+
case 'copy-install':
|
|
1491
|
+
var cmd = target.getAttribute('data-command');
|
|
1492
|
+
if (cmd) copyToClipboard(cmd);
|
|
1493
|
+
break;
|
|
1494
|
+
|
|
1495
|
+
case 'save-team-name':
|
|
1496
|
+
saveTeamName();
|
|
1497
|
+
break;
|
|
1498
|
+
|
|
1499
|
+
case 'save-weights':
|
|
1500
|
+
saveWeights();
|
|
1501
|
+
break;
|
|
1502
|
+
|
|
1503
|
+
case 'change-password':
|
|
1504
|
+
changePassword();
|
|
1505
|
+
break;
|
|
1506
|
+
}
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
// Preset buttons (inside modal)
|
|
1510
|
+
document.addEventListener('click', function (e) {
|
|
1511
|
+
var presetBtn = e.target.closest('.preset-btn');
|
|
1512
|
+
if (!presetBtn) return;
|
|
1513
|
+
var preset = presetBtn.getAttribute('data-preset');
|
|
1514
|
+
if (preset) selectPreset(preset);
|
|
1515
|
+
});
|
|
1516
|
+
|
|
1517
|
+
// Modal overlay click-outside to close
|
|
1518
|
+
document.getElementById('modal-overlay').addEventListener('click', function (e) {
|
|
1519
|
+
if (e.target === this) closeModal();
|
|
1520
|
+
});
|
|
1521
|
+
|
|
1522
|
+
document.getElementById('confirm-overlay').addEventListener('click', function (e) {
|
|
1523
|
+
if (e.target === this) {
|
|
1524
|
+
document.getElementById('confirm-cancel').click();
|
|
1525
|
+
}
|
|
1526
|
+
});
|
|
1527
|
+
|
|
1528
|
+
// Login form
|
|
1529
|
+
document.getElementById('login-form').addEventListener('submit', handleLogin);
|
|
1530
|
+
|
|
1531
|
+
// Logout
|
|
1532
|
+
document.getElementById('logout-btn').addEventListener('click', handleLogout);
|
|
1533
|
+
|
|
1534
|
+
// Hamburger
|
|
1535
|
+
document.getElementById('hamburger-btn').addEventListener('click', function () {
|
|
1536
|
+
var sidebar = document.getElementById('sidebar');
|
|
1537
|
+
if (sidebar.classList.contains('open')) {
|
|
1538
|
+
closeMobileSidebar();
|
|
1539
|
+
} else {
|
|
1540
|
+
openMobileSidebar();
|
|
1541
|
+
}
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
// Sidebar overlay
|
|
1545
|
+
document.getElementById('sidebar-overlay').addEventListener('click', closeMobileSidebar);
|
|
1546
|
+
|
|
1547
|
+
// Hash change
|
|
1548
|
+
window.addEventListener('hashchange', handleRoute);
|
|
1549
|
+
|
|
1550
|
+
// Keyboard: Escape closes modals
|
|
1551
|
+
document.addEventListener('keydown', function (e) {
|
|
1552
|
+
if (e.key === 'Escape') {
|
|
1553
|
+
var modalOverlay = document.getElementById('modal-overlay');
|
|
1554
|
+
if (!modalOverlay.classList.contains('hidden')) {
|
|
1555
|
+
closeModal();
|
|
1556
|
+
return;
|
|
1557
|
+
}
|
|
1558
|
+
var confirmOverlay = document.getElementById('confirm-overlay');
|
|
1559
|
+
if (!confirmOverlay.classList.contains('hidden')) {
|
|
1560
|
+
document.getElementById('confirm-cancel').click();
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
});
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
/* ================================================================
|
|
1567
|
+
WEBSOCKET EVENT HANDLING
|
|
1568
|
+
================================================================ */
|
|
1569
|
+
function setupWSListeners() {
|
|
1570
|
+
WS.onEvent(function (event) {
|
|
1571
|
+
if (!event || !event.type) return;
|
|
1572
|
+
|
|
1573
|
+
// Handle live feed events
|
|
1574
|
+
var feed = wsEventToFeed(event);
|
|
1575
|
+
if (feed) {
|
|
1576
|
+
addLiveFeedItem(feed);
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
// Show toast for blocked events
|
|
1580
|
+
if (event.type === 'user_blocked') {
|
|
1581
|
+
showToast(
|
|
1582
|
+
'User Blocked',
|
|
1583
|
+
(event.userName || 'Unknown') + ' was blocked on ' + (event.model || 'unknown'),
|
|
1584
|
+
'warning',
|
|
1585
|
+
8000
|
|
1586
|
+
);
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
// Show toast for kill events
|
|
1590
|
+
if (event.type === 'user_killed' || (event.type === 'user_status_change' && event.newStatus === 'killed')) {
|
|
1591
|
+
showToast(
|
|
1592
|
+
'User Killed',
|
|
1593
|
+
(event.userName || 'Unknown') + ' has been killed',
|
|
1594
|
+
'error',
|
|
1595
|
+
8000
|
|
1596
|
+
);
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
// Refresh user cards on status changes
|
|
1600
|
+
if (event.type === 'user_status_change' || event.type === 'user_killed' || event.type === 'user_counted') {
|
|
1601
|
+
silentRefreshOverview();
|
|
1602
|
+
}
|
|
1603
|
+
});
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
/* ================================================================
|
|
1607
|
+
INIT
|
|
1608
|
+
================================================================ */
|
|
1609
|
+
function init() {
|
|
1610
|
+
setupEventDelegation();
|
|
1611
|
+
setupWSListeners();
|
|
1612
|
+
|
|
1613
|
+
// If we have a token, try to load team info
|
|
1614
|
+
if (state.token) {
|
|
1615
|
+
API.getUsers().then(function (data) {
|
|
1616
|
+
state.users = data.users;
|
|
1617
|
+
// Try to get team info from login endpoint's stored state
|
|
1618
|
+
// We do not have a dedicated /team endpoint, so we store it on login
|
|
1619
|
+
var storedTeam = localStorage.getItem('clm_team');
|
|
1620
|
+
if (storedTeam) {
|
|
1621
|
+
try { state.team = JSON.parse(storedTeam); } catch (e) { /* ignore */ }
|
|
1622
|
+
}
|
|
1623
|
+
updateTeamNameDisplay();
|
|
1624
|
+
}).catch(function () {
|
|
1625
|
+
// Token might be expired
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// Route
|
|
1630
|
+
handleRoute();
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
// Patch login to also store team info
|
|
1634
|
+
var originalLogin = API.login;
|
|
1635
|
+
API.login = function (password) {
|
|
1636
|
+
return originalLogin(password).then(function (data) {
|
|
1637
|
+
if (data.team) {
|
|
1638
|
+
localStorage.setItem('clm_team', JSON.stringify(data.team));
|
|
1639
|
+
}
|
|
1640
|
+
return data;
|
|
1641
|
+
});
|
|
1642
|
+
};
|
|
1643
|
+
|
|
1644
|
+
// Start when DOM is ready
|
|
1645
|
+
if (document.readyState === 'loading') {
|
|
1646
|
+
document.addEventListener('DOMContentLoaded', init);
|
|
1647
|
+
} else {
|
|
1648
|
+
init();
|
|
1649
|
+
}
|
|
1650
|
+
})();
|