Files
rustguac/static/sessions.html
T
Dave Kempe 3cf21933ea Add credential prompting for /api/connect deep-links, fix NetBox webhook docs
When an address book entry has prompt_credentials: true or no stored
credentials, /api/connect now returns an inline credential form instead
of failing or connecting without auth. The form POSTs to the existing
connect endpoint and redirects to the client page.

Fix NetBox webhook body template docs: use "type" not "session_type"
(matches Vault storage format), replace regex_replace/cut filters with
standard Jinja2 equivalents (lower, split) since NetBox's Jinja2
environment doesn't include Ansible or Django template filters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 19:14:53 +11:00

777 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 = '';
});
// Show jump section on initial load (default is SSH)
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>