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)
671 lines
32 KiB
HTML
671 lines
32 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>rustguac - Recordings</title>
|
|
<link rel="stylesheet" href="/rustguac.css">
|
|
<style>
|
|
#player-section {
|
|
display: none;
|
|
margin: var(--s-3) 0 var(--s-4) 0;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
padding: var(--s-3);
|
|
max-width: 1200px;
|
|
}
|
|
#player-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: var(--s-2);
|
|
}
|
|
#player-title {
|
|
color: var(--text-muted);
|
|
font-size: var(--fz-sm);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
}
|
|
#player-close {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-muted);
|
|
cursor: pointer;
|
|
font-family: monospace;
|
|
font-size: var(--fz-lg);
|
|
padding: 0 var(--s-2);
|
|
height: var(--ctl-sm);
|
|
font-weight: normal;
|
|
}
|
|
#player-close:hover { color: var(--primary); background: none; border-color: transparent; }
|
|
#player-display {
|
|
background: #000;
|
|
overflow: auto;
|
|
max-height: 70vh;
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
}
|
|
#player-controls {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--s-3);
|
|
margin-top: var(--s-2);
|
|
padding: var(--s-2) 0;
|
|
}
|
|
#play-btn {
|
|
min-width: 90px;
|
|
height: var(--ctl-sm);
|
|
font-size: var(--fz-sm);
|
|
}
|
|
#seek-container {
|
|
position: relative;
|
|
flex: 1;
|
|
height: 36px;
|
|
}
|
|
#histogram {
|
|
position: absolute;
|
|
top: 0; left: 0;
|
|
width: 100%; height: 100%;
|
|
border-radius: var(--radius);
|
|
}
|
|
#seek-slider {
|
|
position: absolute;
|
|
top: 0; left: 0;
|
|
width: 100%; height: 100%;
|
|
z-index: 2;
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
background: transparent;
|
|
outline: none;
|
|
margin: 0;
|
|
}
|
|
#seek-slider::-webkit-slider-runnable-track {
|
|
height: 4px;
|
|
background: rgba(255,255,255,0.12);
|
|
border-radius: 2px;
|
|
}
|
|
#seek-slider::-moz-range-track {
|
|
height: 4px;
|
|
background: rgba(255,255,255,0.12);
|
|
border-radius: 2px;
|
|
}
|
|
#seek-slider::-webkit-slider-thumb {
|
|
-webkit-appearance: none;
|
|
width: 14px; height: 14px;
|
|
background: var(--accent); border-radius: 50%; cursor: pointer;
|
|
margin-top: -5px;
|
|
}
|
|
#seek-slider::-moz-range-thumb {
|
|
width: 14px; height: 14px;
|
|
background: var(--accent); border-radius: 50%; cursor: pointer; border: none;
|
|
}
|
|
#player-time { color: var(--text-muted); font-size: var(--fz-sm); white-space: nowrap; }
|
|
.btn-delete { color: var(--primary); }
|
|
.btn-delete:hover { color: var(--primary-hover); }
|
|
/* 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. */
|
|
#recording-list td { overflow-wrap: anywhere; }
|
|
#recording-list 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) {
|
|
table th, table td {
|
|
padding: var(--s-2) var(--s-3);
|
|
font-size: var(--fz-sm);
|
|
}
|
|
table th { font-size: var(--fz-xs); letter-spacing: 0.08em; }
|
|
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>
|
|
|
|
<!-- Guacamole JS modules (same as client.html) -->
|
|
<script src="/guac/Namespace.js"></script>
|
|
<script src="/guac/Client.js"></script>
|
|
<script src="/guac/Display.js"></script>
|
|
<script src="/guac/Event.js"></script>
|
|
<script src="/guac/InputSink.js"></script>
|
|
<script src="/guac/IntegerPool.js"></script>
|
|
<script src="/guac/Keyboard.js"></script>
|
|
<script src="/guac/KeyEventInterpreter.js"></script>
|
|
<script src="/guac/Layer.js"></script>
|
|
<script src="/guac/Mouse.js"></script>
|
|
<script src="/guac/Parser.js"></script>
|
|
<script src="/guac/Position.js"></script>
|
|
<script src="/guac/Status.js"></script>
|
|
<script src="/guac/Touch.js"></script>
|
|
<script src="/guac/Tunnel.js"></script>
|
|
<script src="/guac/UTF8Parser.js"></script>
|
|
<script src="/guac/InputStream.js"></script>
|
|
<script src="/guac/OutputStream.js"></script>
|
|
<script src="/guac/StringReader.js"></script>
|
|
<script src="/guac/StringWriter.js"></script>
|
|
<script src="/guac/ArrayBufferReader.js"></script>
|
|
<script src="/guac/ArrayBufferWriter.js"></script>
|
|
<script src="/guac/BlobReader.js"></script>
|
|
<script src="/guac/BlobWriter.js"></script>
|
|
<script src="/guac/DataURIReader.js"></script>
|
|
<script src="/guac/AudioContextFactory.js"></script>
|
|
<script src="/guac/AudioPlayer.js"></script>
|
|
<script src="/guac/AudioRecorder.js"></script>
|
|
<script src="/guac/VideoPlayer.js"></script>
|
|
<script src="/guac/Yuv444.js"></script>
|
|
<script src="/guac/H264Decoder.js"></script>
|
|
<script src="/guac/JSONReader.js"></script>
|
|
<script src="/guac/Object.js"></script>
|
|
<script src="/guac/RawAudioFormat.js"></script>
|
|
<script src="/guac/SessionRecording.js"></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" class="active">Recordings</a>
|
|
<a href="/reports.html" 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>Recordings</h2>
|
|
|
|
<div id="player-section">
|
|
<div id="player-header">
|
|
<span id="player-title"></span>
|
|
<button id="player-close">✕ close</button>
|
|
</div>
|
|
<div id="player-display"></div>
|
|
<div id="player-controls">
|
|
<button id="play-btn">Play</button>
|
|
<div id="seek-container">
|
|
<canvas id="histogram"></canvas>
|
|
<input type="range" id="seek-slider" min="0" max="1000" value="0">
|
|
</div>
|
|
<span id="player-time">0:00 / 0:00</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="max-width:1200px;margin-bottom:1em">
|
|
<input type="text" id="rec-search" placeholder="Search recordings (user, entry, folder...)" style="width:100%;padding:0.5em;background:var(--input);border:1px solid var(--border);color:var(--text);font-family:monospace;font-size:1em;border-radius:3px;box-sizing:border-box">
|
|
</div>
|
|
<div id="recording-list"></div>
|
|
<div id="rec-pagination" style="max-width:1200px;margin-top:0.8em;font-family:monospace;font-size:0.9em"></div>
|
|
|
|
<div id="typescript-section" style="display:none;max-width:1200px;margin-top:2em">
|
|
<h2 style="margin-bottom:0.2em">SSH Typescripts</h2>
|
|
<p style="color:var(--text-muted);font-size:0.9em;margin-top:0;max-width:70ch">
|
|
Plain-text audit logs of SSH terminal sessions. Listed here for
|
|
accountability; the content is not downloadable through the web UI.
|
|
Retrieving a typescript requires direct access to the rustguac host
|
|
or storage.
|
|
</p>
|
|
<p id="typescript-path" style="color:var(--text-muted);font-size:0.85em;margin-top:0"></p>
|
|
<div id="typescript-list"></div>
|
|
<div id="typescript-pagination" style="margin-top:0.8em;font-family:monospace;font-size:0.9em"></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+' - Recordings';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 = '';
|
|
document.getElementById('reports-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('reports-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.
|
|
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 formatBytes(bytes) {
|
|
if (bytes < 1024) return bytes + ' B';
|
|
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
|
return (bytes / 1048576).toFixed(1) + ' MB';
|
|
}
|
|
|
|
function formatTime(ms) {
|
|
var secs = Math.floor(ms / 1000);
|
|
var m = Math.floor(secs / 60);
|
|
var s = secs % 60;
|
|
return m + ':' + (s < 10 ? '0' : '') + s;
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
var d = document.createElement('div');
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|
|
|
|
function escapeAttr(s) {
|
|
return s.replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>');
|
|
}
|
|
|
|
function refreshRecordings() {
|
|
fetch('/api/recordings', { headers: apiHeaders(), credentials: 'same-origin' })
|
|
.then(function(res) {
|
|
if (!res.ok) throw new Error('Failed to load recordings');
|
|
return res.json();
|
|
})
|
|
.then(function(recordings) {
|
|
allRecordings = recordings;
|
|
// Preserve the active search filter + current page on refresh.
|
|
renderRecordings(filterRecordings(recSearchQuery()));
|
|
});
|
|
}
|
|
|
|
// #159: list-only typescript register. No play/download/delete:
|
|
// the section only shows that a recording exists.
|
|
var tsItems = [];
|
|
var tsPage = 1;
|
|
|
|
document.getElementById('typescript-pagination').addEventListener('click', function(e) {
|
|
var act = e.target.getAttribute('data-ts-page');
|
|
if (!act) return;
|
|
tsPage += (act === 'next' ? 1 : -1);
|
|
renderTsPage();
|
|
});
|
|
|
|
function renderTsPage() {
|
|
var list = document.getElementById('typescript-list');
|
|
var pager = document.getElementById('typescript-pagination');
|
|
var pages = Math.max(1, Math.ceil(tsItems.length / PAGE_SIZE));
|
|
if (tsPage > pages) tsPage = pages;
|
|
if (tsPage < 1) tsPage = 1;
|
|
var slice = tsItems.slice((tsPage - 1) * PAGE_SIZE, (tsPage - 1) * PAGE_SIZE + PAGE_SIZE);
|
|
var html = '<table><thead><tr><th>Name</th><th>Size</th><th>Date</th></tr></thead><tbody>';
|
|
slice.forEach(function(t) {
|
|
var date = t.modified ? new Date(t.modified).toLocaleString() : '';
|
|
html += '<tr>' +
|
|
'<td>' + escapeHtml(t.name || '') + '</td>' +
|
|
'<td>' + formatBytes(t.size_bytes) + '</td>' +
|
|
'<td>' + date + '</td>' +
|
|
'</tr>';
|
|
});
|
|
html += '</tbody></table>';
|
|
list.innerHTML = html;
|
|
renderPager(pager, tsPage, tsItems.length, 'ts-page', 'typescript');
|
|
}
|
|
|
|
function refreshTypescripts() {
|
|
fetch('/api/typescripts', { headers: apiHeaders(), credentials: 'same-origin' })
|
|
.then(function(res) { return res.ok ? res.json() : { items: [] }; })
|
|
.then(function(data) {
|
|
var section = document.getElementById('typescript-section');
|
|
tsItems = (data && data.items) || [];
|
|
if (!tsItems.length) { section.style.display = 'none'; return; }
|
|
section.style.display = '';
|
|
var pathEl = document.getElementById('typescript-path');
|
|
pathEl.textContent = data.path ? ('Stored at ' + data.path + ' on the rustguac host.') : '';
|
|
renderTsPage();
|
|
})
|
|
.catch(function() { /* feature off or unauthorised, leave hidden */ });
|
|
}
|
|
|
|
var allRecordings = [];
|
|
var recSortCol = 'modified';
|
|
var recSortAsc = false;
|
|
var PAGE_SIZE = 50;
|
|
var recPage = 1;
|
|
var recView = []; // current filtered + sorted list being paginated
|
|
|
|
function recSearchQuery() {
|
|
return (document.getElementById('rec-search').value || '').toLowerCase();
|
|
}
|
|
function filterRecordings(q) {
|
|
if (!q) return allRecordings;
|
|
return allRecordings.filter(function(r) {
|
|
return (r.name || '').toLowerCase().indexOf(q) >= 0 ||
|
|
(r.user || '').toLowerCase().indexOf(q) >= 0 ||
|
|
(r.entry_display_name || '').toLowerCase().indexOf(q) >= 0 ||
|
|
(r.address_book_entry || '').toLowerCase().indexOf(q) >= 0 ||
|
|
(r.folder || '').toLowerCase().indexOf(q) >= 0 ||
|
|
(r.session_type || '').toLowerCase().indexOf(q) >= 0;
|
|
});
|
|
}
|
|
|
|
// Render a Prev/Next pager into `el`. `total` items, `size` per page.
|
|
function renderPager(el, page, total, dataAttr, noun) {
|
|
var pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
|
if (pages <= 1) {
|
|
el.innerHTML = '<span style="color:var(--text-muted)">' + total + ' ' + noun + (total === 1 ? '' : 's') + '</span>';
|
|
return;
|
|
}
|
|
el.innerHTML =
|
|
'<button class="btn-small" data-' + dataAttr + '="prev"' + (page <= 1 ? ' disabled' : '') + '>Prev</button>' +
|
|
'<span style="margin:0 0.8em;color:var(--text-muted)">Page ' + page + ' of ' + pages + ' (' + total + ' ' + noun + 's)</span>' +
|
|
'<button class="btn-small" data-' + dataAttr + '="next"' + (page >= pages ? ' disabled' : '') + '>Next</button>';
|
|
}
|
|
|
|
document.getElementById('rec-search').addEventListener('input', function() {
|
|
recPage = 1;
|
|
renderRecordings(filterRecordings(recSearchQuery()));
|
|
});
|
|
|
|
document.getElementById('rec-pagination').addEventListener('click', function(e) {
|
|
var act = e.target.getAttribute('data-rec-page');
|
|
if (!act) return;
|
|
recPage += (act === 'next' ? 1 : -1);
|
|
renderRecPage();
|
|
});
|
|
|
|
function sortRecordings(recs, col, asc) {
|
|
return recs.slice().sort(function(a, b) {
|
|
var va = a[col] || '', vb = b[col] || '';
|
|
if (col === 'size_bytes') { va = va || 0; vb = vb || 0; }
|
|
if (va < vb) return asc ? -1 : 1;
|
|
if (va > vb) return asc ? 1 : -1;
|
|
return 0;
|
|
});
|
|
}
|
|
|
|
function renderRecordings(recordings) {
|
|
recView = sortRecordings(recordings, recSortCol, recSortAsc);
|
|
renderRecPage();
|
|
}
|
|
|
|
function renderRecPage() {
|
|
var container = document.getElementById('recording-list');
|
|
var pager = document.getElementById('rec-pagination');
|
|
if (recView.length === 0) {
|
|
container.innerHTML = '<p class="empty">No recordings found.</p>';
|
|
pager.innerHTML = '';
|
|
return;
|
|
}
|
|
var pages = Math.max(1, Math.ceil(recView.length / PAGE_SIZE));
|
|
if (recPage > pages) recPage = pages;
|
|
if (recPage < 1) recPage = 1;
|
|
var slice = recView.slice((recPage - 1) * PAGE_SIZE, (recPage - 1) * PAGE_SIZE + PAGE_SIZE);
|
|
function sortHeader(label, col) {
|
|
var arrow = recSortCol === col ? (recSortAsc ? ' ▲' : ' ▼') : '';
|
|
return '<th style="cursor:pointer" data-sort-col="' + col + '">' + label + arrow + '</th>';
|
|
}
|
|
var html = '<table><thead><tr>' +
|
|
sortHeader('Entry', 'entry_display_name') +
|
|
sortHeader('User', 'user') +
|
|
sortHeader('Type', 'session_type') +
|
|
sortHeader('Folder', 'folder') +
|
|
sortHeader('Size', 'size_bytes') +
|
|
sortHeader('Date', 'modified') +
|
|
'<th></th><th></th><th></th>' +
|
|
'</tr></thead><tbody>';
|
|
slice.forEach(function(r) {
|
|
var date = r.modified ? new Date(r.modified).toLocaleString() : (r.created_at ? new Date(r.created_at).toLocaleString() : '');
|
|
var entryName = r.entry_display_name || r.address_book_entry || r.name;
|
|
var typeBadge = r.session_type ? '<span style="color:var(--type-' + r.session_type + '-fg)">' + r.session_type + '</span>' : '';
|
|
html += '<tr>' +
|
|
'<td title="' + escapeAttr(r.name) + '">' + entryNameHtml(entryName) + '</td>' +
|
|
'<td>' + escapeHtml(r.user || '') + '</td>' +
|
|
'<td>' + typeBadge + '</td>' +
|
|
'<td>' + escapeHtml(r.folder || '') + '</td>' +
|
|
'<td>' + formatBytes(r.size_bytes) + '</td>' +
|
|
'<td>' + date + '</td>' +
|
|
'<td><button class="btn-small" data-play="' + escapeAttr(r.name) + '">play</button></td>' +
|
|
'<td><a class="btn-small" href="/api/recordings/' + encodeURIComponent(r.name) + '" download="' + escapeAttr(r.name) + '">download</a></td>' +
|
|
'<td><button class="btn-small btn-delete" data-delete-rec="' + escapeAttr(r.name) + '">delete</button></td>' +
|
|
'</tr>';
|
|
});
|
|
html += '</tbody></table>';
|
|
container.innerHTML = html;
|
|
renderPager(pager, recPage, recView.length, 'rec-page', 'recording');
|
|
}
|
|
|
|
document.getElementById('recording-list').addEventListener('click', function(e) {
|
|
if (e.target.getAttribute('data-sort-col')) {
|
|
var col = e.target.getAttribute('data-sort-col');
|
|
if (recSortCol === col) { recSortAsc = !recSortAsc; }
|
|
else { recSortCol = col; recSortAsc = true; }
|
|
recPage = 1;
|
|
renderRecordings(filterRecordings(recSearchQuery()));
|
|
return;
|
|
}
|
|
});
|
|
|
|
// Handle clicks on recording list
|
|
document.getElementById('recording-list').addEventListener('click', function(e) {
|
|
var playName = e.target.getAttribute('data-play');
|
|
if (playName) {
|
|
playRecording(playName);
|
|
return;
|
|
}
|
|
var deleteName = e.target.getAttribute('data-delete-rec');
|
|
if (deleteName) {
|
|
if (!confirm('Delete recording ' + deleteName + '?')) return;
|
|
fetch('/api/recordings/' + encodeURIComponent(deleteName), {
|
|
method: 'DELETE',
|
|
headers: apiHeaders(),
|
|
credentials: 'same-origin'
|
|
}).then(function() {
|
|
refreshRecordings();
|
|
// Close player if this recording is playing
|
|
if (document.getElementById('player-title').textContent === deleteName) {
|
|
closePlayer();
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// Download link needs auth header — intercept clicks
|
|
document.getElementById('recording-list').addEventListener('click', function(e) {
|
|
if (e.target.tagName === 'A' && e.target.hasAttribute('download')) {
|
|
e.preventDefault();
|
|
var url = e.target.href;
|
|
var filename = e.target.getAttribute('download');
|
|
fetch(url, { headers: apiHeaders(), credentials: 'same-origin' })
|
|
.then(function(res) { return res.blob(); })
|
|
.then(function(blob) {
|
|
var a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = filename;
|
|
a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
});
|
|
}
|
|
});
|
|
|
|
var currentRecording = null;
|
|
|
|
function closePlayer() {
|
|
if (currentRecording) {
|
|
currentRecording.pause();
|
|
currentRecording = null;
|
|
}
|
|
document.getElementById('player-display').innerHTML = '';
|
|
document.getElementById('player-section').style.display = 'none';
|
|
document.getElementById('play-btn').textContent = 'Play';
|
|
document.getElementById('seek-slider').value = 0;
|
|
document.getElementById('player-time').textContent = '0:00 / 0:00';
|
|
}
|
|
|
|
document.getElementById('player-close').addEventListener('click', closePlayer);
|
|
|
|
function playRecording(name) {
|
|
closePlayer();
|
|
|
|
document.getElementById('player-title').textContent = name;
|
|
var playerSection = document.getElementById('player-section');
|
|
playerSection.style.display = 'block';
|
|
playerSection.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
|
|
var headers = {};
|
|
if (apiKey) headers['Authorization'] = 'Bearer ' + apiKey;
|
|
var tunnel = new Guacamole.StaticHTTPTunnel(
|
|
'/api/recordings/' + encodeURIComponent(name),
|
|
false,
|
|
headers
|
|
);
|
|
var recording = new Guacamole.SessionRecording(tunnel);
|
|
currentRecording = recording;
|
|
|
|
var displayEl = document.getElementById('player-display');
|
|
var playBtn = document.getElementById('play-btn');
|
|
var seekSlider = document.getElementById('seek-slider');
|
|
var timeEl = document.getElementById('player-time');
|
|
|
|
var display = recording.getDisplay();
|
|
displayEl.appendChild(display.getElement());
|
|
|
|
// Auto-scale VNC recordings to fit the player container
|
|
function scaleRecordingDisplay() {
|
|
var w = display.getWidth();
|
|
var h = display.getHeight();
|
|
if (w > 0 && h > 0) {
|
|
var containerWidth = displayEl.clientWidth || 1080;
|
|
var scale = Math.min(containerWidth / w, 1);
|
|
display.scale(scale);
|
|
}
|
|
}
|
|
display.onresize = function() {
|
|
scaleRecordingDisplay();
|
|
};
|
|
|
|
var duration = 0;
|
|
var currentPos = 0;
|
|
var seeking = false;
|
|
|
|
function rebuildHistogram() {
|
|
var frameData = recording.getFrames();
|
|
if (!frameData.length || duration <= 0) return;
|
|
|
|
var canvas = document.getElementById('histogram');
|
|
var rect = canvas.parentElement.getBoundingClientRect();
|
|
canvas.width = rect.width * devicePixelRatio;
|
|
canvas.height = rect.height * devicePixelRatio;
|
|
|
|
var ctx = canvas.getContext('2d');
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
var bucketCount = Math.min(Math.floor(rect.width / 2), 200);
|
|
if (bucketCount <= 0) return;
|
|
var buckets = new Array(bucketCount).fill(0);
|
|
var bucketDuration = duration / bucketCount;
|
|
|
|
for (var i = 0; i < frameData.length; i++) {
|
|
var idx = Math.min(Math.floor(frameData[i].timestamp / bucketDuration), bucketCount - 1);
|
|
if (idx >= 0) buckets[idx] += frameData[i].size;
|
|
}
|
|
|
|
var sorted = buckets.slice().sort(function(a, b) { return a - b; });
|
|
var maxVal = sorted[Math.floor(sorted.length * 0.95)] || 1;
|
|
|
|
var barWidth = canvas.width / bucketCount;
|
|
for (var i = 0; i < bucketCount; i++) {
|
|
var h = Math.min(buckets[i] / maxVal, 1) * canvas.height * 0.85;
|
|
ctx.fillStyle = 'rgba(91, 192, 190, 0.35)';
|
|
ctx.fillRect(i * barWidth, canvas.height - h, barWidth - 0.5, h);
|
|
}
|
|
}
|
|
|
|
recording.onprogress = function(millis) {
|
|
duration = millis;
|
|
timeEl.textContent = formatTime(currentPos) + ' / ' + formatTime(duration);
|
|
rebuildHistogram();
|
|
};
|
|
|
|
recording.onplay = function() {
|
|
playBtn.textContent = 'Pause';
|
|
};
|
|
|
|
recording.onpause = function() {
|
|
playBtn.textContent = 'Play';
|
|
};
|
|
|
|
recording.onseek = function(position) {
|
|
currentPos = position;
|
|
if (!seeking && duration > 0) {
|
|
seekSlider.value = Math.round((position / duration) * 1000);
|
|
}
|
|
timeEl.textContent = formatTime(position) + ' / ' + formatTime(duration);
|
|
};
|
|
|
|
playBtn.onclick = function() {
|
|
if (recording.isPlaying()) {
|
|
recording.pause();
|
|
} else {
|
|
recording.play();
|
|
}
|
|
};
|
|
|
|
seekSlider.addEventListener('mousedown', function() { seeking = true; });
|
|
seekSlider.addEventListener('mouseup', function() { seeking = false; });
|
|
seekSlider.addEventListener('input', function() {
|
|
if (duration > 0) {
|
|
var target = (seekSlider.value / 1000) * duration;
|
|
recording.seek(target);
|
|
}
|
|
});
|
|
|
|
// Connect to load the recording data, then auto-play
|
|
recording.connect();
|
|
}
|
|
|
|
refreshRecordings();
|
|
refreshTypescripts();
|
|
// Don't auto-refresh recordings as aggressively as sessions
|
|
setInterval(refreshRecordings, 15000);
|
|
setInterval(refreshTypescripts, 15000);
|
|
</script>
|
|
</body>
|
|
</html>
|