mirror of
https://github.com/sol1/rustguac.git
synced 2026-10-24 20:52:24 +00:00
73e0b6ddf2
Unrelated fixes carried by the fork: session times and date filters in local time, no local resolution of the target when a jump host is set, no resize request that changes no pixels, and rustfmt on three hunks left unformatted. (cherry picked from commit d9b361db6eb216d871f3fdac41c423a586e2d559) Upstream changes on pick: the client.html resize skip is kept but without the native-resolution sizing (displayScale/deviceSize), which belongs to the HiDPI commit not taken here, so sizes stay in CSS pixels. The oidc.rs hunk was formatting only and is superseded.
475 lines
22 KiB
HTML
475 lines
22 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; }
|
|
</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">⚙ 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>
|