e482f52f10
The uid_-_target grammar-mode SSH command was documented in the README but nowhere in the UI itself -- users had to remember/reconstruct the format by hand. Adds a "Quick Jump" card with a one-click-copy command for the interactive-picker form, plus a copy button on every row of "Hosts you can reach" that copies the exact grammar-mode command for that specific host (using the logged-in user's own uid, so it's ready to paste and run as-is). conf.ssh.listenPort is now passed to the dashboard view so the command can include the right -p flag when the SSH front door isn't on the default port 22 (theta-env, for example, exposes it on 2222). Verified live: logged in as the local admin user, confirmed the Quick Jump command and a per-host command both populate correctly and copy to the clipboard (toast confirmation), and that the per-host command matches the exact uid_-_target grammar the SSH server's parseUsername expects.
242 lines
9.7 KiB
Plaintext
242 lines
9.7 KiB
Plaintext
<%- include('top') %>
|
||
<script type="text/javascript">app.auth.forceLogin();</script>
|
||
|
||
<div class="row g-3 mb-4">
|
||
<div class="col-6 col-md-3">
|
||
<div class="card shadow-sm text-center"><div class="card-body">
|
||
<div class="display-6" id="stat-active">–</div>
|
||
<div class="text-muted small text-uppercase">Active sessions</div>
|
||
</div></div>
|
||
</div>
|
||
<div class="col-6 col-md-3">
|
||
<div class="card shadow-sm text-center"><div class="card-body">
|
||
<div class="display-6" id="stat-total">–</div>
|
||
<div class="text-muted small text-uppercase">Total connections</div>
|
||
</div></div>
|
||
</div>
|
||
<div class="col-6 col-md-3">
|
||
<div class="card shadow-sm text-center"><div class="card-body">
|
||
<div class="display-6 text-danger" id="stat-fail">–</div>
|
||
<div class="text-muted small text-uppercase">Failed</div>
|
||
</div></div>
|
||
</div>
|
||
<div class="col-6 col-md-3">
|
||
<div class="card shadow-sm text-center"><div class="card-body">
|
||
<div class="display-6" id="stat-users">–</div>
|
||
<div class="text-muted small text-uppercase">Users seen</div>
|
||
</div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row g-3 mb-4">
|
||
<div class="col-12">
|
||
<div class="card shadow-sm">
|
||
<div class="card-header"><i class="fa-solid fa-terminal me-1"></i> Quick Jump</div>
|
||
<div class="card-body">
|
||
<p class="text-muted small mb-2">
|
||
Skip the picker: <code>ssh <your-username>_-_<host-slug>@<this-jump-host></code>
|
||
connects straight to a host. Or just <code>ssh <your-username>@<this-jump-host></code>
|
||
for the interactive picker.
|
||
</p>
|
||
<div class="input-group">
|
||
<input type="text" class="form-control font-monospace" id="quick-jump-cmd" readonly>
|
||
<button class="btn btn-outline-secondary" onclick="copySshCommand('#quick-jump-cmd')" title="Copy">
|
||
<i class="fa-solid fa-copy"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row g-3 mb-4">
|
||
<div class="col-12">
|
||
<div class="card shadow-sm">
|
||
<div class="card-header"><i class="fa-solid fa-network-wired me-1"></i> <span id="my-hosts-title">Hosts you can reach</span></div>
|
||
<table class="table table-sm mb-0"><tbody id="my-hosts"></tbody></table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row g-3 mb-4">
|
||
<div class="col-md-6">
|
||
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-server me-1"></i> Top hosts</div>
|
||
<table class="table table-sm mb-0"><tbody id="top-hosts"></tbody></table>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-user me-1"></i> Top users</div>
|
||
<table class="table table-sm mb-0"><tbody id="top-users"></tbody></table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row g-3">
|
||
<div class="col-12">
|
||
<div class="card shadow-sm">
|
||
<div class="card-header d-flex justify-content-between align-items-center">
|
||
<span><i class="fa-solid fa-key me-1"></i> API Tokens</span>
|
||
<button class="btn btn-sm btn-primary" onclick="createApiToken()"><i class="fa-solid fa-plus"></i> New token</button>
|
||
</div>
|
||
<div class="card-header actionMessage" style="display:none"></div>
|
||
<p class="text-muted small px-3 pt-3 mb-0">
|
||
Personal access tokens authenticate as you against this jump host's own API
|
||
(e.g. <code>GET /api/user/hosts</code>) — not for SSH login. A token carries
|
||
no group claims, so it can't reach admin-only endpoints.
|
||
</p>
|
||
<table class="table table-sm mb-0">
|
||
<thead><tr><th>Name</th><th>Created</th><th>Last used</th><th>Expires</th><th></th></tr></thead>
|
||
<tbody id="api-tokens"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script type="text/javascript">
|
||
function rows(sel, list){
|
||
var $b = $(sel).empty();
|
||
if(!list || !list.length){ $b.append('<tr><td class="text-muted">No data.</td></tr>'); return; }
|
||
list.forEach(function(x){
|
||
$b.append('<tr><td>' + app.jump.esc(x.name) + '</td><td class="text-end">' + x.count + '</td></tr>');
|
||
});
|
||
}
|
||
// The web UI and the SSH front door share a hostname, just not a port.
|
||
var SSH_PORT = <%- JSON.stringify(sshPort) %>;
|
||
function sshCommand(target){
|
||
var uid = app.auth.user && app.auth.user.username;
|
||
if(!uid) return '';
|
||
var portFlag = SSH_PORT === 22 ? '' : ' -p ' + SSH_PORT;
|
||
return 'ssh ' + uid + (target ? '_-_' + target : '') + '@' + location.hostname + portFlag;
|
||
}
|
||
function copySshCommand(sel){
|
||
var $el = $(sel);
|
||
var text = $el.val();
|
||
if(!text) return;
|
||
navigator.clipboard.writeText(text).then(function(){
|
||
app.messages.toast('Copied to clipboard', 'success');
|
||
}, function(){
|
||
app.messages.toast('Could not copy — select and copy manually', 'danger');
|
||
});
|
||
}
|
||
|
||
function hostRows(sel, hosts){
|
||
var $b = $(sel).empty();
|
||
if(!hosts || !hosts.length){ $b.append('<tr><td class="text-muted">No hosts reachable.</td></tr>'); return; }
|
||
hosts.forEach(function(h){
|
||
var addr = (h.metadata && (h.metadata.ip || h.metadata.address)) || '';
|
||
var rowId = 'host-cmd-' + h.slug.replace(/[^a-zA-Z0-9_-]/g, '');
|
||
$b.append('<tr><td>' + app.jump.esc(h.displayName || h.name || h.slug) + '</td>'
|
||
+ '<td class="text-muted small">' + app.jump.esc(h.slug) + '</td>'
|
||
+ '<td class="text-end text-muted small">' + app.jump.esc(addr) + '</td>'
|
||
+ '<td class="text-end">'
|
||
+ '<input type="hidden" id="' + rowId + '" value="' + app.jump.esc(sshCommand(h.slug)) + '">'
|
||
+ '<button class="btn btn-sm btn-outline-secondary" onclick="copySshCommand(\'#' + rowId + '\')" title="Copy quick-jump command"><i class="fa-solid fa-copy"></i></button>'
|
||
+ '</td></tr>');
|
||
});
|
||
}
|
||
function tokenRows(tokens){
|
||
var $b = $('#api-tokens').empty();
|
||
if(!tokens || !tokens.length){ $b.append('<tr><td colspan="5" class="text-muted">No API tokens.</td></tr>'); return; }
|
||
tokens.forEach(function(t){
|
||
var expires = t.expires_at ? app.jump.fmtTime(t.expires_at) : 'Never';
|
||
var lastUsed = t.last_used_on ? app.jump.fmtTime(t.last_used_on) : 'Never';
|
||
$b.append(
|
||
'<tr>'
|
||
+ '<td>' + app.jump.esc(t.name) + '</td>'
|
||
+ '<td class="text-muted small">' + app.jump.fmtTime(t.created_on) + '</td>'
|
||
+ '<td class="text-muted small">' + lastUsed + '</td>'
|
||
+ '<td class="text-muted small">' + expires + '</td>'
|
||
+ '<td class="text-end">'
|
||
+ '<button class="btn btn-sm btn-outline-secondary" onclick="rotateApiToken(\'' + t.id + '\', this)" title="Rotate"><i class="fa-solid fa-rotate"></i></button> '
|
||
+ '<button class="btn btn-sm btn-outline-danger" onclick="revokeApiToken(\'' + t.id + '\', this)" title="Revoke"><i class="fa-solid fa-trash"></i></button>'
|
||
+ '</td>'
|
||
+ '</tr>'
|
||
);
|
||
});
|
||
}
|
||
|
||
function loadApiTokens(){
|
||
app.apiToken.list(function(error, data){
|
||
if(error) return tokenRows([]);
|
||
tokenRows(data && data.results);
|
||
});
|
||
}
|
||
|
||
function showToken(title, token){
|
||
app.modal.open({title: title, bodyHtml:
|
||
'<p class="text-danger"><i class="fa-solid fa-triangle-exclamation"></i> Save this token now — it will <strong>not</strong> be shown again.</p>'
|
||
+ '<div class="input-group"><input type="text" class="form-control font-monospace" readonly value="' + app.jump.esc(token) + '"></div>'
|
||
+ '<p class="mt-3 mb-0 text-muted small">Use it as a bearer token:<br><code>Authorization: Bearer ' + app.jump.esc(token) + '</code></p>'
|
||
});
|
||
}
|
||
|
||
function createApiToken(){
|
||
var $body = app.modal.open({title: 'New API Token', bodyHtml:
|
||
'<div class="mb-3">'
|
||
+ '<label class="form-label">Name</label>'
|
||
+ '<input type="text" class="form-control" id="new-token-name" placeholder="e.g. laptop-cron">'
|
||
+ '</div>'
|
||
+ '<div class="mb-3">'
|
||
+ '<label class="form-label">Expires in (days, blank = never)</label>'
|
||
+ '<input type="number" class="form-control" id="new-token-days" min="1">'
|
||
+ '</div>'
|
||
+ '<button class="btn btn-primary" onclick="submitApiToken()"><i class="fa-solid fa-check"></i> Create</button>'
|
||
});
|
||
$body.find('#new-token-name').focus();
|
||
}
|
||
|
||
function submitApiToken(){
|
||
var name = $('#new-token-name').val().trim();
|
||
var $card = $('#api-tokens').closest('.card');
|
||
if(!name) return app.messages.action('Name is required', $card, 'danger');
|
||
app.apiToken.add({
|
||
name: name,
|
||
expires_in_days: $('#new-token-days').val(),
|
||
}, function(error, data){
|
||
if(error) return app.messages.action((data && data.message) || 'Failed to create token', $card, 'danger');
|
||
showToken('API Token Created', data.token);
|
||
loadApiTokens();
|
||
});
|
||
}
|
||
async function revokeApiToken(id, btn){
|
||
var $card = $(btn).closest('.card');
|
||
var ok = await app.messages.confirm('Revoke this API token? It stops working immediately.', $card, 'danger');
|
||
if(!ok) return;
|
||
app.apiToken.remove(id, function(error, data){
|
||
if(error) return app.messages.action((data && data.message) || 'Failed to revoke token', $card, 'danger');
|
||
loadApiTokens();
|
||
});
|
||
}
|
||
async function rotateApiToken(id, btn){
|
||
var $card = $(btn).closest('.card');
|
||
var ok = await app.messages.confirm('Rotate this API token? The old token stops working immediately.', $card, 'warning');
|
||
if(!ok) return;
|
||
app.apiToken.rotate(id, function(error, data){
|
||
if(error) return app.messages.action((data && data.message) || 'Failed to rotate token', $card, 'danger');
|
||
showToken('API Token Rotated', data.token);
|
||
loadApiTokens();
|
||
});
|
||
}
|
||
|
||
$(document).ready(async function(){
|
||
app.jump.metrics(function(error, data){
|
||
if(error || !data) return;
|
||
$('#stat-active').text(data.active);
|
||
$('#stat-total').text(data.total);
|
||
$('#stat-fail').text(data.fail);
|
||
$('#stat-users').text((data.topUsers || []).length);
|
||
rows('#top-hosts', data.topHosts);
|
||
rows('#top-users', data.topUsers);
|
||
});
|
||
await app.auth.loadUser();
|
||
if(app.auth.isAdmin()) $('#my-hosts-title').text('All hosts');
|
||
$('#quick-jump-cmd').val(sshCommand());
|
||
app.jump.hosts(function(error, data){
|
||
if(error) return hostRows('#my-hosts', []);
|
||
hostRows('#my-hosts', data && data.results);
|
||
});
|
||
loadApiTokens();
|
||
});
|
||
</script>
|
||
<%- include('bottom') %>
|