Files
jump-host/nodejs/views/dashboard.ejs
T
wmantly e482f52f10 Add a Quick Jump copy-to-clipboard section to the dashboard (#22)
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.
2026-07-28 18:10:17 -04:00

242 lines
9.7 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<%- 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 &lt;your-username&gt;_-_&lt;host-slug&gt;@&lt;this-jump-host&gt;</code>
connects straight to a host. Or just <code>ssh &lt;your-username&gt;@&lt;this-jump-host&gt;</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') %>