mirror of
https://github.com/sol1/rustguac.git
synced 2026-10-26 05:29:51 +00:00
6fbf590c1d
pages: bake the caller's role into the served page, version the logo
The header flashed on every navigation: gated links (Sessions, Reports,
Tokens, Admin) were hidden in the markup and shown after /api/me
answered, the logo was revalidated on each page load, and the nav
differed between pages, so switching tabs reflowed the header.
Serve each page in five variants, one per role level, that differ only
in a data-role-level attribute on <html>; optional auth now runs on the
page routes so a logged-in request gets its variant with the navigation
already right on first paint. The ETag is per variant. The logo joins
scripts and stylesheets in content-addressed, immutable caching.
Part of #254
pages: one navigation for every page, gated by role in CSS
Every page carries the same eight links in the same order, including
the Credentials entry (which opens the dialog on the connections page
and deep-links to it from elsewhere), with data-min-role on the gated
ones instead of inline display:none. rustguac.css shows them from the
data-role-level the server writes on <html>; the pages set that
attribute themselves from /api/me for API-key users, whose page GET
carries no credential. Admin-only controls follow the same gate, so
nothing pops in after the first paint. Recordings shows "host (Tenant)"
entry names as the host over a quieter tenant line.
Part of #254
tests: one navigation on every page, gated by data-role-level
(cherry picked from commit 5eed92c6ec)
502 lines
24 KiB
HTML
502 lines
24 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>rustguac - Reports</title>
|
|
<link rel="stylesheet" href="/rustguac.css">
|
|
<style>
|
|
.filter-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--s-3);
|
|
margin: var(--s-3) 0 var(--s-2) 0;
|
|
flex-wrap: wrap;
|
|
}
|
|
.filter-bar input {
|
|
width: 360px;
|
|
max-width: 100%;
|
|
}
|
|
th.sortable { cursor: pointer; user-select: none; }
|
|
th.sortable:hover { color: var(--text); }
|
|
th .sort-arrow { font-size: 0.7em; margin-left: var(--s-1); }
|
|
|
|
.panels {
|
|
display: flex;
|
|
gap: var(--s-4);
|
|
margin-top: var(--s-5);
|
|
flex-wrap: wrap;
|
|
}
|
|
.panel {
|
|
flex: 1;
|
|
min-width: 320px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
padding: var(--s-4);
|
|
}
|
|
.panel-title {
|
|
color: var(--primary);
|
|
font-weight: bold;
|
|
margin-bottom: var(--s-3);
|
|
font-size: var(--fz-sm);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.12em;
|
|
}
|
|
.panel table { width: 100%; margin-top: 0; border: none; }
|
|
.panel th, .panel td {
|
|
padding: var(--s-2) var(--s-2);
|
|
font-size: var(--fz-sm);
|
|
}
|
|
.panel th { background: transparent; padding-top: 0; }
|
|
/* Long names, emails and folder paths wrap inside their cell instead
|
|
of widening the table past the page: a dotted FQDN has no natural
|
|
break point (#252). The action buttons stay on one line. */
|
|
#history-table td { overflow-wrap: anywhere; }
|
|
#history-table td:last-child { overflow-wrap: normal; }
|
|
|
|
/* Narrow desktops (a 1710px MacBook display): the 18px table with
|
|
20px cell padding ran past the right edge. Same treatment as the
|
|
connections table: 16px cells, tighter padding, 14px headers, and
|
|
the actions kept on one line. Nothing is hidden. */
|
|
@media (max-width: 1800px) {
|
|
#history-table th, #history-table td,
|
|
#recordings-table th, #recordings-table td,
|
|
table.list th, table.list td {
|
|
padding: var(--s-2) var(--s-3);
|
|
font-size: var(--fz-sm);
|
|
}
|
|
#history-table th, #recordings-table th, table.list th { font-size: var(--fz-xs); letter-spacing: 0.08em; }
|
|
#history-table td:last-child, #recordings-table td:last-child { white-space: nowrap; }
|
|
.btn-small { padding: 0 var(--s-1); }
|
|
}
|
|
</style>
|
|
<script>(function(){var c=localStorage.getItem('rustguac_theme_colors');if(c){try{var o=JSON.parse(c),r=document.documentElement.style;for(var k in o)r.setProperty('--'+k.replace(/_/g,'-'),o[k]);if(o.bg_pattern&&o.bg_pattern!=='none'){var s=document.createElement('style');s.id='bg-pattern-style';s.textContent='body{background-image:'+o.bg_pattern+';background-attachment:fixed}';document.head.appendChild(s)}}catch(e){}}})();</script>
|
|
</head>
|
|
<body>
|
|
<div class="brand-row">
|
|
<img id="site-logo" src="/logo.svg" alt="">
|
|
<h1>rustguac</h1>
|
|
</div>
|
|
<nav>
|
|
<a href="/connections.html">Connections</a>
|
|
<a href="/sessions.html" id="sessions-link" data-min-role="poweruser">Sessions</a>
|
|
<a href="/recordings.html">Recordings</a>
|
|
<a href="/reports.html" class="active" id="reports-link" data-min-role="poweruser">Reports</a>
|
|
<a href="/docs.html">Docs</a>
|
|
<a href="/tokens.html" id="tokens-link" data-min-role="operator">Tokens</a>
|
|
<a href="/admin.html" id="admin-link" data-min-role="admin">Admin</a>
|
|
<a href="/connections.html#my-credentials" id="my-creds-nav" title="Manage your saved credential variables">🔑 Credentials</a>
|
|
<span id="user-menu-wrapper"><a href="#" id="logout-item">Logout</a><span id="user-menu-btn" title="Preferences">⚙ Settings</span><div id="user-menu"><div class="um-section-label">Theme</div><div id="um-theme-list"></div></div></span>
|
|
</nav>
|
|
|
|
<h2>Reports</h2>
|
|
|
|
<div class="summary-cards" id="summary-cards">
|
|
<div class="summary-card">
|
|
<div class="card-label">Total Sessions</div>
|
|
<div class="card-value" id="stat-total-sessions">--</div>
|
|
</div>
|
|
<div class="summary-card">
|
|
<div class="card-label">Total Hours</div>
|
|
<div class="card-value" id="stat-total-hours">--</div>
|
|
</div>
|
|
<div class="summary-card">
|
|
<div class="card-label">Unique Users</div>
|
|
<div class="card-value" id="stat-unique-users">--</div>
|
|
</div>
|
|
<div class="summary-card">
|
|
<div class="card-label">Active Now</div>
|
|
<div class="card-value" id="stat-active-now">--</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-top:2em">
|
|
<h2>Session History</h2>
|
|
<div class="filter-bar">
|
|
<input type="text" id="filter-input" placeholder="Filter sessions...">
|
|
<button id="export-csv-btn" class="btn-add">Export CSV</button>
|
|
</div>
|
|
<table id="history-table">
|
|
<thead>
|
|
<tr>
|
|
<th class="sortable" data-col="created_by">User <span class="sort-arrow"></span></th>
|
|
<th class="sortable" data-col="entry_display_name">Entry <span class="sort-arrow"></span></th>
|
|
<th class="sortable" data-col="address_book_folder">Folder <span class="sort-arrow"></span></th>
|
|
<th class="sortable" data-col="session_type">Type <span class="sort-arrow"></span></th>
|
|
<th class="sortable" data-col="hostname">Hostname <span class="sort-arrow"></span></th>
|
|
<th class="sortable" data-col="started_at">Started <span class="sort-arrow"></span></th>
|
|
<th class="sortable" data-col="duration_secs">Duration <span class="sort-arrow"></span></th>
|
|
<th class="sortable" data-col="status">Status <span class="sort-arrow"></span></th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="history-body"></tbody>
|
|
</table>
|
|
<div class="pagination" id="pagination">
|
|
<button id="prev-btn" disabled>Previous</button>
|
|
<span class="page-info" id="page-info"></span>
|
|
<button id="next-btn" disabled>Next</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panels">
|
|
<div class="panel">
|
|
<div class="panel-title">Top Connections</div>
|
|
<table>
|
|
<thead>
|
|
<tr><th>Name</th><th>Type</th><th>Sessions</th><th>Total Hours</th></tr>
|
|
</thead>
|
|
<tbody id="top-connections-body"></tbody>
|
|
</table>
|
|
<p class="empty" id="top-connections-empty" style="display:none">No data yet.</p>
|
|
</div>
|
|
<div class="panel">
|
|
<div class="panel-title">Top Users</div>
|
|
<table>
|
|
<thead>
|
|
<tr><th>User</th><th>Sessions</th><th>Total Hours</th><th>Last Session</th></tr>
|
|
</thead>
|
|
<tbody id="top-users-body"></tbody>
|
|
</table>
|
|
<p class="empty" id="top-users-empty" style="display:none">No data yet.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
function applyThemeColors(colors){var r=document.documentElement.style;for(var k in colors)r.setProperty('--'+k.replace(/_/g,'-'),colors[k]);var s=document.getElementById('bg-pattern-style');if(!s){s=document.createElement('style');s.id='bg-pattern-style';document.head.appendChild(s)}s.textContent=colors.bg_pattern&&colors.bg_pattern!=='none'?'body{background-image:'+colors.bg_pattern+';background-attachment:fixed}':'';localStorage.setItem('rustguac_theme_colors',JSON.stringify(colors))}
|
|
var _themePresets={},_adminPreset='aurora';
|
|
var _themeDescriptions={dark:'Navy & cyan \u2014 the default',light:'Clean white & blue','high-contrast':'Maximum readability',terminal:'Retro green-on-black',nord:'Arctic, muted blues',corporate:'Slate & steel blue',aurora:'Midnight blue with ambient glow',jaguar:'Racing green & gold'};
|
|
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'aurora';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){if(l.src!==t.logo_url&&!l.src.endsWith(t.logo_url))l.src=t.logo_url;l.style.display=''}}var menu=document.getElementById('um-theme-list');if(menu){menu.innerHTML='';Object.keys(_themePresets).forEach(function(name){var item=document.createElement('div');item.className='um-item'+(name===active?' active':'');var sw=document.createElement('span');sw.className='um-swatch';var p=_themePresets[name];sw.style.background='linear-gradient(135deg,'+p.primary+' 50%,'+p.accent+' 50%)';item.appendChild(sw);var info=document.createElement('div');info.className='um-theme-info';var nm=document.createElement('span');nm.className='um-theme-name';nm.textContent=name;info.appendChild(nm);var desc=document.createElement('span');desc.className='um-theme-desc';desc.textContent=_themeDescriptions[name]||'';info.appendChild(desc);item.appendChild(info);item.addEventListener('click',function(){localStorage.setItem('rustguac_theme',name);applyThemeColors(_themePresets[name]);menu.querySelectorAll('.um-item').forEach(function(el){el.classList.remove('active')});item.classList.add('active');document.getElementById('user-menu').style.display='none'});menu.appendChild(item)})}}
|
|
var _ub=document.getElementById('user-menu-btn');if(_ub)_ub.addEventListener('click',function(e){e.stopPropagation();var m=document.getElementById('user-menu');m.style.display=m.style.display==='block'?'none':'block'});document.addEventListener('click',function(){var m=document.getElementById('user-menu');if(m)m.style.display='none'});
|
|
fetch('/api/auth/status').then(function(r){return r.json()}).then(function(d){
|
|
if(d.site_title){document.title=d.site_title+' - Reports';document.querySelector('h1').textContent=d.site_title;}
|
|
initTheme(d.theme);
|
|
});
|
|
var apiKey = sessionStorage.getItem('rustguac_api_key');
|
|
|
|
// Check auth: either API key or cookie
|
|
var roleLevel = { admin: 4, poweruser: 3, operator: 2, viewer: 1 };
|
|
if (apiKey) {
|
|
// A key is never on the page GET, so the server served the anonymous variant.
|
|
document.documentElement.setAttribute('data-role-level', '4');
|
|
document.getElementById('admin-link').style.display = '';
|
|
document.getElementById('tokens-link').style.display = '';
|
|
} else {
|
|
fetch('/api/me', { credentials: 'same-origin' })
|
|
.then(function(res) {
|
|
if (res.status === 401 || res.status === 403) { window.location.href = '/'; return; }
|
|
if (!res.ok) return;
|
|
return res.json();
|
|
})
|
|
.then(function(data) {
|
|
if (!data) return;
|
|
if (data.role === 'admin') document.getElementById('admin-link').style.display = '';
|
|
var level = roleLevel[data.role] || 0;
|
|
// Reconcile the server-written nav gate (see rustguac.css): the page
|
|
// GET carried no API key, so key users arrive on the anonymous variant.
|
|
document.documentElement.setAttribute('data-role-level', String((typeof apiKey !== 'undefined' && apiKey) ? 4 : level));
|
|
if (level >= 2) document.getElementById('tokens-link').style.display = '';
|
|
if (level < 3) {
|
|
document.getElementById('sessions-link').style.display = 'none';
|
|
}
|
|
})
|
|
.catch(function() {});
|
|
}
|
|
|
|
document.getElementById('logout-item').addEventListener('click', function() {
|
|
sessionStorage.removeItem('rustguac_api_key');
|
|
fetch('/auth/logout', { credentials: 'same-origin' })
|
|
.finally(function() { window.location.href = '/'; });
|
|
});
|
|
|
|
function apiHeaders() {
|
|
var h = {};
|
|
if (apiKey) h['Authorization'] = 'Bearer ' + apiKey;
|
|
return h;
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
if (!s) return '';
|
|
var d = document.createElement('div');
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|
|
|
|
// ── Duration formatting ──
|
|
function formatDuration(secs) {
|
|
if (secs == null || secs < 0) return '--';
|
|
var h = Math.floor(secs / 3600);
|
|
var m = Math.floor((secs % 3600) / 60);
|
|
var s = Math.floor(secs % 60);
|
|
if (h > 0) return h + 'h ' + m + 'm';
|
|
if (m > 0) return m + 'm ' + s + 's';
|
|
return s + 's';
|
|
}
|
|
|
|
// Render a backend timestamp in the viewer's local time. Session
|
|
// history comes from SQLite's datetime('now'), i.e. UTC formatted
|
|
// "YYYY-MM-DD HH:MM:SS" with no zone marker -- which the browser would
|
|
// otherwise parse as local time and display unshifted. Already-zoned
|
|
// values (e.g. recording mtimes, "...+00:00") pass through unchanged.
|
|
function formatDate(iso) {
|
|
if (!iso) return '--';
|
|
try {
|
|
var s = iso.indexOf('T') === -1 ? iso.replace(' ', 'T') : iso;
|
|
if (!/[zZ]|[+-]\d\d:?\d\d$/.test(s)) s += 'Z';
|
|
var d = new Date(s);
|
|
return isNaN(d.getTime()) ? iso : d.toLocaleString();
|
|
} catch(e) {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
function typeBadge(type) {
|
|
var t = (type || '').toLowerCase();
|
|
return '<span class="type-badge type-' + escapeHtml(t) + '">' + escapeHtml(t) + '</span>';
|
|
}
|
|
|
|
function statusBadge(status) {
|
|
var s = (status || '').toLowerCase();
|
|
return '<span class="status-badge status-' + escapeHtml(s) + '">' + escapeHtml(s) + '</span>';
|
|
}
|
|
|
|
// ── Summary ──
|
|
function loadSummary() {
|
|
fetch('/api/reports/summary', { headers: apiHeaders(), credentials: 'same-origin' })
|
|
.then(function(res) {
|
|
if (!res.ok) throw new Error('Failed');
|
|
return res.json();
|
|
})
|
|
.then(function(data) {
|
|
document.getElementById('stat-total-sessions').textContent = data.total_sessions != null ? data.total_sessions : '--';
|
|
document.getElementById('stat-total-hours').textContent = data.total_hours != null ? data.total_hours.toFixed(1) : '--';
|
|
document.getElementById('stat-unique-users').textContent = data.unique_users != null ? data.unique_users : '--';
|
|
document.getElementById('stat-active-now').textContent = data.active_now != null ? data.active_now : '--';
|
|
})
|
|
.catch(function() {});
|
|
}
|
|
|
|
// ── Session history ──
|
|
var currentOffset = 0;
|
|
var currentLimit = 100;
|
|
var totalSessions = 0;
|
|
var sessionsData = [];
|
|
var sortCol = 'started_at';
|
|
var sortDir = -1; // -1 = desc, 1 = asc
|
|
|
|
function loadSessions() {
|
|
fetch('/api/reports/sessions?limit=' + currentLimit + '&offset=' + currentOffset, { headers: apiHeaders(), credentials: 'same-origin' })
|
|
.then(function(res) {
|
|
if (!res.ok) throw new Error('Failed');
|
|
return res.json();
|
|
})
|
|
.then(function(data) {
|
|
sessionsData = data.sessions || [];
|
|
totalSessions = data.total || 0;
|
|
currentLimit = data.limit || 100;
|
|
currentOffset = data.offset || 0;
|
|
applySortAndRender();
|
|
updatePagination();
|
|
})
|
|
.catch(function() {
|
|
document.getElementById('history-body').innerHTML = '<tr><td colspan="9" class="empty">Failed to load session history.</td></tr>';
|
|
});
|
|
}
|
|
|
|
function applySortAndRender() {
|
|
var sorted = sessionsData.slice();
|
|
sorted.sort(function(a, b) {
|
|
var va = a[sortCol], vb = b[sortCol];
|
|
if (va == null) va = '';
|
|
if (vb == null) vb = '';
|
|
if (typeof va === 'number' && typeof vb === 'number') {
|
|
return (va - vb) * sortDir;
|
|
}
|
|
va = String(va).toLowerCase();
|
|
vb = String(vb).toLowerCase();
|
|
if (va < vb) return -1 * sortDir;
|
|
if (va > vb) return 1 * sortDir;
|
|
return 0;
|
|
});
|
|
|
|
// Apply text filter
|
|
var filterText = (document.getElementById('filter-input').value || '').toLowerCase();
|
|
if (filterText) {
|
|
sorted = sorted.filter(function(s) {
|
|
var searchable = [
|
|
s.created_by, s.entry_display_name, s.address_book_folder,
|
|
s.session_type, s.hostname, s.status,
|
|
formatDate(s.started_at), formatDuration(s.duration_secs)
|
|
].join(' ').toLowerCase();
|
|
return searchable.indexOf(filterText) !== -1;
|
|
});
|
|
}
|
|
|
|
renderSessions(sorted);
|
|
updateSortArrows();
|
|
}
|
|
|
|
function renderSessions(sessions) {
|
|
var tbody = document.getElementById('history-body');
|
|
if (sessions.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="9" class="empty">No sessions found.</td></tr>';
|
|
return;
|
|
}
|
|
var html = '';
|
|
sessions.forEach(function(s) {
|
|
var recLink = '';
|
|
if (s.recording_file) {
|
|
recLink = '<a class="btn-small" href="/recordings.html" title="' + escapeHtml(s.recording_file) + '">recording</a>';
|
|
}
|
|
html += '<tr>' +
|
|
'<td>' + escapeHtml(s.created_by || s.username || '') + '</td>' +
|
|
'<td>' + escapeHtml(s.entry_display_name || s.address_book_entry || '') + '</td>' +
|
|
'<td>' + escapeHtml(s.address_book_folder || '') + '</td>' +
|
|
'<td>' + typeBadge(s.session_type) + '</td>' +
|
|
'<td>' + escapeHtml(s.hostname || '') + '</td>' +
|
|
'<td>' + formatDate(s.started_at) + '</td>' +
|
|
'<td>' + formatDuration(s.duration_secs) + '</td>' +
|
|
'<td>' + statusBadge(s.status) + '</td>' +
|
|
'<td>' + recLink + '</td>' +
|
|
'</tr>';
|
|
});
|
|
tbody.innerHTML = html;
|
|
}
|
|
|
|
function updatePagination() {
|
|
var prevBtn = document.getElementById('prev-btn');
|
|
var nextBtn = document.getElementById('next-btn');
|
|
var info = document.getElementById('page-info');
|
|
|
|
prevBtn.disabled = currentOffset === 0;
|
|
nextBtn.disabled = (currentOffset + currentLimit) >= totalSessions;
|
|
|
|
var start = totalSessions === 0 ? 0 : currentOffset + 1;
|
|
var end = Math.min(currentOffset + currentLimit, totalSessions);
|
|
info.textContent = start + '-' + end + ' of ' + totalSessions;
|
|
}
|
|
|
|
document.getElementById('prev-btn').addEventListener('click', function() {
|
|
currentOffset = Math.max(0, currentOffset - currentLimit);
|
|
loadSessions();
|
|
});
|
|
document.getElementById('next-btn').addEventListener('click', function() {
|
|
currentOffset += currentLimit;
|
|
loadSessions();
|
|
});
|
|
|
|
// ── Sorting ──
|
|
function updateSortArrows() {
|
|
var ths = document.querySelectorAll('#history-table th.sortable');
|
|
for (var i = 0; i < ths.length; i++) {
|
|
var arrow = ths[i].querySelector('.sort-arrow');
|
|
if (ths[i].getAttribute('data-col') === sortCol) {
|
|
arrow.textContent = sortDir === 1 ? '\u25B2' : '\u25BC';
|
|
} else {
|
|
arrow.textContent = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
document.getElementById('history-table').addEventListener('click', function(e) {
|
|
var th = e.target.closest('th.sortable');
|
|
if (!th) return;
|
|
var col = th.getAttribute('data-col');
|
|
if (col === sortCol) {
|
|
sortDir = sortDir * -1;
|
|
} else {
|
|
sortCol = col;
|
|
sortDir = 1;
|
|
}
|
|
applySortAndRender();
|
|
});
|
|
|
|
// ── Filter ──
|
|
document.getElementById('filter-input').addEventListener('input', function() {
|
|
applySortAndRender();
|
|
});
|
|
|
|
// ── Export CSV ──
|
|
document.getElementById('export-csv-btn').addEventListener('click', function() {
|
|
var params = [];
|
|
var filterUser = document.getElementById('filter-input').value;
|
|
if (filterUser) params.push('user=' + encodeURIComponent(filterUser));
|
|
if (apiKey) params.push('key=' + encodeURIComponent(apiKey));
|
|
var url = '/api/reports/sessions/csv';
|
|
if (params.length > 0) url += '?' + params.join('&');
|
|
window.location.href = url;
|
|
});
|
|
|
|
// ── Top Connections ──
|
|
function loadTopConnections() {
|
|
fetch('/api/reports/top-connections', { headers: apiHeaders(), credentials: 'same-origin' })
|
|
.then(function(res) {
|
|
if (!res.ok) throw new Error('Failed');
|
|
return res.json();
|
|
})
|
|
.then(function(data) {
|
|
var tbody = document.getElementById('top-connections-body');
|
|
var empty = document.getElementById('top-connections-empty');
|
|
if (!data || data.length === 0) {
|
|
tbody.innerHTML = '';
|
|
empty.style.display = '';
|
|
return;
|
|
}
|
|
empty.style.display = 'none';
|
|
var html = '';
|
|
data.forEach(function(c) {
|
|
html += '<tr>' +
|
|
'<td>' + escapeHtml(c.name || c.address_book_entry || '') + '</td>' +
|
|
'<td>' + typeBadge(c.session_type) + '</td>' +
|
|
'<td>' + (c.session_count || 0) + '</td>' +
|
|
'<td>' + (c.total_hours != null ? c.total_hours.toFixed(1) : '0') + '</td>' +
|
|
'</tr>';
|
|
});
|
|
tbody.innerHTML = html;
|
|
})
|
|
.catch(function() {
|
|
document.getElementById('top-connections-empty').style.display = '';
|
|
});
|
|
}
|
|
|
|
// ── Top Users ──
|
|
function loadTopUsers() {
|
|
fetch('/api/reports/top-users', { headers: apiHeaders(), credentials: 'same-origin' })
|
|
.then(function(res) {
|
|
if (!res.ok) throw new Error('Failed');
|
|
return res.json();
|
|
})
|
|
.then(function(data) {
|
|
var tbody = document.getElementById('top-users-body');
|
|
var empty = document.getElementById('top-users-empty');
|
|
if (!data || data.length === 0) {
|
|
tbody.innerHTML = '';
|
|
empty.style.display = '';
|
|
return;
|
|
}
|
|
empty.style.display = 'none';
|
|
var html = '';
|
|
data.forEach(function(u) {
|
|
html += '<tr>' +
|
|
'<td>' + escapeHtml(u.user || '') + '</td>' +
|
|
'<td>' + (u.session_count || 0) + '</td>' +
|
|
'<td>' + (u.total_hours != null ? u.total_hours.toFixed(1) : '0') + '</td>' +
|
|
'<td>' + formatDate(u.last_session) + '</td>' +
|
|
'</tr>';
|
|
});
|
|
tbody.innerHTML = html;
|
|
})
|
|
.catch(function() {
|
|
document.getElementById('top-users-empty').style.display = '';
|
|
});
|
|
}
|
|
|
|
// ── Load everything ──
|
|
loadSummary();
|
|
loadSessions();
|
|
loadTopConnections();
|
|
loadTopUsers();
|
|
</script>
|
|
</body>
|
|
</html>
|