Files
rustguac/static/recordings.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

688 lines
33 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="/filters.js"></script>
<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"><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 id="player-section">
<div id="player-header">
<span id="player-title"></span>
<button id="player-close">&#x2715; 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">
<div class="list-tools">
<input type="text" id="rec-search" placeholder="Search recordings (user, entry, folder&#8230;)" class="list-search">
<div id="rec-filters"></div>
</div>
</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,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
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();
}
// Shared facet filters (filters.js): Type, Folder, User; applied on top
// of the text search. Selections persist in sessionStorage for this tab.
var recFilters = rgFilters.create({
mount: document.getElementById('rec-filters'),
id: 'recordings',
facets: [
{ key: 'type', label: 'Type', value: function(r) { return r.session_type; },
render: function(v) { return '<span class="type-badge type-' + escapeAttr(v) + '">' + escapeHtml(v) + '</span>'; } },
{ key: 'folder', label: 'Folder', value: function(r) { return r.folder; } },
{ key: 'user', label: 'User', value: function(r) { return r.user; } }
],
onChange: function() { recPage = 1; renderRecordings(filterRecordings(recSearchQuery())); }
});
function filterRecordings(q) {
recFilters.update(allRecordings);
var list = recFilters.apply(allRecordings);
if (!q) return list;
return list.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 ? ' &#9650;' : ' &#9660;') : '';
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 class="type-badge type-' + escapeAttr(r.session_type) + '">' + escapeHtml(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>