mirror of
https://github.com/sol1/rustguac.git
synced 2026-10-25 21:19:45 +00:00
2dec42d019
A .guac recording only plays in rustguac's own player. The Recordings page now has an mp4 button per recording: it starts a conversion, shows progress, and downloads the video when ready. The file is a standard MP4 that plays in VLC and browsers. - POST /api/recordings/:name/mp4 starts it (202 running, 200 ready, 409 while the session is live, 503 without guacenc), GET .../mp4/status reports it, GET .../mp4 downloads it. Same access and name checks as the recording download (poweruser or admin). - guacenc writes <input>.m4v beside its input, so each job links the recording into a work directory inside the cache and moves the finished file to <cache>/<stem>.mp4; the recording directory is never written. guacenc exits 0 even on failure, so success is the output existing and not being empty. Runs with a timeout, killed on timeout. - At most mp4_max_jobs at once (default 1). The video is the recording's own size (its largest layer 0 size), scaled down to fit 1920x1080 and rounded to even. - Cached MP4s go after mp4_cache_hours (24), when their recording goes, and all of them before rotation removes any recording for disk space. Leftover work directories are cleared at startup and hourly. - The listing carries each recording's mp4 state only when conversion is available, so the button is hidden on a build without guacenc. Config: mp4_enabled, guacenc_path, mp4_cache_path, mp4_cache_hours, mp4_max_jobs, mp4_timeout_secs, mp4_bitrate, mp4_max_width/height under [recording]. Docs in configuration.md (MP4 downloads, including converting by hand) and api.md, which also had the recordings role as operator; it is poweruser. Tests: the converter (cache layout, failure leaves nothing, live and missing refused, purge, size parsing and fitting) with a stand-in guacenc, the shared access check, and a browser spec for the button.
776 lines
37 KiB
HTML
776 lines
37 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">🔑</span> 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>
|
|
|
|
<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">
|
|
<div class="list-tools">
|
|
<input type="text" id="rec-search" placeholder="Search recordings (user, entry, folder…)" 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,'&').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();
|
|
}
|
|
// 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);
|
|
// The server lists an `mp4` state per recording only when it can
|
|
// convert them (#241). A state this page is tracking (a
|
|
// conversion it started) wins over a stale listing.
|
|
mp4Available = recordings.some(function(r) { return 'mp4' in r; });
|
|
recordings.forEach(function(r) {
|
|
if ('mp4' in r && !mp4Polling[r.name]) mp4State[r.name] = r.mp4;
|
|
});
|
|
renderRecPage();
|
|
}
|
|
|
|
// ── MP4 downloads (#241) ──
|
|
// Start a conversion, show its progress on the button by polling the
|
|
// status, and download the file as soon as it is ready. Downloads go
|
|
// through fetch so API-key sessions send their header, and are saved
|
|
// with a.download, never opened as a page.
|
|
var mp4Available = false;
|
|
var mp4State = {}; // recording name -> none|running|ready|failed
|
|
var mp4Polling = {}; // recording name -> true while this page polls
|
|
var MP4_LABELS = { running: 'converting\u2026', ready: 'mp4 \u2193', failed: 'mp4 (retry)' };
|
|
|
|
function mp4Button(name) {
|
|
var st = mp4State[name] || 'none';
|
|
return '<button class="btn-small btn-mp4" data-mp4="' + escapeAttr(name) + '"' +
|
|
(st === 'running' ? ' disabled' : '') +
|
|
' title="Download as an MP4 video that plays anywhere">' +
|
|
(MP4_LABELS[st] || 'mp4') + '</button>';
|
|
}
|
|
|
|
function setMp4State(name, st) {
|
|
mp4State[name] = st;
|
|
var btn = document.querySelector('[data-mp4="' + CSS.escape(name) + '"]');
|
|
if (btn) btn.outerHTML = mp4Button(name);
|
|
}
|
|
|
|
function mp4Url(name, suffix) {
|
|
return '/api/recordings/' + encodeURIComponent(name) + '/mp4' + (suffix || '');
|
|
}
|
|
|
|
function downloadMp4(name) {
|
|
fetch(mp4Url(name), { headers: apiHeaders(), credentials: 'same-origin' })
|
|
.then(function(res) {
|
|
if (!res.ok) throw new Error('The MP4 is no longer available; convert it again.');
|
|
return res.blob();
|
|
})
|
|
.then(function(blob) {
|
|
var a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = name.replace(/\.guac$/, '') + '.mp4';
|
|
a.click();
|
|
setTimeout(function() { URL.revokeObjectURL(a.href); }, 1000);
|
|
})
|
|
.catch(function(err) { setMp4State(name, 'none'); rgAlert(err.message); });
|
|
}
|
|
|
|
function pollMp4(name) {
|
|
mp4Polling[name] = true;
|
|
fetch(mp4Url(name, '/status'), { headers: apiHeaders(), credentials: 'same-origin' })
|
|
.then(function(res) { return res.json(); })
|
|
.then(function(data) {
|
|
if (data.state === 'running') { setTimeout(function() { pollMp4(name); }, 2000); return; }
|
|
delete mp4Polling[name];
|
|
setMp4State(name, data.state);
|
|
if (data.state === 'ready') downloadMp4(name);
|
|
else if (data.state === 'failed') rgAlert('MP4 conversion failed: ' + (data.error || 'unknown error'));
|
|
})
|
|
.catch(function() { setTimeout(function() { pollMp4(name); }, 5000); });
|
|
}
|
|
|
|
function startMp4(name) {
|
|
if (mp4State[name] === 'ready') { downloadMp4(name); return; }
|
|
setMp4State(name, 'running');
|
|
fetch(mp4Url(name), { method: 'POST', headers: apiHeaders(), credentials: 'same-origin' })
|
|
.then(function(res) { return res.json().then(function(data) { return { res: res, data: data }; }); })
|
|
.then(function(r) {
|
|
if (r.res.status === 200 && r.data.state === 'ready') { setMp4State(name, 'ready'); downloadMp4(name); return; }
|
|
if (r.res.status === 202) { pollMp4(name); return; }
|
|
setMp4State(name, 'none');
|
|
rgAlert(r.data.error || 'Could not start the MP4 conversion.');
|
|
})
|
|
.catch(function() { setMp4State(name, 'none'); rgAlert('Could not start the MP4 conversion.'); });
|
|
}
|
|
|
|
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>' + (mp4Available ? '<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>' +
|
|
(mp4Available ? '<td>' + mp4Button(r.name) + '</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 mp4Name = e.target.getAttribute('data-mp4');
|
|
if (mp4Name) {
|
|
startMp4(mp4Name);
|
|
return;
|
|
}
|
|
var deleteName = e.target.getAttribute('data-delete-rec');
|
|
if (deleteName) {
|
|
rgConfirm('Delete recording ' + deleteName + '?', { danger: true, confirmText: 'Delete' }).then(function(ok) {
|
|
if (!ok) return;
|
|
fetch('/api/recordings/' + encodeURIComponent(deleteName), {
|
|
method: 'DELETE',
|
|
headers: apiHeaders(),
|
|
credentials: 'same-origin'
|
|
}).then(function() {
|
|
refreshRecordings();
|
|
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>
|
|
<script src="/search.js"></script>
|
|
<script src="/ui.js"></script>
|
|
</body>
|
|
</html>
|