@@ -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 ────────────────────────────────────────────────
85269async function loadOverview ( ) {
86270 const [ metrics , analytics ] = await Promise . all ( [
0 commit comments