Skip to content
This repository was archived by the owner on Jun 3, 2026. It is now read-only.

Commit 6832acb

Browse files
committed
add user endpoints for admin dashboard
1 parent d1e7d94 commit 6832acb

4 files changed

Lines changed: 479 additions & 1 deletion

File tree

‎admin/app.js‎

Lines changed: 185 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,14 @@ function navigate(page) {
4949
if (nav) nav.classList.add('active');
5050

5151
// Update page title
52-
const titles = { overview:'Overview', logs:'Live Terminal', llm:'LLM & Costs', github:'GitHub Traffic' };
52+
const titles = { overview:'Overview', logs:'Live Terminal', llm:'LLM & Costs', github:'GitHub Traffic', users:'Users' };
5353
const titleEl = document.getElementById('page-title');
5454
if (titleEl) titleEl.textContent = titles[page] || page;
5555

5656
if (page === 'overview') loadOverview();
5757
if (page === 'llm') loadLLM();
5858
if (page === 'github') loadGitHub();
59+
if (page === 'users') loadUsers();
5960
if (page === 'logs' && !_sseSource) connectSystemLogs();
6061
}
6162

@@ -78,9 +79,192 @@ async function loadAll() {
7879
setInterval(() => {
7980
if (currentPage === 'overview') loadOverview();
8081
if (currentPage === 'llm') loadLLM();
82+
if (currentPage === 'users') loadUsers();
8183
}, 30000);
8284
}
8385

86+
// ── Users page ──────────────────────────────────────────────────
87+
let _usersData = [];
88+
let _usersFilter = '';
89+
90+
async function loadUsers() {
91+
const data = await apiFetch('/users');
92+
if (!data || data.error) {
93+
document.getElementById('users-table-body').innerHTML = `<tr><td colspan="7" class="empty-state">${data?.error || 'Failed to load users'}</td></tr>`;
94+
return;
95+
}
96+
97+
_usersData = data.users || [];
98+
99+
// Update summary cards
100+
document.getElementById('users-total').textContent = data.total_users || 0;
101+
102+
const totalKeys = _usersData.reduce((sum, u) => sum + (u.api_key_count || 0), 0);
103+
document.getElementById('users-total-keys').textContent = totalKeys;
104+
105+
// Count active today (last_login within 24h)
106+
const now = new Date();
107+
const activeToday = _usersData.filter(u => {
108+
if (!u.last_login) return false;
109+
const lastLogin = new Date(u.last_login);
110+
return (now - lastLogin) < 24 * 60 * 60 * 1000;
111+
}).length;
112+
document.getElementById('users-active-today').textContent = activeToday;
113+
114+
renderUsersTable();
115+
}
116+
117+
function renderUsersTable() {
118+
const tbody = document.getElementById('users-table-body');
119+
120+
let users = _usersData;
121+
if (_usersFilter) {
122+
const f = _usersFilter.toLowerCase();
123+
users = users.filter(u =>
124+
(u.name || '').toLowerCase().includes(f) ||
125+
(u.email || '').toLowerCase().includes(f) ||
126+
(u.username || '').toLowerCase().includes(f)
127+
);
128+
}
129+
130+
if (users.length === 0) {
131+
tbody.innerHTML = '<tr><td colspan="7" class="empty-state">No users found</td></tr>';
132+
return;
133+
}
134+
135+
const rows = users.map(u => {
136+
const avatar = u.picture
137+
? `<img src="${escHtml(u.picture)}" alt="" class="user-avatar">`
138+
: `<div class="user-avatar-placeholder">${(u.name || '?').charAt(0).toUpperCase()}</div>`;
139+
const username = u.username ? `@${escHtml(u.username)}` : '<span style="color:var(--text3)">—</span>';
140+
const created = u.created_at ? formatDateShort(u.created_at) : '—';
141+
const lastLogin = u.last_login ? formatDateShort(u.last_login) : '—';
142+
143+
return `<tr>
144+
<td>
145+
<div class="user-cell">
146+
${avatar}
147+
<div class="user-info">
148+
<div class="user-name">${escHtml(u.name || 'Unknown')}</div>
149+
<div class="user-email">${escHtml(u.email || '')}</div>
150+
</div>
151+
</div>
152+
</td>
153+
<td>${escHtml(u.email || '—')}</td>
154+
<td>${username}</td>
155+
<td>${created}</td>
156+
<td>${lastLogin}</td>
157+
<td>${u.api_key_count || 0}</td>
158+
<td>
159+
<button class="action-btn" onclick="showUserTrailModal('${escHtml(u.id)}')">
160+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
161+
View Trail
162+
</button>
163+
</td>
164+
</tr>`;
165+
}).join('');
166+
167+
tbody.innerHTML = rows;
168+
}
169+
170+
function filterUsers(query) {
171+
_usersFilter = query;
172+
renderUsersTable();
173+
}
174+
175+
async function showUserTrailModal(userId) {
176+
const modal = document.getElementById('user-trail-modal');
177+
const user = _usersData.find(u => u.id === userId);
178+
if (!user) return;
179+
180+
// Show modal with loading state
181+
modal.style.display = 'flex';
182+
document.getElementById('trail-modal-title').textContent = `Activity Trail: ${escHtml(user.name || user.email)}`;
183+
document.getElementById('trail-table-body').innerHTML = '<tr><td colspan="5" class="empty-state">Loading...</td></tr>';
184+
document.getElementById('trail-calls-24h').textContent = '—';
185+
document.getElementById('trail-unique-routes').textContent = '—';
186+
document.getElementById('trail-unique-paths').innerHTML = '';
187+
188+
// Update user info
189+
const avatar = user.picture
190+
? `<img src="${escHtml(user.picture)}" alt="" style="width:48px;height:48px;border-radius:50%;object-fit:cover;border:2px solid var(--border);">`
191+
: `<div style="width:48px;height:48px;border-radius:50%;background:linear-gradient(135deg,var(--blue),var(--purple));display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:600;">${(user.name || '?').charAt(0).toUpperCase()}</div>`;
192+
193+
document.getElementById('trail-user-info').innerHTML = `
194+
<div style="display:flex;align-items:center;gap:16px;">
195+
${avatar}
196+
<div>
197+
<div style="font-size:16px;font-weight:600;color:var(--text);">${escHtml(user.name || 'Unknown')}</div>
198+
<div style="font-size:13px;color:var(--text2);">${escHtml(user.email || '')}</div>
199+
${user.username ? `<div style="font-size:12px;color:var(--blue);margin-top:4px;">@${escHtml(user.username)}</div>` : ''}
200+
</div>
201+
</div>
202+
`;
203+
204+
// Fetch trail data
205+
const trailData = await apiFetch(`/users/${encodeURIComponent(userId)}/trail?hours=24&limit=50`);
206+
if (!trailData || trailData.error) {
207+
document.getElementById('trail-table-body').innerHTML = `<tr><td colspan="5" class="empty-state">${trailData?.error || 'Failed to load trail'}</td></tr>`;
208+
return;
209+
}
210+
211+
// Update stats
212+
document.getElementById('trail-calls-24h').textContent = trailData.total_calls_24h || 0;
213+
document.getElementById('trail-unique-routes').textContent = (trailData.unique_paths || []).length;
214+
215+
// Show unique paths as tags
216+
const uniquePaths = trailData.unique_paths || [];
217+
if (uniquePaths.length > 0) {
218+
document.getElementById('trail-unique-paths').innerHTML = uniquePaths.map(p =>
219+
`<span class="path-tag">${escHtml(p)}</span>`
220+
).join('');
221+
} else {
222+
document.getElementById('trail-unique-paths').innerHTML = '<span style="color:var(--text3);">No routes accessed</span>';
223+
}
224+
225+
// Render trail table
226+
const trail = trailData.trail || [];
227+
if (trail.length === 0) {
228+
document.getElementById('trail-table-body').innerHTML = '<tr><td colspan="5" class="empty-state">No activity in the last 24 hours</td></tr>';
229+
return;
230+
}
231+
232+
const rows = trail.map(t => {
233+
const ts = new Date(t.ts);
234+
const timeStr = ts.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
235+
const dateStr = ts.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
236+
const statusClass = t.status >= 400 ? 'style="color:var(--red)"' : (t.status >= 300 ? 'style="color:var(--amber)"' : 'style="color:var(--green)"');
237+
238+
return `<tr>
239+
<td title="${ts.toISOString()}">${dateStr} ${timeStr}</td>
240+
<td><span style="font-family:var(--mono);font-size:11px;padding:2px 6px;background:var(--surface2);border-radius:4px;">${t.method || 'GET'}</span></td>
241+
<td style="font-family:var(--mono);font-size:12px;">${escHtml(t.path || '/')}</td>
242+
<td ${statusClass}>${t.status || '—'}</td>
243+
<td>${Math.round(t.latency_ms || 0)}ms</td>
244+
</tr>`;
245+
}).join('');
246+
247+
document.getElementById('trail-table-body').innerHTML = rows;
248+
}
249+
250+
function closeUserTrailModal() {
251+
document.getElementById('user-trail-modal').style.display = 'none';
252+
}
253+
254+
function formatDateShort(dateStr) {
255+
if (!dateStr) return '—';
256+
const d = new Date(dateStr);
257+
const now = new Date();
258+
const diffMs = now - d;
259+
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
260+
261+
if (diffDays === 0) return 'Today';
262+
if (diffDays === 1) return 'Yesterday';
263+
if (diffDays < 7) return `${diffDays}d ago`;
264+
265+
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
266+
}
267+
84268
// ── Overview page ────────────────────────────────────────────────
85269
async function loadOverview() {
86270
const [metrics, analytics] = await Promise.all([

‎admin/index.html‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ <h1>XMem</h1>
6868
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 00-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0020 4.77 5.07 5.07 0 0019.91 1S18.73.65 16 2.48a13.38 13.38 0 00-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 005 4.77a5.44 5.44 0 00-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 009 18.13V22"/></svg>
6969
<span>GitHub</span>
7070
</div>
71+
<div class="nav-item" data-page="users" onclick="navigate('users')">
72+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/></svg>
73+
<span>Users</span>
74+
</div>
7175

7276
<div class="sidebar-footer">
7377
<div class="server-status" id="sidebar-status">
@@ -260,6 +264,108 @@ <h2 id="page-title">Overview</h2>
260264
</div>
261265
</div>
262266

267+
<!-- Users Page -->
268+
<div id="page-users" class="page">
269+
<div class="cards">
270+
<div class="card card-glow-blue">
271+
<div class="card-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/></svg></div>
272+
<div class="card-content"><div class="label">Total Users</div><div class="value" id="users-total">—</div></div>
273+
</div>
274+
<div class="card card-glow-green">
275+
<div class="card-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg></div>
276+
<div class="card-content"><div class="label">API Keys</div><div class="value" id="users-total-keys">—</div></div>
277+
</div>
278+
<div class="card card-glow-purple">
279+
<div class="card-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></div>
280+
<div class="card-content"><div class="label">Active Today</div><div class="value" id="users-active-today">—</div></div>
281+
</div>
282+
</div>
283+
284+
<div class="panel">
285+
<div class="panel-header">
286+
<h3>Users</h3>
287+
<div class="search-box" style="max-width: 300px;">
288+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
289+
<input type="text" placeholder="Search users..." oninput="filterUsers(this.value)" id="users-search-input">
290+
</div>
291+
</div>
292+
<div class="table-wrap">
293+
<table class="tbl">
294+
<thead>
295+
<tr>
296+
<th>User</th>
297+
<th>Email</th>
298+
<th>Username</th>
299+
<th>Created</th>
300+
<th>Last Login</th>
301+
<th>API Keys</th>
302+
<th>Actions</th>
303+
</tr>
304+
</thead>
305+
<tbody id="users-table-body">
306+
<tr><td colspan="7" class="empty-state">Loading...</td></tr>
307+
</tbody>
308+
</table>
309+
</div>
310+
</div>
311+
312+
<!-- User Trail Modal -->
313+
<div id="user-trail-modal" class="modal" style="display:none;">
314+
<div class="modal-backdrop" onclick="closeUserTrailModal()"></div>
315+
<div class="modal-content" style="max-width: 900px; max-height: 80vh; overflow: auto;">
316+
<div class="modal-header">
317+
<h3 id="trail-modal-title">User Activity Trail</h3>
318+
<button class="modal-close" onclick="closeUserTrailModal()">&times;</button>
319+
</div>
320+
<div class="modal-body">
321+
<div id="trail-user-info" style="margin-bottom: 16px; padding: 12px; background: rgba(59, 130, 246, 0.1); border-radius: 8px;">
322+
<!-- User info will be populated here -->
323+
</div>
324+
325+
<div style="display: flex; gap: 16px; margin-bottom: 16px;">
326+
<div class="trail-stat">
327+
<span class="trail-stat-label">Calls (24h)</span>
328+
<span class="trail-stat-value" id="trail-calls-24h">—</span>
329+
</div>
330+
<div class="trail-stat">
331+
<span class="trail-stat-label">Unique Routes</span>
332+
<span class="trail-stat-value" id="trail-unique-routes">—</span>
333+
</div>
334+
<div class="trail-stat">
335+
<span class="trail-stat-label">Period</span>
336+
<span class="trail-stat-value">24h</span>
337+
</div>
338+
</div>
339+
340+
<div style="margin-bottom: 12px;">
341+
<strong>Routes Accessed:</strong>
342+
<div id="trail-unique-paths" style="display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px;">
343+
<!-- Unique paths will be populated here -->
344+
</div>
345+
</div>
346+
347+
<div class="panel-header"><h4>Recent API Calls</h4></div>
348+
<div class="table-wrap">
349+
<table class="tbl">
350+
<thead>
351+
<tr>
352+
<th>Time</th>
353+
<th>Method</th>
354+
<th>Path</th>
355+
<th>Status</th>
356+
<th>Latency</th>
357+
</tr>
358+
</thead>
359+
<tbody id="trail-table-body">
360+
<tr><td colspan="5" class="empty-state">Loading...</td></tr>
361+
</tbody>
362+
</table>
363+
</div>
364+
</div>
365+
</div>
366+
</div>
367+
</div>
368+
263369
</div>
264370
</div>
265371

‎admin/style.css‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,3 +198,35 @@ canvas{width:100%!important;height:100%!important}
198198
.log-status-live{color:var(--green);background:rgba(16,185,129,0.1);border-color:rgba(16,185,129,0.2)}
199199
.log-status-connecting{color:var(--amber);background:rgba(245,158,11,0.1);border-color:rgba(245,158,11,0.2)}
200200
.log-status-auth-error{color:var(--red);background:rgba(239,68,68,0.1);border-color:rgba(239,68,68,0.2)}
201+
202+
/* ── Modal ──────────────────────────────────────────────── */
203+
.modal{position:fixed;top:0;left:0;width:100%;height:100%;z-index:100;display:flex;align-items:center;justify-content:center}
204+
.modal-backdrop{position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);backdrop-filter:blur(4px)}
205+
.modal-content{position:relative;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 25px 50px -12px rgba(0,0,0,0.8);z-index:101}
206+
.modal-header{display:flex;align-items:center;justify-content:space-between;padding:20px 24px;border-bottom:1px solid var(--border)}
207+
.modal-header h3{font-size:16px;font-weight:600;color:var(--text)}
208+
.modal-close{width:32px;height:32px;display:flex;align-items:center;justify-content:center;background:transparent;border:1px solid var(--border);border-radius:6px;color:var(--text2);font-size:18px;cursor:pointer;transition:var(--transition)}
209+
.modal-close:hover{border-color:var(--border2);color:var(--text);background:var(--surface2)}
210+
.modal-body{padding:24px}
211+
212+
/* ── User Row Avatar ─────────────────────────────────────── */
213+
.user-cell{display:flex;align-items:center;gap:12px}
214+
.user-avatar{width:36px;height:36px;border-radius:50%;background:var(--surface2);border:1px solid var(--border);object-fit:cover}
215+
.user-avatar-placeholder{width:36px;height:36px;border-radius:50%;background:linear-gradient(135deg, var(--blue), var(--purple));display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:600;color:var(--text)}
216+
.user-info{flex:1;min-width:0}
217+
.user-name{font-size:13px;font-weight:500;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
218+
.user-email{font-size:12px;color:var(--text3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
219+
220+
/* ── Trail Stats ─────────────────────────────────────────── */
221+
.trail-stat{display:flex;flex-direction:column;gap:4px;padding:12px 16px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);min-width:100px}
222+
.trail-stat-label{font-size:11px;color:var(--text3);text-transform:uppercase;letter-spacing:0.5px}
223+
.trail-stat-value{font-size:20px;font-weight:600;color:var(--text);font-family:var(--mono)}
224+
225+
/* ── Path Tags ───────────────────────────────────────────── */
226+
.path-tag{display:inline-flex;align-items:center;padding:4px 10px;background:var(--surface2);border:1px solid var(--border);border-radius:4px;font-size:12px;font-family:var(--mono);color:var(--text2)}
227+
.path-tag:hover{border-color:var(--accent);color:var(--accent)}
228+
229+
/* ── Action Buttons ───────────────────────────────────────── */
230+
.action-btn{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;background:var(--surface2);border:1px solid var(--border);border-radius:6px;color:var(--text2);font-size:12px;font-weight:500;cursor:pointer;transition:var(--transition)}
231+
.action-btn:hover{border-color:var(--blue);color:var(--blue);background:rgba(59,130,246,0.1)}
232+
.action-btn svg{width:14px;height:14px}

0 commit comments

Comments
 (0)