Files
rustguac/static/reports.html
T
Jad Seifeddine be866264bd pages: shared facet filters and one list toolbar on every list page
reports: show entry names as host over a tenant subline

Session history and the most-used table showed "host (Tenant)" names
wrapped across three lines; they now read as the host over a quieter
tenant line, as on Connections and Recordings.

Part of #254

sessions, recordings: show the session type with the shared badge (#254)

The sessions page printed the type as plain text and the recordings page
as a coloured word; connections and reports already used .type-badge
from rustguac.css. All four now use the same badge. The Proxmox badge
colour moves into the shared stylesheet with the others.

Shared facet filters (static/filters.js) on the sessions, recordings,
reports and connections pages: a dropdown per facet (Type, Folder, User,
Owner, Status as each page has them), multi-select with counts that narrow
as other facets apply, All/None, a find box for long lists, a Clear filters
link, and selections kept per page in sessionStorage. They sit beside the
existing text search and combine with it; the buttons are compacted to the
height and padding of the other controls and the dropdown reserves room for
its scrollbar so it no longer sits over the counts.

The list pages share one toolbar: a borderless row with the search box, the
facets and the page action on the right (New ad-hoc session, Export CSV),
placeholders standardised to 'Search <noun> (fields…)'. Sessions gains a
search (user, host, owner, ID, type) and a User facet; Reports moves from
its own .filter-bar to the shared one. The redundant page titles go — the
nav already names and marks the active page — and section headings centre
their heading and action button on one line.

Connections: the folder header is sticky at the top of the entries pane, so
it stays under the nav and the active-sessions strip while a long table
scrolls. All four pages show the session type with the shared .type-badge
(the Proxmox colour moves into rustguac.css), reports and recordings show
"host (Tenant)" names as the host over a quieter tenant line, and the key
glyph before Credentials in the nav is sized to the text.

Part of #254

tests: shared facet filters and the list toolbar on every list page
2026-10-16 16:51:36 +07:00

513 lines
25 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>
</body>
</html>