Files
rustguac/static/sessions.html
T
Dave Kempe 76c93bc502 Add multi-hop SSH tunnels, VNC sessions, and web session tunnel support
Multi-hop SSH tunnel chains allow routing any session type through
multiple bastion hosts. VNC is now a first-class session type.
Web browser sessions can tunnel through jump hosts with automatic
URL rewriting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 08:22:34 +11:00

774 lines
35 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>rustguac - Sessions</title>
<style>
:root { --primary: #e94560; --primary-hover: #c73652; --accent: #5bc0be; --accent-hover: #4aa3a1; --bg: #1a1a2e; --surface: #16213e; --input: #0f3460; --text: #e0e0e0; --text-muted: #aaa; --border: #333; }
body { font-family: monospace; background: var(--bg); color: var(--text); padding: 2em; font-size: 18px; }
h1 { color: var(--primary); }
h2 { color: var(--primary); font-size: 1em; margin-top: 2em; }
a { color: var(--accent); }
code { background: var(--surface); padding: 0.2em 0.5em; border-radius: 3px; }
nav { margin-bottom: 1.5em; font-size: 0.95em; }
nav a { margin-right: 1.5em; text-decoration: none; }
nav a:hover { text-decoration: underline; }
nav .active { color: var(--primary); font-weight: bold; }
nav .logout { color: #888; float: right; cursor: pointer; }
nav .logout:hover { color: var(--primary); }
form {
background: var(--surface);
padding: 1.5em;
border-radius: 6px;
max-width: 400px;
margin: 1.5em 0;
}
label { display: block; margin-top: 0.8em; color: var(--text-muted); font-size: 0.9em; }
input {
display: block;
width: 100%;
padding: 0.4em;
margin-top: 0.2em;
background: var(--input);
border: 1px solid var(--border);
color: var(--text);
font-family: monospace;
font-size: 1em;
border-radius: 3px;
box-sizing: border-box;
}
input:focus { outline: none; border-color: var(--primary); }
button {
margin-top: 1.2em;
padding: 0.5em 1.5em;
background: var(--primary);
color: #fff;
border: none;
font-family: monospace;
font-size: 1em;
border-radius: 3px;
cursor: pointer;
}
button:hover { background: var(--primary-hover); }
button:disabled { background: #555; cursor: default; }
#error { color: var(--primary); margin-top: 0.8em; white-space: pre-wrap; }
#sessions { margin-top: 2em; }
#sessions table { border-collapse: collapse; width: 100%; }
#sessions th, #sessions td {
text-align: left; padding: 0.4em 0.8em; border-bottom: 1px solid var(--border);
}
#sessions th { color: var(--text-muted); font-size: 0.85em; }
#sessions a { text-decoration: none; }
#sessions a:hover { text-decoration: underline; }
.status-pending { color: #f0c040; }
.status-active { color: var(--accent); }
.status-completed { color: #888; }
.status-error { color: var(--primary); }
.status-expired { color: #666; }
.btn-small {
background: none; border: none; color: var(--primary);
cursor: pointer; font-family: monospace; padding: 0; font-size: 0.9em;
}
.btn-small:hover { text-decoration: underline; }
.btn-share { color: var(--accent); }
/* Hop cards */
.hop-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
margin-bottom: 0.6em;
}
.hop-card-header {
display: flex;
align-items: center;
gap: 0.5em;
padding: 0.4em 0.6em;
cursor: pointer;
font-size: 0.9em;
}
.hop-card-header:hover { background: var(--input); }
.hop-card-header .hop-num {
color: var(--accent);
font-weight: bold;
min-width: 2em;
}
.hop-card-header .hop-hostname {
flex: 1;
color: var(--text);
}
.hop-card-header .hop-hostname input {
display: inline;
width: 100%;
padding: 0.2em 0.4em;
background: var(--input);
border: 1px solid var(--border);
color: var(--text);
font-family: monospace;
font-size: 1em;
border-radius: 3px;
box-sizing: border-box;
}
.hop-card-header .hop-remove {
color: var(--primary);
cursor: pointer;
font-size: 0.85em;
background: none;
border: none;
padding: 0.2em 0.4em;
font-family: monospace;
}
.hop-card-header .hop-remove:hover { text-decoration: underline; }
.hop-card-body {
padding: 0.4em 0.6em 0.6em 2.6em;
display: none;
}
.hop-card-body.expanded { display: block; }
.hop-card-body label { margin-top: 0.5em; }
.hop-card-body input, .hop-card-body textarea {
display: block;
width: 100%;
padding: 0.3em;
margin-top: 0.2em;
background: var(--input);
border: 1px solid var(--border);
color: var(--text);
font-family: monospace;
font-size: 0.95em;
border-radius: 3px;
box-sizing: border-box;
}
/* Flow diagram */
.flow-diagram {
display: flex;
align-items: center;
gap: 0;
flex-wrap: wrap;
margin-bottom: 0.8em;
font-size: 0.85em;
}
.flow-node {
display: inline-block;
padding: 0.2em 0.6em;
border-radius: 3px;
white-space: nowrap;
}
.flow-node-you { background: var(--input); color: var(--text-muted); }
.flow-node-hop { background: #1b4332; color: #52b788; }
.flow-node-target { background: var(--input); color: var(--accent); font-weight: bold; }
.flow-arrow { color: var(--text-muted); margin: 0 0.3em; }
.btn-add-hop {
background: var(--input);
color: var(--accent);
border: 1px solid var(--border);
font-size: 0.85em;
padding: 0.3em 0.8em;
margin-top: 0;
}
.btn-add-hop:hover { background: var(--surface); }
.share-url {
background: var(--input);
padding: 0.3em 0.6em;
border-radius: 3px;
font-size: 0.85em;
word-break: break-all;
display: none;
margin-top: 0.3em;
}
</style>
</head>
<body>
<img id="site-logo" src="/logo.svg" style="max-height:40px;vertical-align:middle;margin-right:0.5em" alt=""><h1 style="display:inline;vertical-align:middle">rustguac</h1>
<nav>
<a href="/addressbook.html">Address Book</a>
<a href="/sessions.html" class="active" id="sessions-link">Sessions</a>
<a href="/recordings.html">Recordings</a>
<a href="/docs.html">Docs</a>
<a href="/tokens.html" id="tokens-link" style="display:none">Tokens</a>
<a href="/admin.html" id="admin-link" style="display:none">Admin</a>
<span class="logout" id="logout-btn">logout</span>
</nav>
<div id="adhoc-notice" style="display:none;background:var(--surface);padding:1em 1.5em;border-radius:6px;max-width:400px;margin:1.5em 0;color:var(--text-muted);">
Use the <a href="/addressbook.html">Address Book</a> to connect to saved entries.
</div>
<form id="session-form" style="display:none">
<strong>New Session</strong>
<label>Type
<select id="session-type" style="display:block;width:100%;padding:0.4em;margin-top:0.2em;background:var(--input);border:1px solid var(--border);color:var(--text);font-family:monospace;font-size:1em;border-radius:3px;">
<option value="ssh" selected>SSH</option>
<option value="rdp">RDP</option>
<option value="vnc">VNC</option>
<option value="web">Web</option>
</select>
</label>
<div id="ssh-fields">
<label>Host
<input type="text" id="hostname" placeholder="192.168.1.1">
</label>
<label>Port
<input type="number" id="port" value="22">
</label>
<label>Username (optional — SSH will prompt if empty)
<input type="text" id="username">
</label>
<label>Password (optional — SSH will prompt if empty)
<input type="password" id="password">
</label>
<label style="margin-top:0.8em">
<input type="checkbox" id="generate-keypair" style="display:inline;width:auto;margin-right:0.4em"> Generate ephemeral SSH keypair
</label>
<div id="private-key-field">
<label>Private key (optional — paste OpenSSH PEM)
<textarea id="private-key" rows="4" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" style="display:block;width:100%;padding:0.4em;margin-top:0.2em;background:var(--input);border:1px solid var(--border);color:var(--text);font-family:monospace;font-size:0.85em;border-radius:3px;box-sizing:border-box;resize:vertical;"></textarea>
</label>
<div style="color:var(--primary);font-size:0.8em;margin-top:0.3em;">Warning: Do not paste important production keys here. Use ephemeral or test keys only.</div>
</div>
</div>
<div id="rdp-fields" style="display:none">
<label>Host
<input type="text" id="rdp-hostname" placeholder="192.168.1.1">
</label>
<label>Port
<input type="number" id="rdp-port" value="3389">
</label>
<label>Username
<input type="text" id="rdp-username">
</label>
<label>Password
<input type="password" id="rdp-password">
</label>
<label>Domain (optional)
<input type="text" id="rdp-domain">
</label>
<label>Security
<select id="rdp-security" style="display:block;width:100%;padding:0.4em;margin-top:0.2em;background:var(--input);border:1px solid var(--border);color:var(--text);font-family:monospace;font-size:1em;border-radius:3px;">
<option value="" selected>Default</option>
<option value="tls">TLS</option>
<option value="nla">NLA</option>
<option value="rdp">RDP</option>
</select>
</label>
<label style="margin-top:0.8em">
<input type="checkbox" id="rdp-ignore-cert" style="display:inline;width:auto;margin-right:0.4em"> Ignore certificate errors
</label>
</div>
<div id="vnc-fields" style="display:none">
<label>Host
<input type="text" id="vnc-hostname" placeholder="192.168.1.1">
</label>
<label>Port
<input type="number" id="vnc-port" value="5900">
</label>
<label>Password (optional)
<input type="password" id="vnc-password">
</label>
</div>
<div id="web-fields" style="display:none">
<label>URL
<input type="text" id="url" placeholder="https://example.com">
</label>
</div>
<div id="jump-section" style="display:none;margin-top:1em;padding-top:0.8em;border-top:1px solid var(--border)">
<label style="cursor:pointer;color:var(--accent);font-size:0.95em" id="jump-toggle">
<span id="jump-arrow">&#9654;</span> SSH Tunnel / Jump Hosts
</label>
<div id="jump-fields" style="display:none">
<div style="color:#555;font-size:0.8em;margin-bottom:0.5em">Route through one or more SSH bastion hosts (multi-hop).</div>
<div id="flow-diagram" class="flow-diagram"></div>
<div id="hops-list"></div>
<button type="button" class="btn-add-hop" id="add-hop-btn">+ Add Jump Host</button>
</div>
</div>
<label>Banner message (shown to user before session starts)
<input type="text" id="banner" placeholder="e.g. Please enter your credentials when prompted">
</label>
<button type="submit" id="connect-btn">Connect</button>
<div id="error"></div>
</form>
<div id="sessions">
<strong>Sessions</strong>
<table>
<thead><tr>
<th>ID</th><th>Type</th><th>Host</th><th>User</th><th>Owner</th><th>Status</th><th>Viewers</th><th></th><th></th><th></th>
</tr></thead>
<tbody id="session-list"></tbody>
</table>
</div>
<script>
function applyTheme(theme) {
if (!theme) return;
var props = {primary_color:'--primary',primary_hover:'--primary-hover',accent_color:'--accent',accent_hover:'--accent-hover',bg_color:'--bg',surface_color:'--surface',input_color:'--input',text_color:'--text',text_muted:'--text-muted',border_color:'--border'};
var r = document.documentElement.style;
for (var k in props) { if (theme[k]) r.setProperty(props[k], theme[k]); }
if (theme.logo_url) { var logo = document.getElementById('site-logo'); if (logo) { logo.src = theme.logo_url; logo.style.display = ''; } }
}
fetch('/api/auth/status').then(function(r){return r.json()}).then(function(d){
if(d.site_title){document.title=d.site_title+' - Sessions';document.querySelector('h1').textContent=d.site_title;}
applyTheme(d.theme);
});
var apiKey = sessionStorage.getItem('rustguac_api_key');
// Check auth and role
var roleLevel = { admin: 4, poweruser: 3, operator: 2, viewer: 1 };
function checkRole() {
fetch('/api/me', { headers: apiHeaders(), credentials: 'same-origin' })
.then(function(res) {
if (!res.ok) { window.location.href = '/'; return; }
return res.json();
})
.then(function(data) {
if (!data) return;
var level = roleLevel[data.role] || 0;
if (level < 3) {
window.location.href = '/addressbook.html';
return;
}
if (data.role === 'admin') {
document.getElementById('admin-link').style.display = '';
}
if (level >= 2) {
document.getElementById('tokens-link').style.display = '';
}
if (level >= 3) {
// poweruser+ can create ad-hoc sessions
document.getElementById('session-form').style.display = '';
}
})
.catch(function() { window.location.href = '/'; });
}
if (apiKey) {
// API key users are always admin
document.getElementById('session-form').style.display = '';
document.getElementById('admin-link').style.display = '';
document.getElementById('tokens-link').style.display = '';
} else {
checkRole();
}
document.getElementById('logout-btn').addEventListener('click', function() {
sessionStorage.removeItem('rustguac_api_key');
// Also clear server session cookie via logout endpoint
fetch('/auth/logout', { credentials: 'same-origin' })
.finally(function() { window.location.href = '/'; });
});
function apiHeaders(extra) {
var h = {};
if (apiKey) h['Authorization'] = 'Bearer ' + apiKey;
if (extra) { for (var k in extra) h[k] = extra[k]; }
return h;
}
var form = document.getElementById('session-form');
var errorEl = document.getElementById('error');
var btn = document.getElementById('connect-btn');
var sessionType = document.getElementById('session-type');
var sshFields = document.getElementById('ssh-fields');
var rdpFields = document.getElementById('rdp-fields');
var vncFields = document.getElementById('vnc-fields');
var webFields = document.getElementById('web-fields');
var jumpSection = document.getElementById('jump-section');
var generateKeypair = document.getElementById('generate-keypair');
var privateKeyField = document.getElementById('private-key-field');
var passwordField = document.getElementById('password');
generateKeypair.addEventListener('change', function() {
if (generateKeypair.checked) {
privateKeyField.style.display = 'none';
passwordField.disabled = true;
passwordField.value = '';
} else {
privateKeyField.style.display = '';
passwordField.disabled = false;
}
});
sessionType.addEventListener('change', function() {
sshFields.style.display = 'none';
rdpFields.style.display = 'none';
vncFields.style.display = 'none';
webFields.style.display = 'none';
var v = sessionType.value;
if (v === 'ssh') sshFields.style.display = '';
else if (v === 'rdp') rdpFields.style.display = '';
else if (v === 'vnc') vncFields.style.display = '';
else if (v === 'web') webFields.style.display = '';
// Show jump host section for all session types
jumpSection.style.display = '';
});
document.getElementById('jump-toggle').addEventListener('click', function() {
var fields = document.getElementById('jump-fields');
var arrow = document.getElementById('jump-arrow');
if (fields.style.display === 'none') {
fields.style.display = '';
arrow.innerHTML = '&#9660;';
} else {
fields.style.display = 'none';
arrow.innerHTML = '&#9654;';
}
});
// ── Escape helpers ──
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;');
}
// ── Multi-hop management ──
var hopList = []; // Array of {hostname, port, username, password, private_key, expanded}
function renderHops() {
var container = document.getElementById('hops-list');
container.innerHTML = '';
hopList.forEach(function(hop, i) {
var card = document.createElement('div');
card.className = 'hop-card';
// Header
var header = document.createElement('div');
header.className = 'hop-card-header';
var num = document.createElement('span');
num.className = 'hop-num';
num.textContent = '#' + (i + 1);
header.appendChild(num);
var hostWrap = document.createElement('span');
hostWrap.className = 'hop-hostname';
var hostInput = document.createElement('input');
hostInput.type = 'text';
hostInput.placeholder = 'bastion.example.com';
hostInput.value = hop.hostname || '';
hostInput.setAttribute('data-hop', i);
hostInput.setAttribute('data-field', 'hostname');
hostInput.addEventListener('input', onHopFieldChange);
hostInput.addEventListener('click', function(e) { e.stopPropagation(); });
hostWrap.appendChild(hostInput);
header.appendChild(hostWrap);
var toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'hop-remove';
toggle.textContent = hop.expanded ? 'collapse' : 'expand';
toggle.setAttribute('data-hop', i);
toggle.addEventListener('click', function(e) {
e.stopPropagation();
hopList[i].expanded = !hopList[i].expanded;
renderHops();
});
header.appendChild(toggle);
var removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'hop-remove';
removeBtn.textContent = 'remove';
removeBtn.setAttribute('data-hop', i);
removeBtn.addEventListener('click', function(e) {
e.stopPropagation();
hopList.splice(i, 1);
renderHops();
updateFlowDiagram();
});
header.appendChild(removeBtn);
card.appendChild(header);
// Body (collapsible)
var body = document.createElement('div');
body.className = 'hop-card-body' + (hop.expanded ? ' expanded' : '');
body.innerHTML =
'<label>SSH Port<input type="number" data-hop="' + i + '" data-field="port" value="' + (hop.port || 22) + '"></label>' +
'<label>Username<input type="text" data-hop="' + i + '" data-field="username" value="' + escapeAttr(hop.username || '') + '"></label>' +
'<label>Password<input type="password" data-hop="' + i + '" data-field="password" value="' + escapeAttr(hop.password || '') + '"></label>' +
'<label>Private key (PEM)<textarea data-hop="' + i + '" data-field="private_key" rows="2" style="resize:vertical;font-size:0.85em" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----">' + escapeHtml(hop.private_key || '') + '</textarea></label>';
var inputs = body.querySelectorAll('input, textarea');
for (var j = 0; j < inputs.length; j++) {
inputs[j].addEventListener('input', onHopFieldChange);
}
card.appendChild(body);
container.appendChild(card);
});
updateFlowDiagram();
}
function onHopFieldChange(e) {
var idx = parseInt(e.target.getAttribute('data-hop'));
var field = e.target.getAttribute('data-field');
if (field === 'port') {
hopList[idx][field] = parseInt(e.target.value) || 22;
} else {
hopList[idx][field] = e.target.value;
}
if (field === 'hostname') updateFlowDiagram();
}
function updateFlowDiagram() {
var diag = document.getElementById('flow-diagram');
if (!diag) return;
var html = '<span class="flow-node flow-node-you">You</span>';
hopList.forEach(function(hop) {
var label = hop.hostname || '???';
html += '<span class="flow-arrow">&rarr;</span><span class="flow-node flow-node-hop">' + escapeHtml(label) + '</span>';
});
// Add target
var type = sessionType.value;
var targetHost = '';
var targetPort = '';
var typeLabel = type.toUpperCase();
if (type === 'ssh') {
targetHost = document.getElementById('hostname').value || '???';
targetPort = document.getElementById('port').value || '22';
} else if (type === 'rdp') {
targetHost = document.getElementById('rdp-hostname').value || '???';
targetPort = document.getElementById('rdp-port').value || '3389';
} else if (type === 'vnc') {
targetHost = document.getElementById('vnc-hostname').value || '???';
targetPort = document.getElementById('vnc-port').value || '5900';
} else if (type === 'web') {
// Parse host:port from URL
try {
var u = new URL(document.getElementById('url').value);
targetHost = u.hostname || '???';
targetPort = u.port || (u.protocol === 'https:' ? '443' : '80');
} catch(e) {
targetHost = '???';
targetPort = '80';
}
}
if (targetHost) {
html += '<span class="flow-arrow">&rarr;</span><span class="flow-node flow-node-target">' + escapeHtml(targetHost) + ':' + escapeHtml(targetPort) + ' ' + typeLabel + '</span>';
}
// Show URL rewrite warning for web sessions with hops
var warn = '';
if (type === 'web' && hopList.length > 0) {
warn = '<div style="color:var(--primary);font-size:0.8em;margin-top:0.4em">Note: The URL will be rewritten to 127.0.0.1:{tunnel_port} inside the headless browser. TLS certificate errors are expected if the target uses HTTPS.</div>';
}
diag.innerHTML = hopList.length > 0 ? html + warn : '';
}
document.getElementById('add-hop-btn').addEventListener('click', function() {
hopList.push({ hostname: '', port: 22, username: '', password: '', private_key: '', expanded: true });
renderHops();
});
form.addEventListener('submit', function(e) {
e.preventDefault();
errorEl.textContent = '';
btn.disabled = true;
btn.textContent = 'Connecting...';
var body = {};
if (sessionType.value === 'web') {
body.session_type = 'web';
body.url = document.getElementById('url').value;
} else if (sessionType.value === 'vnc') {
body.session_type = 'vnc';
body.hostname = document.getElementById('vnc-hostname').value;
body.port = parseInt(document.getElementById('vnc-port').value) || 5900;
var pw = document.getElementById('vnc-password').value;
if (pw) body.password = pw;
} else if (sessionType.value === 'rdp') {
body.session_type = 'rdp';
body.hostname = document.getElementById('rdp-hostname').value;
body.port = parseInt(document.getElementById('rdp-port').value) || 3389;
var un = document.getElementById('rdp-username').value;
if (un) body.username = un;
var pw = document.getElementById('rdp-password').value;
if (pw) body.password = pw;
var dom = document.getElementById('rdp-domain').value;
if (dom) body.domain = dom;
var sec = document.getElementById('rdp-security').value;
if (sec) body.security = sec;
if (document.getElementById('rdp-ignore-cert').checked) body.ignore_cert = true;
} else {
body.session_type = 'ssh';
body.hostname = document.getElementById('hostname').value;
body.port = parseInt(document.getElementById('port').value) || 22;
var un = document.getElementById('username').value;
if (un) body.username = un;
if (document.getElementById('generate-keypair').checked) {
body.generate_keypair = true;
} else {
var pw = document.getElementById('password').value;
if (pw) body.password = pw;
var pk = document.getElementById('private-key').value.trim();
if (pk) body.private_key = pk;
}
}
// Jump hosts (multi-hop, all session types)
var hops = hopList.filter(function(h) { return h.hostname && h.hostname.trim(); })
.map(function(h) {
var hop = { hostname: h.hostname.trim(), port: h.port || 22, username: h.username || '' };
if (h.password) hop.password = h.password;
if (h.private_key) hop.private_key = h.private_key;
return hop;
});
if (hops.length > 0) body.jump_hosts = hops;
var banner = document.getElementById('banner').value;
if (banner) body.banner = banner;
fetch('/api/sessions', {
method: 'POST',
headers: apiHeaders({ 'Content-Type': 'application/json' }),
credentials: 'same-origin',
body: JSON.stringify(body),
})
.then(function(res) {
if (!res.ok) return res.text().then(function(t) { throw new Error(t); });
return res.json();
})
.then(function(data) {
window.open(data.client_url, '_blank');
hopList = [];
renderHops();
refreshSessions();
})
.catch(function(err) {
errorEl.textContent = err.message;
})
.finally(function() {
btn.disabled = false;
btn.textContent = 'Connect';
});
});
var expandedShares = {};
function h(tag, text, attrs) {
var el = document.createElement(tag);
if (text != null) el.textContent = text;
if (attrs) for (var k in attrs) el.setAttribute(k, attrs[k]);
return el;
}
function refreshSessions() {
fetch('/api/sessions', { headers: apiHeaders(), credentials: 'same-origin' })
.then(function(res) {
if (!res.ok) throw new Error('auth');
return res.json();
})
.then(function(sessions) {
var tbody = document.getElementById('session-list');
tbody.innerHTML = '';
sessions.forEach(function(s) {
var tr = document.createElement('tr');
var shortId = s.session_id.substring(0, 8);
var shareFullUrl = window.location.origin + s.share_url;
var rowId = 'share-' + s.session_id;
var hostCol = s.session_type === 'web' ? (s.url || '') : s.hostname;
var tdId = document.createElement('td');
var idLink = h('a', shortId, { href: s.client_url, target: '_blank' });
tdId.appendChild(idLink);
tr.appendChild(tdId);
tr.appendChild(h('td', s.session_type));
tr.appendChild(h('td', hostCol));
tr.appendChild(h('td', s.username));
tr.appendChild(h('td', s.created_by || ''));
tr.appendChild(h('td', s.status, { 'class': 'status-' + s.status }));
tr.appendChild(h('td', String(s.active_connections)));
var tdOpen = document.createElement('td');
tdOpen.appendChild(h('a', 'open', { href: s.client_url, target: '_blank' }));
tr.appendChild(tdOpen);
var tdShare = document.createElement('td');
tdShare.appendChild(h('button', 'share', { 'class': 'btn-small btn-share', 'data-url': shareFullUrl, 'data-row': rowId }));
tr.appendChild(tdShare);
var tdDel = document.createElement('td');
tdDel.appendChild(h('button', 'delete', { 'class': 'btn-small', 'data-delete': s.session_id }));
tr.appendChild(tdDel);
tbody.appendChild(tr);
var shareTr = document.createElement('tr');
shareTr.id = rowId;
shareTr.style.display = expandedShares[rowId] ? '' : 'none';
var shareTd = document.createElement('td');
shareTd.colSpan = 10;
var shareDiv = h('div', null, { 'class': 'share-url', style: 'display:block' });
shareDiv.appendChild(h('span', shareFullUrl));
shareDiv.appendChild(document.createTextNode(' '));
shareDiv.appendChild(h('button', 'copy', { 'class': 'btn-small btn-copy', 'data-copy-url': shareFullUrl }));
shareTd.appendChild(shareDiv);
shareTr.appendChild(shareTd);
tbody.appendChild(shareTr);
});
});
}
document.getElementById('session-list').addEventListener('click', function(e) {
if (e.target.getAttribute('data-delete')) {
var id = e.target.getAttribute('data-delete');
fetch('/api/sessions/' + id, { method: 'DELETE', headers: apiHeaders(), credentials: 'same-origin' })
.then(function() { refreshSessions(); });
}
if (e.target.getAttribute('data-copy-url')) {
var url = e.target.getAttribute('data-copy-url');
var copyBtn = e.target;
function onCopied() {
copyBtn.textContent = 'copied!';
setTimeout(function() { copyBtn.textContent = 'copy'; }, 2000);
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(onCopied).catch(function() {
// Fallback for insecure contexts
var ta = document.createElement('textarea');
ta.value = url;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
onCopied();
});
} else {
var ta = document.createElement('textarea');
ta.value = url;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
onCopied();
}
return;
}
if (e.target.classList.contains('btn-share')) {
var rowId = e.target.getAttribute('data-row');
var row = document.getElementById(rowId);
if (row) {
var show = row.style.display === 'none';
row.style.display = show ? '' : 'none';
expandedShares[rowId] = show;
}
}
});
refreshSessions();
setInterval(refreshSessions, 5000);
</script>
</body>
</html>