mirror of
https://github.com/sol1/rustguac.git
synced 2026-10-26 13:39:54 +00:00
be866264bd
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
801 lines
42 KiB
HTML
801 lines
42 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>rustguac - Sessions</title>
|
|
<link rel="stylesheet" href="/rustguac.css">
|
|
<style>
|
|
form {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
padding: var(--s-4) var(--s-5);
|
|
border-radius: var(--radius);
|
|
max-width: 480px;
|
|
margin: var(--s-4) 0;
|
|
}
|
|
form input {
|
|
display: block;
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
}
|
|
form button {
|
|
margin-top: var(--s-4);
|
|
min-width: 140px;
|
|
}
|
|
#sessions { margin-top: var(--s-5); }
|
|
#sessions a { text-decoration: none; font-size: var(--fz-sm); }
|
|
#sessions a:hover { text-decoration: underline; }
|
|
#sessions a.shadow-link {
|
|
color: var(--status-pending);
|
|
font-weight: bold;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
#sessions a.shadow-link:hover { color: var(--primary); }
|
|
#sessions a.shadow-link-busy { opacity: 0.6; pointer-events: none; }
|
|
/* Owner column cues so admins can scan own vs others' sessions */
|
|
#sessions td.owner-self { color: var(--text-muted); }
|
|
#sessions td.owner-other { color: var(--accent); font-weight: bold; }
|
|
.btn-add-hop {
|
|
background: transparent;
|
|
color: var(--accent);
|
|
border: 1px solid var(--border);
|
|
font-size: var(--fz-sm);
|
|
height: var(--ctl-sm);
|
|
padding: 0 var(--s-3);
|
|
margin-top: var(--s-2);
|
|
font-weight: normal;
|
|
}
|
|
.btn-add-hop:hover { background: var(--surface); border-color: var(--accent); }
|
|
.adhoc-modal form { background: transparent; border: 0; padding: 0; margin: 0; max-width: none; width: auto; }
|
|
.adhoc-modal #new-session-fields { margin-top: 0; }
|
|
</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>
|
|
</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" class="active" id="sessions-link" data-min-role="poweruser">Sessions</a>
|
|
<a href="/recordings.html">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="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="/connections.html">Connections</a> to connect to saved entries.
|
|
</div>
|
|
|
|
<div id="adhoc-modal" class="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="adhoc-modal-title">
|
|
<div class="modal adhoc-modal">
|
|
<h3 id="adhoc-modal-title">New Ad-Hoc Session</h3>
|
|
<p class="modal-hint">A one-off session to any host. Nothing is saved; use Connections for entries you come back to.</p>
|
|
<form id="session-form">
|
|
<div id="new-session-fields">
|
|
<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>
|
|
<option value="vdi">VDI (Docker)</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>Username (optional)
|
|
<input type="text" id="vnc-username">
|
|
</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="vdi-fields" style="display:none">
|
|
<label>Container Image
|
|
<input type="text" id="vdi-image" placeholder="e.g. myregistry/xrdp-desktop:latest">
|
|
</label>
|
|
<div style="color:var(--text-muted);font-size:0.8em;margin-top:0.3em">Docker image with xrdp on port 3389. Container is ephemeral.</div>
|
|
</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">▶</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>
|
|
<div class="modal-actions">
|
|
<button type="submit" id="connect-btn">Connect</button>
|
|
<button type="button" class="btn-cancel" id="adhoc-cancel">Cancel</button>
|
|
</div>
|
|
<div id="error"></div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="sessions">
|
|
<div class="list-tools">
|
|
<input type="text" id="session-search" class="list-search" placeholder="Search sessions (user, host, owner, ID…)">
|
|
<div id="session-filters"></div>
|
|
<button type="button" class="btn-add list-action" id="new-session-btn" style="display:none" title="Start a one-off session to any host. Nothing is saved.">⚡ New ad-hoc session</button>
|
|
</div>
|
|
<span class="section-count" id="session-counts" style="display:none"></span>
|
|
<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>
|
|
</tr></thead>
|
|
<tbody id="session-list"></tbody>
|
|
</table>
|
|
<p id="session-empty" class="empty" style="display:none"></p>
|
|
</div>
|
|
|
|
<script src="/filters.js"></script>
|
|
<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+' - Sessions';document.querySelector('h1').textContent=d.site_title;}
|
|
initTheme(d.theme);
|
|
});
|
|
var apiKey = sessionStorage.getItem('rustguac_api_key');
|
|
|
|
// Check auth and role
|
|
var roleLevel = { admin: 4, poweruser: 3, operator: 2, viewer: 1 };
|
|
var myName = null;
|
|
var myRole = null;
|
|
function checkRole() {
|
|
fetch('/api/me', { headers: apiHeaders(), 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;
|
|
myName = data.name || null;
|
|
myRole = data.role || null;
|
|
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 < 3) {
|
|
window.location.href = '/connections.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('new-session-btn').style.display = '';
|
|
document.getElementById('reports-link').style.display = '';
|
|
}
|
|
})
|
|
.catch(function() {});
|
|
}
|
|
if (apiKey) {
|
|
// A key is never on the page GET, so the server served the anonymous variant.
|
|
document.documentElement.setAttribute('data-role-level', '4');
|
|
// API key users are always admin
|
|
myRole = 'admin';
|
|
document.getElementById('new-session-btn').style.display = '';
|
|
document.getElementById('admin-link').style.display = '';
|
|
document.getElementById('tokens-link').style.display = '';
|
|
document.getElementById('reports-link').style.display = '';
|
|
// Fetch /api/me anyway to learn our display_name for own-session checks.
|
|
fetch('/api/me', { headers: apiHeaders(), credentials: 'same-origin' })
|
|
.then(function(r) { return r.ok ? r.json() : null; })
|
|
.then(function(d) { if (d && d.name) myName = d.name; })
|
|
.catch(function() {});
|
|
} else {
|
|
checkRole();
|
|
}
|
|
|
|
document.getElementById('logout-item').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;
|
|
}
|
|
});
|
|
|
|
var vdiFields = document.getElementById('vdi-fields');
|
|
|
|
sessionType.addEventListener('change', function() {
|
|
sshFields.style.display = 'none';
|
|
rdpFields.style.display = 'none';
|
|
vncFields.style.display = 'none';
|
|
webFields.style.display = 'none';
|
|
vdiFields.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 = '';
|
|
else if (v === 'vdi') vdiFields.style.display = '';
|
|
// Hide jump hosts for VDI (local containers)
|
|
jumpSection.style.display = v === 'vdi' ? 'none' : '';
|
|
});
|
|
|
|
// "New ad-hoc session" opens the form in a modal; Cancel, Escape or
|
|
// a click on the backdrop closes it. The form itself is unchanged.
|
|
var adhocModal = document.getElementById('adhoc-modal');
|
|
function openAdhoc() {
|
|
adhocModal.classList.add('active');
|
|
document.getElementById('error').textContent = '';
|
|
var first = document.getElementById('session-type');
|
|
if (first) { try { first.focus(); } catch (e) {} }
|
|
}
|
|
function closeAdhoc() { adhocModal.classList.remove('active'); }
|
|
document.getElementById('new-session-btn').addEventListener('click', openAdhoc);
|
|
document.getElementById('adhoc-cancel').addEventListener('click', closeAdhoc);
|
|
adhocModal.addEventListener('mousedown', function(e) { if (e.target === adhocModal) closeAdhoc(); });
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Escape' && adhocModal.classList.contains('active')) closeAdhoc();
|
|
});
|
|
|
|
// 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 = '▼';
|
|
} else {
|
|
fields.style.display = 'none';
|
|
arrow.innerHTML = '▶';
|
|
}
|
|
});
|
|
|
|
// ── Escape helpers ──
|
|
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, '>');
|
|
}
|
|
|
|
// ── 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">→</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">→</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 === 'vdi') {
|
|
body.session_type = 'vdi';
|
|
body.container_image = document.getElementById('vdi-image').value;
|
|
} else 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 un = document.getElementById('vnc-username').value;
|
|
if (un) body.username = un;
|
|
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;
|
|
// Send browser dimensions so RDP sessions start at correct resolution
|
|
var bw = window.innerWidth, bh = window.innerHeight;
|
|
if (bw > 0 && bh > 0) {
|
|
body.width = bw;
|
|
body.height = bh;
|
|
body.dpi = Math.round((window.devicePixelRatio || 1) * 96);
|
|
}
|
|
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';
|
|
});
|
|
});
|
|
|
|
|
|
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() {
|
|
// Admins get everyone's sessions; non-admins see only their own (see #102).
|
|
fetch('/api/sessions?all=true', { headers: apiHeaders(), credentials: 'same-origin' })
|
|
.then(function(res) {
|
|
if (!res.ok) throw new Error('auth');
|
|
return res.json();
|
|
})
|
|
.then(function(sessions) {
|
|
lastSessionList = sessions;
|
|
renderSessionList(sessions);
|
|
});
|
|
}
|
|
|
|
// Shared facet filters (filters.js): Type, Status, Owner. Selections
|
|
// persist in sessionStorage for this tab. Counts in the header stay
|
|
// for the full list; the table shows the filtered one.
|
|
var lastSessionList = null;
|
|
var sessionFilters = rgFilters.create({
|
|
mount: document.getElementById('session-filters'),
|
|
id: 'sessions',
|
|
facets: [
|
|
{ key: 'type', label: 'Type', value: function(s) { return s.session_type; },
|
|
render: function(v) { return '<span class="type-badge type-' + v.replace(/[^a-z0-9-]/gi, '') + '">' + v.replace(/[<>&]/g, '') + '</span>'; } },
|
|
{ key: 'user', label: 'User', value: function(s) { return s.username; } },
|
|
{ key: 'owner', label: 'Owner', value: function(s) { return s.created_by; } },
|
|
{ key: 'status', label: 'Status', value: function(s) { return s.status; } }
|
|
],
|
|
onChange: function() { if (lastSessionList) renderSessionList(lastSessionList); }
|
|
});
|
|
function sessionSearchQuery() {
|
|
return (document.getElementById('session-search').value || '').toLowerCase();
|
|
}
|
|
document.getElementById('session-search').addEventListener('input', function() {
|
|
if (lastSessionList) renderSessionList(lastSessionList);
|
|
});
|
|
|
|
function renderSessionList(allSessions) {
|
|
sessionFilters.update(allSessions);
|
|
var sessions = sessionFilters.apply(allSessions);
|
|
var q = sessionSearchQuery();
|
|
if (q) {
|
|
sessions = sessions.filter(function(s) {
|
|
var host = (s.session_type === 'web' ? s.url : s.hostname) || '';
|
|
return (s.username || '').toLowerCase().indexOf(q) >= 0
|
|
|| host.toLowerCase().indexOf(q) >= 0
|
|
|| (s.created_by || '').toLowerCase().indexOf(q) >= 0
|
|
|| (s.session_id || '').toLowerCase().indexOf(q) >= 0
|
|
|| (s.session_type || '').toLowerCase().indexOf(q) >= 0;
|
|
});
|
|
}
|
|
var tbody = document.getElementById('session-list');
|
|
var empty = document.getElementById('session-empty');
|
|
var counts = document.getElementById('session-counts');
|
|
tbody.innerHTML = '';
|
|
|
|
var activeCount = 0;
|
|
var ownActiveCount = 0;
|
|
|
|
sessions.forEach(function(s) {
|
|
var tr = document.createElement('tr');
|
|
var shortId = s.session_id.substring(0, 8);
|
|
var hostCol = s.session_type === 'web' ? (s.url || '') : s.hostname;
|
|
var isActive = s.status === 'active';
|
|
var isOwn = myName && s.created_by === myName;
|
|
var canShadow = isActive && !isOwn && myRole === 'admin';
|
|
|
|
if (isActive) activeCount++;
|
|
if (isActive && isOwn) ownActiveCount++;
|
|
if (!isOwn) tr.classList.add('row-other');
|
|
|
|
var tdId = document.createElement('td');
|
|
var idLink = h('a', shortId, { href: s.client_url, target: '_blank' });
|
|
tdId.appendChild(idLink);
|
|
tr.appendChild(tdId);
|
|
|
|
var tdType = document.createElement('td');
|
|
tdType.appendChild(h('span', s.session_type, { 'class': 'type-badge type-' + s.session_type }));
|
|
tr.appendChild(tdType);
|
|
tr.appendChild(h('td', hostCol));
|
|
tr.appendChild(h('td', s.username));
|
|
tr.appendChild(h('td', s.created_by || '', { 'class': isOwn ? 'owner-self' : 'owner-other' }));
|
|
tr.appendChild(h('td', s.status, { 'class': 'status-' + s.status }));
|
|
tr.appendChild(h('td', String(s.active_connections)));
|
|
|
|
var tdOpen = document.createElement('td');
|
|
if (isActive && isOwn) {
|
|
tdOpen.appendChild(h('a', 'open', { href: s.client_url, target: '_blank' }));
|
|
} else if (canShadow) {
|
|
tdOpen.appendChild(h('a', 'watch', {
|
|
href: '#',
|
|
'class': 'shadow-link',
|
|
'data-shadow': s.session_id,
|
|
'data-read-only': 'true',
|
|
title: 'Join this session as a view-only viewer (10 min token, audit-logged)'
|
|
}));
|
|
tdOpen.appendChild(document.createTextNode(' '));
|
|
tdOpen.appendChild(h('a', 'shadow', {
|
|
href: '#',
|
|
'class': 'shadow-link',
|
|
'data-shadow': s.session_id,
|
|
title: 'Join this session with keyboard and mouse control (10 min token, audit-logged)'
|
|
}));
|
|
}
|
|
tr.appendChild(tdOpen);
|
|
|
|
// Delete: owner can always delete own; admin can delete any.
|
|
var canDelete = isOwn || myRole === 'admin';
|
|
var tdDel = document.createElement('td');
|
|
if (canDelete) {
|
|
tdDel.appendChild(h('button', 'delete', { 'class': 'btn-small', 'data-delete': s.session_id }));
|
|
}
|
|
tr.appendChild(tdDel);
|
|
|
|
tbody.appendChild(tr);
|
|
});
|
|
|
|
// Count indicator: "12 active · 3 yours" for admins seeing others,
|
|
// "3 active" when caller only sees their own.
|
|
if (sessions.length === 0 && allSessions.length > 0) {
|
|
empty.style.display = '';
|
|
empty.textContent = 'No sessions match the current filters.';
|
|
} else if (activeCount === 0) {
|
|
empty.style.display = '';
|
|
empty.textContent = myRole === 'admin'
|
|
? 'No active sessions.'
|
|
: 'No active sessions. Use Connections to connect to a saved entry, or the form above to start an ad-hoc session.';
|
|
} else {
|
|
empty.style.display = 'none';
|
|
}
|
|
// Header counts describe the whole list, not the filtered view.
|
|
var totalActive = 0, ownActive = 0;
|
|
allSessions.forEach(function(s) {
|
|
if (s.status !== 'active') return;
|
|
totalActive++;
|
|
if (myName && s.created_by === myName) ownActive++;
|
|
});
|
|
if (totalActive === 0) {
|
|
counts.textContent = '';
|
|
} else if (myRole === 'admin' && totalActive !== ownActive) {
|
|
counts.textContent = totalActive + ' active · ' + ownActive + ' yours' + (sessionFilters.active() ? ' · ' + sessions.length + ' shown' : '');
|
|
} else {
|
|
counts.textContent = totalActive + ' active' + (sessionFilters.active() ? ' · ' + sessions.length + ' shown' : '');
|
|
}
|
|
}
|
|
|
|
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-shadow')) {
|
|
e.preventDefault();
|
|
var id = e.target.getAttribute('data-shadow');
|
|
var link = e.target;
|
|
var originalText = link.textContent;
|
|
link.textContent = 'minting...';
|
|
link.classList.add('shadow-link-busy');
|
|
var shadowHeaders = apiHeaders();
|
|
shadowHeaders['Content-Type'] = 'application/json';
|
|
fetch('/api/sessions/' + id + '/shadow', {
|
|
method: 'POST',
|
|
headers: shadowHeaders,
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({ read_only: link.getAttribute('data-read-only') === 'true' })
|
|
})
|
|
.then(function(res) {
|
|
if (!res.ok) return res.text().then(function(t) { throw new Error(t || 'shadow failed'); });
|
|
return res.json();
|
|
})
|
|
.then(function(data) {
|
|
if (data.url) window.open(data.url, '_blank');
|
|
})
|
|
.catch(function(err) {
|
|
document.getElementById('error').textContent = 'Shadow failed: ' + err.message;
|
|
})
|
|
.finally(function() {
|
|
link.textContent = originalText;
|
|
link.classList.remove('shadow-link-busy');
|
|
});
|
|
}
|
|
});
|
|
|
|
refreshSessions();
|
|
setInterval(refreshSessions, 5000);
|
|
</script>
|
|
</body>
|
|
</html>
|