Files
rustguac/static/reports.html
T
Dave Kempe e4fb8ee552 pages: wrap long names so the tables fit at any font width (#252)
The 1710px spec from the fork passed against main as well as against the
change it was meant to pin: its fixture names are short enough to fit
either way under Linux's monospace font, and the overrun on the reporter's
Mac came from wider glyphs and longer real names. With realistic data
(dotted FQDNs, service accounts, long emails and folder paths, which have
no natural break point) main overran by 370px, and the tighter padding on
its own still overran Connections and Recordings.

Table cells on Connections, Recordings and Reports now wrap long text
inside the cell (overflow-wrap: anywhere), with the Connect and action
buttons kept on one line. The spec gains a long-data test per page: all
three fail on main, two fail with the padding change alone, all pass now.
The e2e README lists only the specs that exist.
2026-10-13 09:47:00 +11:00

496 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">Sessions</a>
<a href="/recordings.html">Recordings</a>
<a href="/reports.html" class="active">Reports</a>
<a href="/docs.html">Docs</a>
<a href="/tokens.html" id="tokens-link" style="display:none">Tokens</a>
<a href="/admin.html" id="admin-link" style="display:none">Admin</a>
<span id="user-menu-wrapper"><a href="#" id="logout-item">Logout</a><span id="user-menu-btn" title="Preferences">&#9881; 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) {
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;
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>