feat: self-hosted remote support over VNC
Browser-based remote control (noVNC) with invite links, per-user access control, garagedoor SSO and a persisted client list. The hub proxies RFB rather than pointing the browser at a VNC server. That is what lets it authenticate upstream with a stored password the browser never sees, and enforce view-only by dropping input messages on the client->server stream instead of hiding buttons. Machines are reachable two ways: direct TCP for LAN hosts, or an outbound agent tunnel for anything behind NAT. Node 22's global WebSocket keeps the agent dependency-free, and node:sqlite keeps the image free of native builds. Ships with an end-to-end suite that boots the real server against a fake VNC server and a fake auth service (72 assertions), plus Gitea Actions CI/CD to Portainer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+718
@@ -0,0 +1,718 @@
|
||||
'use strict';
|
||||
|
||||
/* Remote Support console. No build step: plain DOM, plain fetch. */
|
||||
|
||||
const TOKEN_KEY = 'rcs.token';
|
||||
|
||||
const state = {
|
||||
token: localStorage.getItem(TOKEN_KEY) || null,
|
||||
username: null,
|
||||
isAdmin: false,
|
||||
clients: [],
|
||||
tab: 'machines',
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------- helpers */
|
||||
|
||||
function h(tag, props = {}, ...children) {
|
||||
const el = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(props || {})) {
|
||||
if (v === null || v === undefined || v === false) continue;
|
||||
if (k === 'class') el.className = v;
|
||||
else if (k === 'text') el.textContent = v;
|
||||
else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2).toLowerCase(), v);
|
||||
else el.setAttribute(k, v === true ? '' : v);
|
||||
}
|
||||
for (const child of children.flat()) {
|
||||
if (child === null || child === undefined || child === false) continue;
|
||||
el.append(child.nodeType ? child : document.createTextNode(String(child)));
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function $(sel) { return document.querySelector(sel); }
|
||||
|
||||
function toast(message, ms = 2600) {
|
||||
document.getElementById('toast')?.remove();
|
||||
const el = h('div', { id: 'toast', text: message });
|
||||
document.body.append(el);
|
||||
setTimeout(() => el.remove(), ms);
|
||||
}
|
||||
|
||||
function ago(ts) {
|
||||
if (!ts) return 'never';
|
||||
const s = Math.floor((Date.now() - ts) / 1000);
|
||||
if (s < 45) return 'just now';
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
||||
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
||||
return `${Math.floor(s / 86400)}d ago`;
|
||||
}
|
||||
|
||||
function until(ts) {
|
||||
if (!ts) return 'never';
|
||||
const s = Math.floor((ts - Date.now()) / 1000);
|
||||
if (s <= 0) return 'expired';
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
||||
if (s < 86400) return `${Math.floor(s / 3600)}h`;
|
||||
return `${Math.floor(s / 86400)}d`;
|
||||
}
|
||||
|
||||
function bytes(n) {
|
||||
if (!n) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), units.length - 1);
|
||||
return `${(n / 1024 ** i).toFixed(i ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function duration(from, to) {
|
||||
const s = Math.floor(((to || Date.now()) - from) / 1000);
|
||||
const m = Math.floor(s / 60);
|
||||
return m ? `${m}m ${s % 60}s` : `${s}s`;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- api */
|
||||
|
||||
async function api(path, { method = 'GET', body } = {}) {
|
||||
const res = await fetch(`/api${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(state.token ? { Authorization: `Bearer ${state.token}` } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
// The garagedoor token is only good for an hour; expiry means log in again.
|
||||
signOut();
|
||||
throw new Error('your session expired — sign in again');
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || `request failed (${res.status})`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- modal */
|
||||
|
||||
function modal({ title, body, actions }) {
|
||||
const root = $('#modal-root');
|
||||
const close = () => { root.innerHTML = ''; document.removeEventListener('keydown', onKey); };
|
||||
const onKey = (e) => { if (e.key === 'Escape') close(); };
|
||||
document.addEventListener('keydown', onKey);
|
||||
|
||||
const box = h('div', { class: 'modal' },
|
||||
h('div', { class: 'modal-head' },
|
||||
h('h2', { text: title }),
|
||||
h('div', { class: 'spacer' }),
|
||||
h('button', { class: 'ghost small', text: '✕', onclick: close })),
|
||||
body,
|
||||
actions ? h('div', { class: 'modal-actions' }, ...actions(close)) : null);
|
||||
|
||||
const backdrop = h('div', {
|
||||
class: 'modal-backdrop',
|
||||
onclick: (e) => { if (e.target === backdrop) close(); },
|
||||
}, box);
|
||||
|
||||
root.append(backdrop);
|
||||
box.querySelector('input, select, textarea')?.focus();
|
||||
return close;
|
||||
}
|
||||
|
||||
function showLink(title, url, note) {
|
||||
const input = h('input', { value: url, readonly: true });
|
||||
modal({
|
||||
title,
|
||||
body: h('div', {},
|
||||
note ? h('p', { class: 'faint', text: note }) : null,
|
||||
h('div', { class: 'copybox' },
|
||||
input,
|
||||
h('button', {
|
||||
class: 'primary',
|
||||
text: 'Copy',
|
||||
onclick: async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
toast('Link copied');
|
||||
} catch {
|
||||
input.select();
|
||||
toast('Press ⌘C / Ctrl-C to copy');
|
||||
}
|
||||
},
|
||||
})),
|
||||
h('p', { class: 'faint', style: 'margin-bottom:0', text: 'This link is shown once. Copy it now.' })),
|
||||
actions: (close) => [h('button', { text: 'Done', onclick: close })],
|
||||
});
|
||||
input.select();
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- auth */
|
||||
|
||||
async function signIn(event) {
|
||||
event.preventDefault();
|
||||
const button = $('#login-button');
|
||||
const err = $('#login-error');
|
||||
err.classList.add('hidden');
|
||||
button.disabled = true;
|
||||
button.textContent = 'Signing in…';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: $('#username').value, password: $('#password').value }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || 'sign in failed');
|
||||
|
||||
state.token = data.token;
|
||||
state.username = data.username;
|
||||
state.isAdmin = data.isAdmin;
|
||||
localStorage.setItem(TOKEN_KEY, data.token);
|
||||
$('#password').value = '';
|
||||
showApp();
|
||||
} catch (e) {
|
||||
err.textContent = e.message;
|
||||
err.classList.remove('hidden');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = 'Sign in';
|
||||
}
|
||||
}
|
||||
|
||||
function signOut() {
|
||||
state.token = null;
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
$('#app').classList.add('hidden');
|
||||
$('#login').classList.remove('hidden');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- machines */
|
||||
|
||||
function connect(client, viewOnly) {
|
||||
const url = new URL('/viewer', location.origin);
|
||||
url.searchParams.set('client', client.id);
|
||||
if (viewOnly) url.searchParams.set('viewOnly', '1');
|
||||
window.open(url.toString(), '_blank', 'noopener');
|
||||
}
|
||||
|
||||
function clientCard(client) {
|
||||
const isAgent = client.mode === 'agent';
|
||||
const online = isAgent ? client.online : null;
|
||||
|
||||
const status = isAgent
|
||||
? h('span', { class: `dot ${online ? 'online' : 'offline'}`, title: online ? 'online' : 'offline' })
|
||||
: h('span', { class: 'dot direct', title: 'direct connection' });
|
||||
|
||||
const meta = [];
|
||||
if (isAgent) meta.push(online ? 'Online' : `Last seen ${ago(client.lastSeenAt)}`);
|
||||
else meta.push(`${client.host}:${client.port}`);
|
||||
if (client.os) meta.push(client.os);
|
||||
|
||||
const canConnect = !isAgent || online;
|
||||
|
||||
const actions = [
|
||||
h('button', {
|
||||
class: 'primary small',
|
||||
text: 'Connect',
|
||||
disabled: !canConnect,
|
||||
onclick: () => connect(client, false),
|
||||
}),
|
||||
h('button', {
|
||||
class: 'small',
|
||||
text: 'View only',
|
||||
disabled: !canConnect,
|
||||
onclick: () => connect(client, true),
|
||||
}),
|
||||
];
|
||||
|
||||
if (state.isAdmin) {
|
||||
actions.push(h('button', { class: 'ghost small', text: 'Share', onclick: () => newShareLink(client) }));
|
||||
actions.push(h('button', { class: 'ghost small', text: '⋯', onclick: () => clientMenu(client) }));
|
||||
}
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
h('div', { class: 'card-title' },
|
||||
status,
|
||||
h('h3', { text: client.name }),
|
||||
h('div', { class: 'spacer' }),
|
||||
client.requireConsent ? h('span', { class: 'tag warn', text: 'asks first' }) : null,
|
||||
client.grantedRole ? h('span', { class: 'tag role', text: client.grantedRole }) : null),
|
||||
client.description ? h('div', { class: 'faint', text: client.description }) : null,
|
||||
h('div', { class: 'faint', text: meta.join(' · ') }),
|
||||
client.tags.length ? h('div', { class: 'row wrap' }, client.tags.map((t) => h('span', { class: 'tag', text: t }))) : null,
|
||||
h('div', { class: 'card-actions' }, ...actions));
|
||||
}
|
||||
|
||||
function clientMenu(client) {
|
||||
modal({
|
||||
title: client.name,
|
||||
body: h('div', { class: 'row wrap' },
|
||||
h('button', { class: 'small', text: 'Edit', onclick: () => { $('#modal-root').innerHTML = ''; clientForm(client); } }),
|
||||
h('button', { class: 'small', text: 'Who has access', onclick: () => { $('#modal-root').innerHTML = ''; grantsDialog(client); } }),
|
||||
h('button', { class: 'small', text: 'Session history', onclick: () => { $('#modal-root').innerHTML = ''; historyDialog(client); } }),
|
||||
h('button', {
|
||||
class: 'small',
|
||||
text: client.enrolled ? 'Re-issue agent key' : 'Issue agent key',
|
||||
onclick: async () => {
|
||||
if (client.enrolled && !confirm('The current agent will be disconnected and must be reconfigured. Continue?')) return;
|
||||
const { agentKey } = await api(`/clients/${client.id}/agent-key`, { method: 'POST' });
|
||||
$('#modal-root').innerHTML = '';
|
||||
showLink('Agent key', agentKey, 'Put this in the agent config on that machine.');
|
||||
refresh();
|
||||
},
|
||||
}),
|
||||
h('button', {
|
||||
class: 'small danger',
|
||||
text: 'Delete',
|
||||
onclick: async () => {
|
||||
if (!confirm(`Delete "${client.name}"? Its grants and live sessions go with it.`)) return;
|
||||
await api(`/clients/${client.id}`, { method: 'DELETE' });
|
||||
$('#modal-root').innerHTML = '';
|
||||
toast('Machine deleted');
|
||||
refresh();
|
||||
},
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
function clientForm(client) {
|
||||
const editing = !!client;
|
||||
const c = client || { mode: 'direct', port: 5900, tags: [] };
|
||||
|
||||
const name = h('input', { value: c.name || '', required: true, placeholder: 'Reception PC' });
|
||||
const description = h('input', { value: c.description || '', placeholder: 'Optional note' });
|
||||
const host = h('input', { value: c.host || '', placeholder: '192.168.4.50' });
|
||||
const port = h('input', { type: 'number', value: c.port || 5900, min: '1', max: '65535' });
|
||||
const password = h('input', {
|
||||
type: 'password',
|
||||
placeholder: editing && c.hasPassword ? '•••••••• (unchanged)' : 'VNC password, if the server needs one',
|
||||
});
|
||||
const tags = h('input', { value: (c.tags || []).join(', '), placeholder: 'office, windows' });
|
||||
const consent = h('input', { type: 'checkbox', ...(c.requireConsent ? { checked: true } : {}) });
|
||||
const mode = h('select', {},
|
||||
h('option', { value: 'direct', ...(c.mode === 'direct' ? { selected: true } : {}) }, 'Direct — the hub can reach its VNC port'),
|
||||
h('option', { value: 'agent', ...(c.mode === 'agent' ? { selected: true } : {}) }, 'Agent — the machine dials out to the hub'));
|
||||
|
||||
const directFields = h('div', {},
|
||||
h('div', { class: 'field-row' },
|
||||
h('div', { class: 'field' }, h('label', { text: 'Host' }), host),
|
||||
h('div', { class: 'field' }, h('label', { text: 'Port' }), port)));
|
||||
|
||||
const syncMode = () => { directFields.classList.toggle('hidden', mode.value !== 'direct'); };
|
||||
mode.addEventListener('change', syncMode);
|
||||
|
||||
const error = h('div', { class: 'notice error hidden' });
|
||||
|
||||
const body = h('div', {},
|
||||
error,
|
||||
h('div', { class: 'field' }, h('label', { text: 'Name' }), name),
|
||||
h('div', { class: 'field' }, h('label', { text: 'Description' }), description),
|
||||
h('div', { class: 'field' }, h('label', { text: 'Connection' }), mode),
|
||||
directFields,
|
||||
h('div', { class: 'field' }, h('label', { text: 'VNC password' }), password),
|
||||
h('div', { class: 'field' }, h('label', { text: 'Tags' }), tags),
|
||||
h('div', { class: 'field' }, h('label', { class: 'check' }, consent, 'Ask the person at that machine before connecting')));
|
||||
|
||||
syncMode();
|
||||
|
||||
modal({
|
||||
title: editing ? 'Edit machine' : 'Add machine',
|
||||
body,
|
||||
actions: (close) => [
|
||||
h('button', { text: 'Cancel', onclick: close }),
|
||||
h('button', {
|
||||
class: 'primary',
|
||||
text: editing ? 'Save' : 'Add',
|
||||
onclick: async (e) => {
|
||||
const button = e.currentTarget;
|
||||
button.disabled = true;
|
||||
error.classList.add('hidden');
|
||||
const payload = {
|
||||
name: name.value,
|
||||
description: description.value,
|
||||
mode: mode.value,
|
||||
host: host.value,
|
||||
port: Number(port.value) || 5900,
|
||||
tags: tags.value,
|
||||
requireConsent: consent.checked,
|
||||
};
|
||||
// Leaving the password blank on an edit keeps whatever is stored.
|
||||
if (password.value || !editing) payload.vncPassword = password.value;
|
||||
|
||||
try {
|
||||
if (editing) await api(`/clients/${client.id}`, { method: 'PATCH', body: payload });
|
||||
else await api('/clients', { method: 'POST', body: payload });
|
||||
close();
|
||||
toast(editing ? 'Saved' : 'Machine added');
|
||||
refresh();
|
||||
} catch (err) {
|
||||
error.textContent = err.message;
|
||||
error.classList.remove('hidden');
|
||||
button.disabled = false;
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function grantsDialog(client) {
|
||||
const { grants } = await api(`/clients/${client.id}/grants`);
|
||||
const list = h('div', { class: 'table-wrap', style: 'margin-bottom:16px' });
|
||||
|
||||
const draw = (rows) => {
|
||||
list.innerHTML = '';
|
||||
if (!rows.length) {
|
||||
list.append(h('div', { class: 'empty', text: 'Only admins can reach this machine.' }));
|
||||
return;
|
||||
}
|
||||
list.append(h('table', {},
|
||||
h('thead', {}, h('tr', {}, h('th', { text: 'User' }), h('th', { text: 'Role' }), h('th', { text: 'Expires' }), h('th', {}))),
|
||||
h('tbody', {}, rows.map((g) => h('tr', {},
|
||||
h('td', { text: g.username }),
|
||||
h('td', { text: g.role }),
|
||||
h('td', { class: 'faint', text: g.expires_at ? until(g.expires_at) : '—' }),
|
||||
h('td', {}, h('button', {
|
||||
class: 'ghost small danger',
|
||||
text: 'Remove',
|
||||
onclick: async () => {
|
||||
await api(`/clients/${client.id}/grants/${encodeURIComponent(g.username)}`, { method: 'DELETE' });
|
||||
draw(rows.filter((r) => r.username !== g.username));
|
||||
},
|
||||
})))))));
|
||||
};
|
||||
draw(grants);
|
||||
|
||||
const username = h('input', { placeholder: 'username' });
|
||||
const role = h('select', {}, h('option', { value: 'viewer' }, 'View only'), h('option', { value: 'operator' }, 'Full control'));
|
||||
|
||||
modal({
|
||||
title: `Access to ${client.name}`,
|
||||
body: h('div', {},
|
||||
list,
|
||||
h('div', { class: 'field-row' },
|
||||
h('div', { class: 'field' }, h('label', { text: 'Add user' }), username),
|
||||
h('div', { class: 'field' }, h('label', { text: 'Role' }), role))),
|
||||
actions: (close) => [
|
||||
h('button', { text: 'Close', onclick: close }),
|
||||
h('button', {
|
||||
class: 'primary',
|
||||
text: 'Grant access',
|
||||
onclick: async () => {
|
||||
if (!username.value.trim()) return;
|
||||
await api(`/clients/${client.id}/grants`, {
|
||||
method: 'POST',
|
||||
body: { username: username.value.trim(), role: role.value },
|
||||
});
|
||||
const fresh = await api(`/clients/${client.id}/grants`);
|
||||
username.value = '';
|
||||
draw(fresh.grants);
|
||||
toast('Access granted');
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function historyDialog(client) {
|
||||
const { sessions } = await api(`/sessions/history?clientId=${encodeURIComponent(client.id)}&limit=50`);
|
||||
modal({
|
||||
title: `${client.name} — sessions`,
|
||||
body: sessions.length
|
||||
? h('div', { class: 'table-wrap' }, sessionTable(sessions))
|
||||
: h('div', { class: 'empty', text: 'No sessions recorded yet.' }),
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- invites */
|
||||
|
||||
const TTL_OPTIONS = [
|
||||
['1 hour', 3600e3],
|
||||
['8 hours', 8 * 3600e3],
|
||||
['1 day', 24 * 3600e3],
|
||||
['7 days', 7 * 24 * 3600e3],
|
||||
['30 days', 30 * 24 * 3600e3],
|
||||
];
|
||||
|
||||
function newEnrollLink() {
|
||||
const name = h('input', { placeholder: 'leave blank to use the machine name' });
|
||||
const tags = h('input', { placeholder: 'office, windows' });
|
||||
const consent = h('input', { type: 'checkbox' });
|
||||
const ttl = h('select', {}, TTL_OPTIONS.map(([label, ms], i) =>
|
||||
h('option', { value: ms, ...(i === 2 ? { selected: true } : {}) }, label)));
|
||||
|
||||
modal({
|
||||
title: 'Invite a machine',
|
||||
body: h('div', {},
|
||||
h('p', { class: 'faint', text: 'Send this to whoever is at the machine. Running the agent with it registers the machine here.' }),
|
||||
h('div', { class: 'field' }, h('label', { text: 'Name it' }), name),
|
||||
h('div', { class: 'field' }, h('label', { text: 'Tags' }), tags),
|
||||
h('div', { class: 'field' }, h('label', { text: 'Link valid for' }), ttl),
|
||||
h('div', { class: 'field' }, h('label', { class: 'check' }, consent, 'Ask the person there before each connection'))),
|
||||
actions: (close) => [
|
||||
h('button', { text: 'Cancel', onclick: close }),
|
||||
h('button', {
|
||||
class: 'primary',
|
||||
text: 'Create link',
|
||||
onclick: async () => {
|
||||
const res = await api('/invites', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
kind: 'enroll',
|
||||
name: name.value || undefined,
|
||||
tags: tags.value,
|
||||
requireConsent: consent.checked,
|
||||
ttlMs: Number(ttl.value),
|
||||
},
|
||||
});
|
||||
close();
|
||||
showLink('Enrollment link', res.url, 'Open this on the machine you want to support.');
|
||||
refresh();
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function newShareLink(preselect) {
|
||||
const target = h('select', {}, state.clients.map((c) =>
|
||||
h('option', { value: c.id, ...(preselect && c.id === preselect.id ? { selected: true } : {}) }, c.name)));
|
||||
const role = h('select', {}, h('option', { value: 'viewer' }, 'View only'), h('option', { value: 'operator' }, 'Full control'));
|
||||
const ttl = h('select', {}, TTL_OPTIONS.map(([label, ms], i) =>
|
||||
h('option', { value: ms, ...(i === 0 ? { selected: true } : {}) }, label)));
|
||||
const label = h('input', { placeholder: 'who is this for?' });
|
||||
|
||||
modal({
|
||||
title: 'Support link',
|
||||
body: h('div', {},
|
||||
h('p', { class: 'faint', text: 'Anyone with this link can connect to that machine until it expires. No sign in needed.' }),
|
||||
h('div', { class: 'field' }, h('label', { text: 'Machine' }), target),
|
||||
h('div', { class: 'field' }, h('label', { text: 'They can' }), role),
|
||||
h('div', { class: 'field' }, h('label', { text: 'Link valid for' }), ttl),
|
||||
h('div', { class: 'field' }, h('label', { text: 'Label' }), label)),
|
||||
actions: (close) => [
|
||||
h('button', { text: 'Cancel', onclick: close }),
|
||||
h('button', {
|
||||
class: 'primary',
|
||||
text: 'Create link',
|
||||
onclick: async () => {
|
||||
const res = await api('/invites', {
|
||||
method: 'POST',
|
||||
body: { kind: 'session', clientId: target.value, role: role.value, ttlMs: Number(ttl.value), label: label.value },
|
||||
});
|
||||
close();
|
||||
showLink('Support link', res.url, 'Send this to the person who needs access.');
|
||||
if (state.tab === 'invites') loadInvites();
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function loadInvites() {
|
||||
const container = $('#invites');
|
||||
const { invites } = await api('/invites');
|
||||
container.innerHTML = '';
|
||||
|
||||
if (!invites.length) {
|
||||
container.append(h('div', { class: 'empty', text: 'No invite links yet.' }));
|
||||
return;
|
||||
}
|
||||
|
||||
container.append(h('div', { class: 'table-wrap' }, h('table', {},
|
||||
h('thead', {}, h('tr', {},
|
||||
h('th', { text: 'Kind' }), h('th', { text: 'Target' }), h('th', { text: 'Label' }),
|
||||
h('th', { text: 'Role' }), h('th', { text: 'Uses' }), h('th', { text: 'Expires' }),
|
||||
h('th', { text: 'Status' }), h('th', {}))),
|
||||
h('tbody', {}, invites.map((i) => h('tr', {},
|
||||
h('td', { text: i.kind === 'enroll' ? 'Machine' : 'Support' }),
|
||||
h('td', { text: i.clientName || '—' }),
|
||||
h('td', { class: 'faint', text: i.label || '—' }),
|
||||
h('td', { text: i.role || '—' }),
|
||||
h('td', { text: i.maxUses ? `${i.uses}/${i.maxUses}` : String(i.uses) }),
|
||||
h('td', { class: 'faint', text: until(i.expiresAt) }),
|
||||
h('td', {}, h('span', { class: `tag ${i.status === 'active' ? 'role' : ''}`, text: i.status })),
|
||||
h('td', {}, i.status === 'active'
|
||||
? h('button', {
|
||||
class: 'ghost small danger',
|
||||
text: 'Revoke',
|
||||
onclick: async () => {
|
||||
await api(`/invites/${i.id}/revoke`, { method: 'POST' });
|
||||
toast('Link revoked');
|
||||
loadInvites();
|
||||
},
|
||||
})
|
||||
: h('button', {
|
||||
class: 'ghost small',
|
||||
text: 'Delete',
|
||||
onclick: async () => {
|
||||
await api(`/invites/${i.id}`, { method: 'DELETE' });
|
||||
loadInvites();
|
||||
},
|
||||
}))))))));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- sessions */
|
||||
|
||||
function sessionTable(rows) {
|
||||
return h('table', {},
|
||||
h('thead', {}, h('tr', {},
|
||||
h('th', { text: 'Machine' }), h('th', { text: 'Who' }), h('th', { text: 'Role' }),
|
||||
h('th', { text: 'Started' }), h('th', { text: 'Length' }), h('th', { text: 'Traffic' }), h('th', { text: 'Ended' }))),
|
||||
h('tbody', {}, rows.map((s) => h('tr', {},
|
||||
h('td', { text: s.client_name || s.clientName || '—' }),
|
||||
h('td', { text: s.username }),
|
||||
h('td', {}, h('span', { class: 'tag', text: s.role })),
|
||||
h('td', { class: 'faint', text: ago(s.started_at || s.startedAt) }),
|
||||
h('td', { class: 'faint', text: duration(s.started_at || s.startedAt, s.ended_at) }),
|
||||
h('td', { class: 'faint', text: bytes((s.bytes_in || 0) + (s.bytes_out || 0)) }),
|
||||
h('td', { class: 'faint', text: s.end_reason || '—' })))));
|
||||
}
|
||||
|
||||
async function loadSessions() {
|
||||
const liveBox = $('#live-sessions');
|
||||
const histBox = $('#session-history');
|
||||
|
||||
const { sessions: liveRows } = await api('/sessions/live');
|
||||
liveBox.innerHTML = '';
|
||||
if (!liveRows.length) {
|
||||
liveBox.append(h('div', { class: 'empty', text: 'Nobody is connected right now.' }));
|
||||
} else {
|
||||
liveBox.append(h('div', { class: 'table-wrap' }, h('table', {},
|
||||
h('thead', {}, h('tr', {},
|
||||
h('th', { text: 'Machine' }), h('th', { text: 'Who' }), h('th', { text: 'Role' }),
|
||||
h('th', { text: 'Source' }), h('th', { text: 'For' }), h('th', { text: 'Traffic' }), h('th', {}))),
|
||||
h('tbody', {}, liveRows.map((s) => h('tr', {},
|
||||
h('td', {}, h('span', { class: 'row' }, h('span', { class: 'dot online' }), s.clientName)),
|
||||
h('td', { text: s.username }),
|
||||
h('td', {}, h('span', { class: 'tag role', text: s.role })),
|
||||
h('td', { class: 'faint', text: s.source }),
|
||||
h('td', { class: 'faint', text: duration(s.startedAt) }),
|
||||
h('td', { class: 'faint', text: bytes(s.bytesIn + s.bytesOut) }),
|
||||
h('td', {}, state.isAdmin
|
||||
? h('button', {
|
||||
class: 'ghost small danger',
|
||||
text: 'Disconnect',
|
||||
onclick: async () => {
|
||||
await api(`/sessions/${s.id}/kill`, { method: 'POST' });
|
||||
toast('Session ended');
|
||||
loadSessions();
|
||||
},
|
||||
})
|
||||
: null)))))));
|
||||
}
|
||||
|
||||
if (!state.isAdmin) {
|
||||
histBox.innerHTML = '';
|
||||
histBox.append(h('div', { class: 'empty', text: 'History is admin-only.' }));
|
||||
return;
|
||||
}
|
||||
const { sessions: history } = await api('/sessions/history?limit=100');
|
||||
histBox.innerHTML = '';
|
||||
histBox.append(history.length
|
||||
? h('div', { class: 'table-wrap' }, sessionTable(history))
|
||||
: h('div', { class: 'empty', text: 'No sessions recorded yet.' }));
|
||||
}
|
||||
|
||||
async function loadAudit() {
|
||||
const box = $('#audit');
|
||||
const { audit } = await api('/sessions/audit?limit=200');
|
||||
box.innerHTML = '';
|
||||
box.append(audit.length
|
||||
? h('div', { class: 'table-wrap' }, h('table', {},
|
||||
h('thead', {}, h('tr', {}, h('th', { text: 'When' }), h('th', { text: 'Who' }), h('th', { text: 'Action' }), h('th', { text: 'Target' }), h('th', { text: 'Detail' }))),
|
||||
h('tbody', {}, audit.map((a) => h('tr', {},
|
||||
h('td', { class: 'faint', text: ago(a.ts) }),
|
||||
h('td', { text: a.username || '—' }),
|
||||
h('td', {}, h('span', { class: 'tag', text: a.action })),
|
||||
h('td', { class: 'mono dim', text: (a.target || '—').slice(0, 8) }),
|
||||
h('td', { class: 'faint', text: a.detail || '' }))))))
|
||||
: h('div', { class: 'empty', text: 'Nothing logged yet.' }));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- shell */
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const { clients, isAdmin } = await api('/clients');
|
||||
state.clients = clients;
|
||||
state.isAdmin = isAdmin;
|
||||
|
||||
const grid = $('#machines');
|
||||
grid.innerHTML = '';
|
||||
$('#machines-count').textContent = clients.length ? `${clients.length} total` : '';
|
||||
|
||||
if (!clients.length) {
|
||||
grid.append(h('div', { class: 'empty', style: 'grid-column:1/-1' },
|
||||
state.isAdmin
|
||||
? 'No machines yet. Add one directly, or send an enrollment link.'
|
||||
: 'Nobody has given you access to a machine yet.'));
|
||||
} else {
|
||||
for (const c of clients) grid.append(clientCard(c));
|
||||
}
|
||||
|
||||
for (const el of document.querySelectorAll('[data-admin-only]')) el.classList.toggle('hidden', !state.isAdmin);
|
||||
} catch (err) {
|
||||
toast(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function selectTab(tab) {
|
||||
state.tab = tab;
|
||||
for (const b of document.querySelectorAll('#tabs button')) b.classList.toggle('active', b.dataset.tab === tab);
|
||||
for (const name of ['machines', 'invites', 'sessions', 'audit']) {
|
||||
$(`#tab-${name}`).classList.toggle('hidden', name !== tab);
|
||||
}
|
||||
if (tab === 'machines') refresh();
|
||||
if (tab === 'invites') loadInvites().catch((e) => toast(e.message));
|
||||
if (tab === 'sessions') loadSessions().catch((e) => toast(e.message));
|
||||
if (tab === 'audit') loadAudit().catch((e) => toast(e.message));
|
||||
}
|
||||
|
||||
function showApp() {
|
||||
$('#login').classList.add('hidden');
|
||||
$('#app').classList.remove('hidden');
|
||||
$('#whoami').textContent = state.isAdmin ? `${state.username} · admin` : state.username;
|
||||
|
||||
for (const el of [$('#btn-add-client'), $('#btn-enroll-invite'), $('#btn-new-enroll'), $('#btn-new-share')]) {
|
||||
el.classList.toggle('hidden', !state.isAdmin);
|
||||
}
|
||||
$('#tabs').querySelector('[data-tab="audit"]').classList.toggle('hidden', !state.isAdmin);
|
||||
|
||||
selectTab('machines');
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
$('#login-form').addEventListener('submit', signIn);
|
||||
$('#logout').addEventListener('click', signOut);
|
||||
$('#tabs').addEventListener('click', (e) => {
|
||||
const tab = e.target.closest('button')?.dataset.tab;
|
||||
if (tab) selectTab(tab);
|
||||
});
|
||||
$('#btn-add-client').addEventListener('click', () => clientForm(null));
|
||||
$('#btn-enroll-invite').addEventListener('click', newEnrollLink);
|
||||
$('#btn-new-enroll').addEventListener('click', newEnrollLink);
|
||||
$('#btn-new-share').addEventListener('click', () => newShareLink(null));
|
||||
$('#btn-refresh-sessions').addEventListener('click', () => loadSessions().catch((e) => toast(e.message)));
|
||||
|
||||
if (!state.token) return $('#login').classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const me = await api('/me');
|
||||
state.username = me.username;
|
||||
state.isAdmin = me.isAdmin;
|
||||
showApp();
|
||||
} catch {
|
||||
$('#login').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Keep the machine list and any open session view roughly current.
|
||||
setInterval(() => {
|
||||
if (document.hidden || $('#app').classList.contains('hidden')) return;
|
||||
if (state.tab === 'machines') refresh();
|
||||
if (state.tab === 'sessions') loadSessions().catch(() => {});
|
||||
}, 15_000);
|
||||
}
|
||||
|
||||
boot();
|
||||
Reference in New Issue
Block a user