Files
rustguac/static/reports.html
T
Jad Seifeddine 32581f30ae search: one global connection search, as a floating overlay on every page
The '/' search box leaves the connections folder header for a bigger,
centred box in the brand row, inline with the logo. Results open in a dim,
blurred overlay over an untouched folder view: a centred panel with the
summary, the Type and Folder facets and the results table (fixed columns,
one line per cell, folder shown by display name over its key, Connect, new
window and open-folder gathered into one actions cell). Focusing the box or
pressing / opens it and lists every connection the user can see; typing
narrows it; Escape from anywhere, a mousedown outside the box and panel, or
opening a folder closes it (an open facet menu takes the first Escape).

static/search.js is the single implementation, used on every page
including connections, which dropped ~16k of duplicated markup, CSS and JS;
the styles live once in rustguac.css so the results look identical
everywhere. The index is cached in sessionStorage and only re-fetched when
the address book's version changes, so moving between pages is instant; the
connections page keeps the folder-specific hooks it needs (rgSelectFolder
selects a folder in place, rgSearchIndex feeds the credentials banner) and
opening a folder from another page navigates to connections with
class, and the module stands down cleanly on a page with no brand row.

Connections also gets a more generous content gutter, the folder action
buttons move down to the filter row beside Type, the folder title is inset
to align with the table, and the Connect button keeps its size and spins
in place instead of growing to 'Connecting...'.

Part of #254

tests: the global search overlay on every page

search: keep page modals above the brand-row search box

The global search lifts the brand row to z-index 300 so the box is not
dimmed by its own results overlay (200), but the page modals sat at 100, so
the search input showed through the ad-hoc session dialog and could still
be focused. Modal overlays now sit at 1000, above the search chrome and
below the themed dialogs (4000) and the embedded session (5000). A spec
pins it on the ad-hoc and folder modals.

search: never on the login page, nor for anyone the server does not recognise

The global search was included on index.html, so the login page carried
the search box, and because the index was cached in sessionStorage it
kept answering with the whole address book after logout, or to the next
login in the same tab.

- index.html no longer loads search.js at all.
- search.js only renders for an operator or above: the role the server
  bakes on <html data-role-level> decides on first paint, and an API-key
  user (served the anonymous variant) is confirmed through /api/me first.
  Anonymous or viewer: nothing injected, cache swept.
- The cached index is keyed to the identity /api/me returns; a cache left
  by a different login is dropped rather than shown.
- A 401/403 from the index or version endpoints removes the box, the
  overlay and the cache; the logout link clears the cache before the
  login page shows.

e2e: login page has no box and never loads search.js; anonymous and
viewer callers get nothing and a stale cache is wiped; a different
login's cache is dropped; the same login keeps its cache across pages;
logout and expiry forget it.

(cherry picked from commit b8c5c0f9ee)
2026-10-17 12:36:40 +11:00

514 lines
26 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>rustguac - Reports</title>
<link rel="stylesheet" href="/rustguac.css">
<style>
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 src="/filters.js"></script>
<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"><span class="nav-key">&#128273;</span> Credentials</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>
<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="list-tools">
<input type="text" id="filter-input" class="list-search" placeholder="Search session history (user, host, folder&#8230;)">
<div id="history-filters"></div>
<button id="export-csv-btn" class="btn-add list-action">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;
}
// "host (Tenant)" display names, as the NetBox sync writes them, read
// as the host over a quieter tenant line (same as Connections).
function entryNameHtml(label) {
var m = /^(.*\S)\s+\(([^()]+)\)$/.exec(label || '');
if (!m) return escapeHtml(label || '');
return '<span class="entry-name">' + escapeHtml(m[1]) + '</span><span class="entry-sub">' + escapeHtml(m[2]) + '</span>';
}
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;
});
// Facet filters (filters.js), then the text filter
historyFilters.update(sessionsData);
sorted = historyFilters.apply(sorted);
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>' + entryNameHtml(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();
});
// Shared facet filters (filters.js): Type, Folder, User, Status.
// Selections persist in sessionStorage for this tab.
var historyFilters = rgFilters.create({
mount: document.getElementById('history-filters'),
id: 'reports',
facets: [
{ key: 'type', label: 'Type', value: function(s) { return s.session_type; }, render: typeBadge },
{ key: 'folder', label: 'Folder', value: function(s) { return s.address_book_folder; } },
{ key: 'user', label: 'User', value: function(s) { return s.created_by; } },
{ key: 'status', label: 'Status', value: function(s) { return s.status; } }
],
onChange: 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>' + entryNameHtml(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>
<script src="/search.js"></script>
</body>
</html>