1036 lines
52 KiB
JavaScript
1036 lines
52 KiB
JavaScript
'use strict';
|
||
/* labdeck frontend: vanilla JS, no build step. */
|
||
|
||
// ------------------------------------------------------------------ utils
|
||
const $ = (s, r = document) => r.querySelector(s);
|
||
const enc = new TextEncoder();
|
||
const VIEWS = [['dashboard', 'dashboard'], ['hosts', 'hosts'], ['terminals', 'terminals'], ['events', 'events'], ['settings', 'settings']];
|
||
const THEMES = [['slate', '#ff9e3d'], ['amber', '#ffb000'], ['green', '#4dff8f'], ['paper', '#b4540a']];
|
||
const BLOCKS = '▁▂▃▄▅▆▇█';
|
||
|
||
function h(tag, attrs, ...kids) {
|
||
const el = document.createElement(tag);
|
||
for (const [k, v] of Object.entries(attrs || {})) {
|
||
if (v == null || v === false) continue;
|
||
if (k === 'class') el.className = v;
|
||
else if (k === 'on') for (const [ev, fn] of Object.entries(v)) el.addEventListener(ev, fn);
|
||
else if (k === 'for') el.htmlFor = v;
|
||
else if (k === 'style' || k.startsWith('data-') || k.startsWith('aria-')) el.setAttribute(k, v === true ? '' : v);
|
||
else if (k in el) el[k] = v;
|
||
else el.setAttribute(k, v === true ? '' : v);
|
||
}
|
||
for (const kid of kids.flat(Infinity)) {
|
||
if (kid == null || kid === false) continue;
|
||
el.append(kid.nodeType ? kid : document.createTextNode(String(kid)));
|
||
}
|
||
return el;
|
||
}
|
||
|
||
async function api(method, path, body) {
|
||
const opt = { method, headers: { 'X-Requested-With': 'labdeck' } };
|
||
if (body !== undefined) {
|
||
opt.headers['Content-Type'] = 'application/json';
|
||
opt.body = JSON.stringify(body);
|
||
}
|
||
const r = await fetch(path, opt);
|
||
let data = null;
|
||
try { data = await r.json(); } catch { /* no body */ }
|
||
if (r.status === 401 && !path.startsWith('/api/auth/')) {
|
||
location.reload();
|
||
throw new Error('session expired');
|
||
}
|
||
if (!r.ok) throw new Error((data && data.error) || `HTTP ${r.status}`);
|
||
return data;
|
||
}
|
||
|
||
function toast(msg, isErr) {
|
||
const t = h('div', { class: 'toast' + (isErr ? ' err' : ''), role: isErr ? 'alert' : 'status' }, msg);
|
||
$('#toasts').append(t);
|
||
setTimeout(() => t.remove(), isErr ? 7000 : 3500);
|
||
}
|
||
const fail = e => toast(e.message || String(e), true);
|
||
|
||
function dur(s) {
|
||
s = Math.max(0, Math.floor(s));
|
||
if (s < 90) return s + 's';
|
||
if (s < 5400) return Math.floor(s / 60) + 'm';
|
||
if (s < 172800) return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm';
|
||
return Math.floor(s / 86400) + 'd ' + Math.floor((s % 86400) / 3600) + 'h';
|
||
}
|
||
const now = () => Math.floor(Date.now() / 1000);
|
||
const ago = ts => (ts ? dur(now() - ts) + ' ago' : 'never');
|
||
const fmtTime = ts => new Date(ts * 1000).toLocaleString();
|
||
|
||
// ------------------------------------------------------------------ state
|
||
const S = {
|
||
ready: false, view: 'dashboard', version: '',
|
||
services: [], hosts: [], snippets: [], events: [],
|
||
filter: '', hostFilter: '', open: new Set(), hist: {},
|
||
sessions: [], active: null, svcSig: '', pollTimer: null,
|
||
};
|
||
|
||
function applyTheme(t) {
|
||
if (!t) { try { t = localStorage.getItem('labdeck-theme'); } catch { /* ignore */ } }
|
||
if (!THEMES.some(x => x[0] === t)) t = 'slate';
|
||
document.documentElement.setAttribute('data-theme', t);
|
||
try { localStorage.setItem('labdeck-theme', t); } catch { /* ignore */ }
|
||
for (const s of S.sessions) if (s.term) s.term.options.theme = xtermTheme();
|
||
}
|
||
const currentTheme = () => document.documentElement.getAttribute('data-theme');
|
||
|
||
// ------------------------------------------------------------------ boot / auth
|
||
async function boot() {
|
||
applyTheme();
|
||
let st;
|
||
try { st = await api('GET', '/api/auth/state'); } catch (e) {
|
||
$('#app').textContent = 'Cannot reach the labdeck server: ' + e.message;
|
||
return;
|
||
}
|
||
S.version = st.version;
|
||
if (!st.initialized) return renderAuth('setup');
|
||
if (!st.authenticated) return renderAuth('login');
|
||
startApp();
|
||
}
|
||
|
||
function renderAuth(mode) {
|
||
const setup = mode === 'setup';
|
||
const pw = h('input', { type: 'password', id: 'pw', autocomplete: setup ? 'new-password' : 'current-password', autofocus: true });
|
||
const pw2 = setup ? h('input', { type: 'password', id: 'pw2', autocomplete: 'new-password' }) : null;
|
||
const err = h('div', { class: 'err', role: 'alert' });
|
||
const btn = h('button', { class: 'btn primary', type: 'submit' }, setup ? 'Create vault' : 'Unlock');
|
||
const form = h('form', {
|
||
on: {
|
||
submit: async e => {
|
||
e.preventDefault();
|
||
err.textContent = '';
|
||
if (setup) {
|
||
if (pw.value.length < 10) { err.textContent = 'Use at least 10 characters.'; return; }
|
||
if (pw.value !== pw2.value) { err.textContent = 'The two passwords do not match.'; return; }
|
||
}
|
||
btn.disabled = true;
|
||
btn.textContent = 'Deriving key...';
|
||
try {
|
||
await api('POST', setup ? '/api/auth/setup' : '/api/auth/login', { password: pw.value });
|
||
startApp();
|
||
} catch (ex) {
|
||
err.textContent = ex.message;
|
||
btn.disabled = false;
|
||
btn.textContent = setup ? 'Create vault' : 'Unlock';
|
||
pw.select();
|
||
}
|
||
},
|
||
},
|
||
},
|
||
h('label', null, h('span', null, 'master password'), pw),
|
||
setup ? h('label', null, h('span', null, 'repeat password'), pw2) : null,
|
||
err,
|
||
btn,
|
||
);
|
||
$('#app').replaceChildren(h('div', { class: 'authpage' }, h('div', { class: 'login' },
|
||
h('h1', null, 'lab', h('b', null, 'deck')),
|
||
h('p', null, setup
|
||
? 'First start. Choose a master password. It encrypts every SSH password and key stored here, and it cannot be recovered if you lose it.'
|
||
: 'Enter the master password to unlock the vault.'),
|
||
form)),
|
||
h('footer', { class: 'status' }, h('span', { class: 'grow' }), versionTag()));
|
||
}
|
||
|
||
async function startApp() {
|
||
buildShell();
|
||
S.ready = true;
|
||
await Promise.all([loadServices(), loadHosts(), loadSnippets()]).catch(fail);
|
||
const v = (location.hash || '').slice(1);
|
||
setView(VIEWS.some(x => x[0] === v) ? v : 'dashboard');
|
||
S.pollTimer = setInterval(() => { loadServices().catch(() => {}); }, 5000);
|
||
setInterval(tickClock, 1000);
|
||
updateStatus();
|
||
}
|
||
|
||
// ------------------------------------------------------------------ shell
|
||
function buildShell() {
|
||
const tabs = h('nav', { class: 'tabs', id: 'tabs', 'aria-label': 'Views' });
|
||
VIEWS.forEach(([id, label], i) => {
|
||
tabs.append(h('button', { 'data-view': id, on: { click: () => setView(id) } },
|
||
h('kbd', null, 'alt+' + (i + 1)), label, id === 'terminals' ? h('span', { class: 'count', id: 'termcount' }) : null));
|
||
});
|
||
const panes = h('div', { class: 'panes', id: 'panes' });
|
||
$('#app').replaceChildren(
|
||
h('header', { class: 'top' },
|
||
h('div', { class: 'brand' }, 'lab', h('b', null, 'deck')),
|
||
tabs,
|
||
h('div', { class: 'tools' },
|
||
h('button', { class: 'btn ghost sm', title: 'Command palette (Ctrl+K or Alt+K)', on: { click: () => openPalette() } }, 'commands'),
|
||
h('button', { class: 'btn ghost sm', title: 'Lock the vault and log out', on: { click: lock } }, 'lock')),
|
||
),
|
||
h('main', null,
|
||
h('div', { id: 'page' }),
|
||
h('div', { id: 'termview' }, h('div', { class: 'termbar', id: 'termbar' }), panes)),
|
||
h('footer', { class: 'status', id: 'status' }),
|
||
);
|
||
new ResizeObserver(() => fitActive()).observe(panes);
|
||
}
|
||
|
||
async function lock() {
|
||
if (S.sessions.some(s => s.status === 'connected')
|
||
&& !(await confirmBox('Open SSH sessions will be closed. Lock anyway?', 'Lock'))) return;
|
||
try { await api('POST', '/api/auth/logout'); } catch { /* ignore */ }
|
||
location.reload();
|
||
}
|
||
|
||
function setView(v) {
|
||
S.view = v;
|
||
history.replaceState(null, '', '#' + v);
|
||
document.querySelectorAll('#tabs button').forEach(b => b.classList.toggle('on', b.dataset.view === v));
|
||
const page = $('#page'), tv = $('#termview');
|
||
tv.classList.toggle('show', v === 'terminals');
|
||
page.classList.toggle('hidden', v === 'terminals');
|
||
if (v === 'terminals') { renderTerminals(); return; }
|
||
page.scrollTop = 0;
|
||
({ dashboard: buildDashboard, hosts: buildHosts, events: buildEvents, settings: buildSettings })[v]();
|
||
}
|
||
|
||
// ------------------------------------------------------------------ data loading
|
||
async function loadServices() {
|
||
const d = await api('GET', '/api/services');
|
||
const sig = JSON.stringify(d);
|
||
S.services = d;
|
||
if (sig !== S.svcSig) {
|
||
S.svcSig = sig;
|
||
if (S.view === 'dashboard') updateDashboard();
|
||
updateStatus();
|
||
}
|
||
}
|
||
async function loadHosts() { S.hosts = await api('GET', '/api/hosts'); }
|
||
async function loadSnippets() { S.snippets = await api('GET', '/api/snippets'); }
|
||
|
||
function versionTag() {
|
||
return h('span', { class: 'ver', title: 'labdeck ' + S.version }, 'v' + S.version);
|
||
}
|
||
|
||
// ------------------------------------------------------------------ status bar
|
||
function updateStatus() {
|
||
const el = $('#status');
|
||
if (!el) return;
|
||
const act = S.services.filter(s => s.enabled);
|
||
const up = act.filter(s => s.live.status === 'up').length;
|
||
const down = act.filter(s => s.live.status === 'down').length;
|
||
const lat = act.map(s => s.live.latency_ms).filter(x => x != null && x >= 0);
|
||
const avg = lat.length ? Math.round(lat.reduce((a, b) => a + b, 0) / lat.length) : null;
|
||
const n = S.sessions.filter(s => s.status === 'connected').length;
|
||
el.replaceChildren(...[
|
||
h('span', { class: 'up' }, '● ' + up + ' up'),
|
||
h('span', { class: down ? 'down' : 'dim' }, (down ? '● ' : '○ ') + down + ' down'),
|
||
avg != null ? h('span', { class: 'hide-s' }, 'avg ' + avg + ' ms') : null,
|
||
h('span', { class: 'grow' }),
|
||
n ? h('span', { class: 'hide-s' }, n + ' ssh session' + (n > 1 ? 's' : '')) : null,
|
||
h('span', { class: 'hide-s' }, 'vault unlocked'),
|
||
h('span', { id: 'clock' }, new Date().toLocaleTimeString()),
|
||
versionTag(),
|
||
].filter(Boolean));
|
||
const tc = $('#termcount');
|
||
if (tc) tc.textContent = S.sessions.length ? String(S.sessions.length) : '';
|
||
document.title = down ? `(${down} down) labdeck` : 'labdeck';
|
||
}
|
||
function tickClock() { const c = $('#clock'); if (c) c.textContent = new Date().toLocaleTimeString(); }
|
||
|
||
// ------------------------------------------------------------------ modal / confirm
|
||
const modals = [];
|
||
function openModal(title, bodyNodes, footerNodes, opts = {}) {
|
||
const prev = document.activeElement;
|
||
const box = h('div', { class: 'modal', role: 'dialog', 'aria-modal': 'true', 'aria-label': title, style: opts.wide ? 'max-width:760px' : null },
|
||
h('header', null, h('h3', null, title), h('button', { class: 'btn ghost sm', type: 'button', 'aria-label': 'Close', on: { click: closeModal } }, 'esc')),
|
||
bodyNodes,
|
||
footerNodes);
|
||
const ov = h('div', { class: 'overlay', on: { mousedown: e => { if (e.target === ov) closeModal(); } } }, box);
|
||
document.body.append(ov);
|
||
modals.push({ ov, prev });
|
||
const first = box.querySelector('input:not([type=hidden]), textarea, select');
|
||
if (first) first.focus();
|
||
return ov;
|
||
}
|
||
function closeModal() {
|
||
const m = modals.pop();
|
||
if (!m) return;
|
||
m.ov.remove();
|
||
if (m.prev && m.prev.focus && document.contains(m.prev)) m.prev.focus();
|
||
}
|
||
function confirmBox(message, okLabel = 'Confirm') {
|
||
return new Promise(res => {
|
||
let done = false, ov = null;
|
||
const finish = v => {
|
||
if (done) return;
|
||
done = true;
|
||
if (ov && document.contains(ov)) closeModal();
|
||
res(v);
|
||
};
|
||
const ok = h('button', { class: 'btn primary', type: 'button', on: { click: () => finish(true) } }, okLabel);
|
||
ov = openModal('Please confirm',
|
||
h('div', { class: 'body' }, h('div', null, message)),
|
||
h('footer', null, h('button', { class: 'btn', type: 'button', on: { click: () => finish(false) } }, 'Cancel'), ok));
|
||
ok.focus();
|
||
// closed from outside (Escape, click on the backdrop): resolve as "no"
|
||
const mo = new MutationObserver(() => { if (!document.contains(ov)) { mo.disconnect(); finish(false); } });
|
||
mo.observe(document.body, { childList: true });
|
||
});
|
||
}
|
||
|
||
/** fields: array of field specs, or arrays of specs (rendered side by side). */
|
||
function formModal({ title, fields, values, submitLabel = 'Save', onSubmit, extra = [], wide }) {
|
||
const inputs = {};
|
||
const rows = [];
|
||
const err = h('div', { class: 'form-error', role: 'alert' });
|
||
|
||
function build(f) {
|
||
const id = 'f_' + f.key;
|
||
let input;
|
||
const v = values[f.key];
|
||
if (f.type === 'select') {
|
||
input = h('select', { id }, f.options.map(([val, lab]) => h('option', { value: String(val), selected: String(v ?? '') === String(val) }, lab)));
|
||
} else if (f.type === 'textarea') {
|
||
input = h('textarea', { id, rows: f.rows || 3, placeholder: f.placeholder || '', spellcheck: false, value: v ?? '' });
|
||
} else if (f.type === 'checkbox') {
|
||
input = h('input', { id, type: 'checkbox', checked: !!v });
|
||
} else {
|
||
input = h('input', { id, type: f.type || 'text', placeholder: f.placeholder || '', value: v ?? '', autocomplete: f.type === 'password' ? 'new-password' : 'off', spellcheck: false });
|
||
}
|
||
inputs[f.key] = { input, spec: f };
|
||
const extraEl = f.file ? h('div', null, h('label', { class: 'btn sm' }, 'Load from file',
|
||
h('input', { type: 'file', class: 'hidden', on: { change: async e => { const file = e.target.files[0]; if (file) { input.value = await file.text(); update(); } } } }))) : null;
|
||
const root = f.type === 'checkbox'
|
||
? h('div', { class: 'field check' }, input, h('label', { for: id }, f.label), f.hint ? h('span', { class: 'hint' }, f.hint) : null)
|
||
: h('div', { class: 'field' }, h('label', { for: id }, f.label), input, extraEl, f.hint ? h('div', { class: 'hint' }, f.hint) : null);
|
||
inputs[f.key].root = root;
|
||
return root;
|
||
}
|
||
for (const f of fields) {
|
||
if (Array.isArray(f)) rows.push(h('div', { class: 'grid2' }, f.map(build)));
|
||
else rows.push(build(f));
|
||
}
|
||
|
||
function read() {
|
||
const out = {};
|
||
for (const [k, { input, spec }] of Object.entries(inputs)) {
|
||
if (spec.type === 'checkbox') out[k] = input.checked;
|
||
else if (spec.type === 'number') out[k] = Number(input.value) || 0;
|
||
else if (spec.parse === 'int-or-null') out[k] = input.value === '' ? null : Number(input.value);
|
||
else out[k] = input.value;
|
||
}
|
||
return out;
|
||
}
|
||
let opts_onChange = null;
|
||
function update() {
|
||
const cur = read();
|
||
for (const { root, spec } of Object.values(inputs)) {
|
||
const vis = spec.show ? spec.show(cur) : true;
|
||
root.classList.toggle('hidden', !vis);
|
||
// a hidden grid cell must not leave an empty row behind
|
||
}
|
||
for (const r of rows) {
|
||
if (r.classList.contains('grid2')) r.classList.toggle('hidden', [...r.children].every(c => c.classList.contains('hidden')));
|
||
}
|
||
if (opts_onChange) opts_onChange(cur, inputs);
|
||
}
|
||
const save = h('button', { class: 'btn primary', type: 'submit' }, submitLabel);
|
||
const form = h('form', {
|
||
on: {
|
||
input: update, change: update,
|
||
submit: async e => {
|
||
e.preventDefault();
|
||
err.textContent = '';
|
||
save.disabled = true;
|
||
try { await onSubmit(read()); closeModal(); } catch (ex) { err.textContent = ex.message; save.disabled = false; }
|
||
},
|
||
},
|
||
}, h('div', { class: 'body' }, rows, err),
|
||
h('footer', null, h('div', { class: 'grow' }, extra), h('button', { class: 'btn', type: 'button', on: { click: closeModal } }, 'Cancel'), save));
|
||
openModal(title, form, null, { wide });
|
||
update();
|
||
const first = form.querySelector('input:not([type=hidden]):not(.hidden), textarea, select');
|
||
if (first) first.focus();
|
||
return { update, inputs, setOnChange: fn => { opts_onChange = fn; } };
|
||
}
|
||
|
||
// ------------------------------------------------------------------ dashboard
|
||
function buildDashboard() {
|
||
const filter = h('input', {
|
||
type: 'search', id: 'filter', placeholder: 'filter services ( / )', value: S.filter, 'aria-label': 'Filter services',
|
||
on: { input: e => { S.filter = e.target.value; updateDashboard(); } },
|
||
});
|
||
$('#page').replaceChildren(
|
||
h('div', { class: 'prompt' }, h('span', { class: 'ps1' }, 'labdeck', h('i', null, ':'), '~', h('i', null, '$')), h('span', null, 'watch services'), h('span', { class: 'cursor' })),
|
||
h('div', { class: 'summary', id: 'summary' }),
|
||
h('div', { class: 'toolbar' }, filter,
|
||
h('button', { class: 'btn', on: { click: () => editService(null) } }, 'Add service'),
|
||
h('button', { class: 'btn', on: { click: checkAll } }, 'Check all now')),
|
||
h('div', { id: 'list' }));
|
||
updateDashboard();
|
||
}
|
||
|
||
function svcClass(s) {
|
||
const l = s.live || {};
|
||
if (!s.enabled || l.status === 'paused') return 's-paused';
|
||
if (l.status === 'up') return l.fails > 0 ? 's-retry' : 's-up';
|
||
if (l.status === 'down') return 's-down';
|
||
return 's-unk';
|
||
}
|
||
|
||
function sparkNodes(arr) {
|
||
if (!arr || !arr.length) return h('span', { class: 'dim' }, 'waiting for data');
|
||
const vals = arr.filter(v => v != null);
|
||
const max = Math.max(1, ...vals);
|
||
const out = [];
|
||
const pad = 30 - arr.length;
|
||
if (pad > 0) out.push(h('span', { class: 'dim' }, '·'.repeat(pad)));
|
||
let run = '';
|
||
const flush = () => { if (run) { out.push(h('span', { class: 'ok' }, run)); run = ''; } };
|
||
for (const v of arr) {
|
||
if (v == null) { flush(); out.push(h('span', { class: 'x' }, '×')); }
|
||
else run += BLOCKS[Math.min(7, Math.floor((v / max) * 7.999))];
|
||
}
|
||
flush();
|
||
return out;
|
||
}
|
||
|
||
function updateDashboard() {
|
||
const list = $('#list');
|
||
if (!list || S.view !== 'dashboard') return;
|
||
const focusId = document.activeElement && document.activeElement.dataset ? document.activeElement.dataset.id : null;
|
||
|
||
const q = S.filter.trim().toLowerCase();
|
||
const items = S.services.filter(s => !q || (s.name + ' ' + s.address + ' ' + s.grp + ' ' + s.kind).toLowerCase().includes(q));
|
||
|
||
const act = S.services.filter(s => s.enabled);
|
||
const cnt = k => act.filter(s => s.live.status === k).length;
|
||
const sum = $('#summary');
|
||
sum.replaceChildren(
|
||
h('span', { class: 'up' }, cnt('up') + ' up'),
|
||
h('span', { class: cnt('down') ? 'down' : 'dim' }, cnt('down') + ' down'),
|
||
h('span', { class: 'dim' }, cnt('unknown') + ' pending'),
|
||
h('span', { class: 'dim' }, (S.services.length - act.length) + ' paused'));
|
||
|
||
if (!S.services.length) {
|
||
list.replaceChildren(h('div', { class: 'empty' },
|
||
h('p', null, 'No services yet. Add a web address, a host:port, or a hostname to start health checks.'),
|
||
h('button', { class: 'btn primary', on: { click: () => editService(null) } }, 'Add your first service')));
|
||
return;
|
||
}
|
||
if (!items.length) { list.replaceChildren(h('div', { class: 'empty' }, 'Nothing matches "' + S.filter + '".')); return; }
|
||
|
||
const groups = new Map();
|
||
for (const s of items) {
|
||
const g = s.grp || 'ungrouped';
|
||
if (!groups.has(g)) groups.set(g, []);
|
||
groups.get(g).push(s);
|
||
}
|
||
const frag = [];
|
||
for (const [g, arr] of groups) {
|
||
frag.push(h('section', { class: 'group' }, h('h2', null, g), arr.map(svcBlock)));
|
||
}
|
||
list.replaceChildren(...frag);
|
||
if (focusId) { const el = list.querySelector(`[data-id="${focusId}"]`); if (el) el.focus(); }
|
||
for (const id of S.open) refreshHist(id);
|
||
}
|
||
|
||
function statusText(s) {
|
||
const l = s.live;
|
||
if (!s.enabled || l.status === 'paused') return 'paused';
|
||
if (l.status === 'up') return 'up ' + dur(now() - l.since);
|
||
if (l.status === 'down') return 'down ' + dur(now() - l.since);
|
||
return 'pending';
|
||
}
|
||
|
||
function svcBlock(s) {
|
||
const l = s.live;
|
||
const isOpen = S.open.has(s.id);
|
||
const toggle = () => { isOpen ? S.open.delete(s.id) : S.open.add(s.id); updateDashboard(); };
|
||
const lat = l.status === 'up' && l.latency_ms != null ? l.latency_ms + ' ms' : '-';
|
||
const up = s.uptime_24h;
|
||
const upCls = up == null ? 'dim' : up >= 99 ? 'up' : up >= 95 ? 'warn' : 'down';
|
||
const tls = l.tls_days != null && l.tls_days <= 14
|
||
? h('span', { class: l.tls_days <= 7 ? 'down' : 'warn' }, ' tls ' + Math.max(0, l.tls_days) + 'd') : null;
|
||
const host = s.host_id ? S.hosts.find(x => x.id === s.host_id) : null;
|
||
const row = h('div', {
|
||
class: `svc ${svcClass(s)}${isOpen ? ' open' : ''}`, tabindex: 0, role: 'button', 'aria-expanded': String(isOpen), 'data-id': s.id,
|
||
on: { click: toggle, keydown: e => { if (e.target === row && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); toggle(); } } },
|
||
},
|
||
h('span', { class: 'led', 'aria-hidden': 'true' }, !s.enabled ? '○' : '●'),
|
||
h('span', { class: 'name trunc' }, s.name),
|
||
h('span', { class: 'addr trunc' }, s.address, tls),
|
||
h('span', { class: 'right' }, lat),
|
||
h('span', { class: 'right ' + upCls }, up == null ? '-' : up.toFixed(up >= 99.95 ? 0 : 1) + '%'),
|
||
h('span', { class: 'spark', 'aria-hidden': 'true' }, sparkNodes(s.spark)),
|
||
h('span', { class: 'since dim' }, statusText(s)),
|
||
h('span', { class: 'acts', on: { click: e => e.stopPropagation() } },
|
||
h('button', { class: 'btn sm', title: 'Run the check now', on: { click: () => checkNow(s.id) } }, 'check'),
|
||
host ? h('button', { class: 'btn sm', title: 'Open SSH terminal on ' + host.name, on: { click: () => connectHost(host) } }, 'ssh') : null,
|
||
h('button', { class: 'btn sm', on: { click: () => editService(s) } }, 'edit')),
|
||
);
|
||
if (!isOpen) return row;
|
||
|
||
const dl = h('dl', null);
|
||
const add = (k, v) => { if (v != null && v !== '') dl.append(h('dt', null, k), h('dd', null, v)); };
|
||
add('status', statusText(s) + (l.fails ? ` (${l.fails} failed check${l.fails > 1 ? 's' : ''} in a row)` : ''));
|
||
add('last check', l.last_check ? ago(l.last_check) + (l.code ? ` (HTTP ${l.code})` : '') : 'not yet');
|
||
if (l.error) dl.append(h('dt', null, 'error'), h('dd', { class: 'down' }, l.error));
|
||
const isHttp = s.kind === 'http';
|
||
add('target', isHttp ? h('a', { href: /^https?:\/\//.test(s.address) ? s.address : 'http://' + s.address, target: '_blank', rel: 'noopener noreferrer' }, s.address) : `${s.kind} ${s.address}`);
|
||
add('schedule', `every ${s.interval_s}s, timeout ${s.timeout_s}s, down after ${s.down_after} failure${s.down_after > 1 ? 's' : ''}`);
|
||
if (isHttp) add('expects', 'status ' + s.expect + (s.keyword ? `, body contains "${s.keyword}"` : '') + (s.verify_tls ? '' : ', certificate not verified'));
|
||
if (l.tls_days != null) add('certificate', (l.tls_days >= 0 ? `expires in ${l.tls_days} days` : `expired ${-l.tls_days} days ago`));
|
||
if (host) add('ssh host', host.name);
|
||
add('notes', s.notes);
|
||
const strip = h('div', { class: 'strip', id: 'strip-' + s.id, 'aria-label': 'Last 24 hours' });
|
||
drawStrip(strip, S.hist[s.id]);
|
||
const detail = h('div', { class: 'detail' }, dl, strip,
|
||
h('div', { class: 'strip-legend' }, h('span', null, '24h ago'), h('span', null, 'now')));
|
||
return h('div', null, row, detail);
|
||
}
|
||
|
||
async function refreshHist(id) {
|
||
const c = S.hist[id];
|
||
if (c && now() - c.at < 30) return;
|
||
try {
|
||
const d = await api('GET', `/api/services/${id}/history?hours=24`);
|
||
S.hist[id] = { at: now(), points: d.points };
|
||
const el = $('#strip-' + id);
|
||
if (el) drawStrip(el, S.hist[id]);
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
function drawStrip(el, hist) {
|
||
const N = 96, span = 86400, t0 = now() - span;
|
||
const buckets = Array.from({ length: N }, () => ({ ok: 0, bad: 0 }));
|
||
if (hist) for (const [ts, ok] of hist.points) {
|
||
const i = Math.min(N - 1, Math.max(0, Math.floor(((ts - t0) / span) * N)));
|
||
ok ? buckets[i].ok++ : buckets[i].bad++;
|
||
}
|
||
el.replaceChildren(...buckets.map((b, i) => {
|
||
const total = b.ok + b.bad;
|
||
const cls = !total ? '' : b.bad === 0 ? 'g' : b.bad / total > 0.5 ? 'r' : 'y';
|
||
const label = new Date((t0 + (i * span) / N) * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||
return h('i', { class: cls, title: total ? `${label}: ${b.ok} ok, ${b.bad} failed` : label + ': no data' });
|
||
}));
|
||
}
|
||
|
||
async function checkNow(id) {
|
||
try {
|
||
const out = await api('POST', `/api/services/${id}/check`);
|
||
const i = S.services.findIndex(s => s.id === id);
|
||
if (i >= 0) S.services[i] = out;
|
||
S.svcSig = '';
|
||
delete S.hist[id];
|
||
updateDashboard();
|
||
updateStatus();
|
||
} catch (e) { fail(e); }
|
||
}
|
||
async function checkAll() {
|
||
try {
|
||
const r = await api('POST', '/api/check-all');
|
||
toast(`Running ${r.started} check${r.started === 1 ? '' : 's'}.`);
|
||
setTimeout(() => loadServices().catch(() => {}), 1500);
|
||
} catch (e) { fail(e); }
|
||
}
|
||
|
||
function editService(s) {
|
||
const isNew = !s;
|
||
const v = s ? { ...s } : { kind: 'http', interval_s: 30, timeout_s: 5, expect: '200-399', verify_tls: true, down_after: 2, enabled: true };
|
||
const hostOpts = [['', 'none'], ...S.hosts.map(x => [x.id, `${x.name} (${x.username}@${x.address})`])];
|
||
const ph = { http: 'https://nas.lan:5001 or 192.168.1.10:8080', tcp: '192.168.1.10:22', ping: '192.168.1.1' };
|
||
const fm = formModal({
|
||
title: isNew ? 'Add service' : 'Edit service',
|
||
values: v, wide: true,
|
||
submitLabel: isNew ? 'Add service' : 'Save changes',
|
||
fields: [
|
||
[{ key: 'name', label: 'Name', placeholder: 'Gitea' }, { key: 'grp', label: 'Group', placeholder: 'infrastructure' }],
|
||
[{ key: 'kind', label: 'Check type', type: 'select', options: [['http', 'HTTP(S) request'], ['tcp', 'TCP port'], ['ping', 'Ping']] },
|
||
{ key: 'address', label: 'Address', placeholder: ph[v.kind] }],
|
||
[{ key: 'interval_s', label: 'Check every (seconds)', type: 'number' }, { key: 'timeout_s', label: 'Timeout (seconds)', type: 'number' }],
|
||
[{ key: 'expect', label: 'Accepted status codes', placeholder: '200-399', show: x => x.kind === 'http', hint: 'For example 200, 200-299 or 200,204,301-302.' },
|
||
{ key: 'down_after', label: 'Mark as down after N failures', type: 'number' }],
|
||
{ key: 'keyword', label: 'Page must contain (optional)', show: x => x.kind === 'http', placeholder: 'Sign in' },
|
||
{ key: 'verify_tls', label: 'Verify TLS certificate', type: 'checkbox', show: x => x.kind === 'http', hint: 'Turn off for self-signed certificates.' },
|
||
{ key: 'host_id', label: 'Linked SSH host', type: 'select', options: hostOpts, parse: 'int-or-null', hint: 'Adds an ssh button to this service.' },
|
||
{ key: 'notes', label: 'Notes', type: 'textarea', rows: 2 },
|
||
{ key: 'enabled', label: 'Run checks for this service', type: 'checkbox' },
|
||
],
|
||
onSubmit: async x => {
|
||
const body = { ...x, host_id: x.host_id };
|
||
if (isNew) await api('POST', '/api/services', body); else await api('PUT', '/api/services/' + s.id, body);
|
||
toast(isNew ? 'Service added.' : 'Changes saved.');
|
||
await loadServices();
|
||
},
|
||
extra: isNew ? [] : [h('button', {
|
||
class: 'btn danger', type: 'button', on: {
|
||
click: async () => {
|
||
if (!(await confirmBox(`Delete "${s.name}" and its check history?`, 'Delete'))) return;
|
||
try { await api('DELETE', '/api/services/' + s.id); closeModal(); toast('Service deleted.'); S.open.delete(s.id); await loadServices(); } catch (e) { fail(e); }
|
||
},
|
||
},
|
||
}, 'Delete')],
|
||
});
|
||
// keep the address placeholder in sync with the check type
|
||
fm.setOnChange((cur, inputs) => { inputs.address.input.placeholder = ph[cur.kind] || ''; });
|
||
}
|
||
|
||
// ------------------------------------------------------------------ hosts
|
||
function buildHosts() {
|
||
$('#page').replaceChildren(
|
||
h('div', { class: 'prompt' }, h('span', { class: 'ps1' }, 'labdeck', h('i', null, ':'), '~', h('i', null, '$')), h('span', null, 'ssh --list'), h('span', { class: 'cursor' })),
|
||
h('div', { class: 'summary dim' }, 'Passwords and keys are encrypted with your master password. Enter or double-click opens a terminal.'),
|
||
h('div', { class: 'toolbar' },
|
||
h('input', { type: 'search', id: 'filter', placeholder: 'filter hosts ( / )', value: S.hostFilter, 'aria-label': 'Filter hosts', on: { input: e => { S.hostFilter = e.target.value; updateHosts(); } } }),
|
||
h('button', { class: 'btn', on: { click: () => editHost(null) } }, 'Add host')),
|
||
h('div', { id: 'list' }));
|
||
updateHosts();
|
||
}
|
||
|
||
function updateHosts() {
|
||
const list = $('#list');
|
||
if (!list) return;
|
||
const q = S.hostFilter.trim().toLowerCase();
|
||
const items = S.hosts.filter(x => !q || (x.name + ' ' + x.address + ' ' + x.grp + ' ' + x.username).toLowerCase().includes(q));
|
||
if (!S.hosts.length) {
|
||
list.replaceChildren(h('div', { class: 'empty' }, h('p', null, 'No SSH hosts yet. Save one and it opens in a terminal tab with one click.'),
|
||
h('button', { class: 'btn primary', on: { click: () => editHost(null) } }, 'Add your first host')));
|
||
return;
|
||
}
|
||
const t = h('table', { class: 't' },
|
||
h('thead', null, h('tr', null, ['name', 'login', 'group', 'auth', 'host key', 'last used', ''].map(x => h('th', null, x)))),
|
||
h('tbody', null, items.map(x => {
|
||
const open = S.sessions.filter(s => s.host.id === x.id && s.status !== 'closed').length;
|
||
return h('tr', {
|
||
class: 'row', tabindex: 0, 'data-id': x.id,
|
||
on: { dblclick: () => connectHost(x), keydown: e => { if (e.key === 'Enter' && e.target.tagName === 'TR') connectHost(x); } },
|
||
},
|
||
h('td', null, h('b', null, x.name), open ? h('span', { class: 'up' }, ` ● ${open}`) : null),
|
||
h('td', { class: 'dim' }, `${x.username}@${x.address}` + (x.port !== 22 ? ':' + x.port : '')),
|
||
h('td', { class: 'dim' }, x.grp || '-'),
|
||
h('td', { class: 'dim' }, x.auth === 'key' ? 'private key' : 'password'),
|
||
h('td', { class: x.host_key ? 'dim' : 'warn', title: x.host_key }, x.host_key ? 'trusted' : 'not seen yet'),
|
||
h('td', { class: 'dim' }, x.last_connected ? ago(x.last_connected) : 'never'),
|
||
h('td', { class: 'acts' },
|
||
h('button', { class: 'btn sm primary', on: { click: () => connectHost(x) } }, 'Connect'), ' ',
|
||
h('button', { class: 'btn sm', on: { click: () => editHost(x) } }, 'Edit')));
|
||
})));
|
||
list.replaceChildren(t);
|
||
}
|
||
|
||
function editHost(x) {
|
||
const isNew = !x;
|
||
const v = x ? { ...x, secret_pw: '', secret_key: '', passphrase: '' } : { port: 22, auth: 'password', secret_pw: '', secret_key: '', passphrase: '' };
|
||
const keepHint = isNew ? null : 'Leave empty to keep the stored one.';
|
||
formModal({
|
||
title: isNew ? 'Add SSH host' : 'Edit SSH host',
|
||
values: v, wide: true, submitLabel: isNew ? 'Save host' : 'Save changes',
|
||
fields: [
|
||
[{ key: 'name', label: 'Name', placeholder: 'nas' }, { key: 'grp', label: 'Group', placeholder: 'servers' }],
|
||
[{ key: 'address', label: 'Host or IP', placeholder: '192.168.1.10' }, { key: 'port', label: 'Port', type: 'number' }],
|
||
[{ key: 'username', label: 'User' }, { key: 'auth', label: 'Authentication', type: 'select', options: [['password', 'Password'], ['key', 'Private key']] }],
|
||
{ key: 'secret_pw', label: 'Password', type: 'password', show: c => c.auth === 'password', hint: keepHint },
|
||
{ key: 'secret_key', label: 'Private key (PEM / OpenSSH)', type: 'textarea', rows: 5, file: true, show: c => c.auth === 'key', placeholder: '-----BEGIN OPENSSH PRIVATE KEY-----', hint: keepHint },
|
||
{ key: 'passphrase', label: 'Key passphrase (if the key has one)', type: 'password', show: c => c.auth === 'key' },
|
||
{ key: 'startup', label: 'Run after login (optional)', placeholder: 'tmux new -A -s main', hint: 'Sent to the shell right after connecting.' },
|
||
{ key: 'notes', label: 'Notes', type: 'textarea', rows: 2 },
|
||
],
|
||
onSubmit: async c => {
|
||
const body = {
|
||
name: c.name, grp: c.grp, address: c.address, port: c.port || 22, username: c.username, auth: c.auth,
|
||
startup: c.startup, notes: c.notes,
|
||
secret: c.auth === 'key' ? c.secret_key : c.secret_pw,
|
||
passphrase: c.auth === 'key' ? c.passphrase : '',
|
||
};
|
||
if (isNew) await api('POST', '/api/hosts', body); else await api('PUT', '/api/hosts/' + x.id, body);
|
||
toast(isNew ? 'Host saved.' : 'Changes saved.');
|
||
await loadHosts();
|
||
if (S.view === 'hosts') updateHosts();
|
||
},
|
||
extra: isNew ? [] : [
|
||
h('button', {
|
||
class: 'btn danger', type: 'button', on: {
|
||
click: async () => {
|
||
if (!(await confirmBox(`Delete host "${x.name}" and its stored credentials?`, 'Delete'))) return;
|
||
try { await api('DELETE', '/api/hosts/' + x.id); closeModal(); toast('Host deleted.'); await loadHosts(); if (S.view === 'hosts') updateHosts(); } catch (e) { fail(e); }
|
||
},
|
||
},
|
||
}, 'Delete'),
|
||
x.host_key ? h('button', {
|
||
class: 'btn', type: 'button', title: x.host_key, on: {
|
||
click: async () => {
|
||
try { await api('POST', `/api/hosts/${x.id}/forget-key`); closeModal(); toast('Host key forgotten. The next connection trusts the key it sees.'); await loadHosts(); if (S.view === 'hosts') updateHosts(); } catch (e) { fail(e); }
|
||
},
|
||
},
|
||
}, 'Forget host key') : null,
|
||
],
|
||
});
|
||
}
|
||
|
||
// ------------------------------------------------------------------ terminals
|
||
function xtermTheme() {
|
||
const cs = getComputedStyle(document.documentElement);
|
||
const g = n => cs.getPropertyValue(n).trim();
|
||
return {
|
||
background: g('--bg'), foreground: g('--fg'), cursor: g('--accent'), cursorAccent: g('--bg'),
|
||
selectionBackground: g('--sel2'),
|
||
black: g('--bg3'), red: g('--down'), green: g('--up'), yellow: g('--warn'), blue: g('--blue'), magenta: g('--mag'), cyan: g('--cyan'), white: g('--fg'),
|
||
brightBlack: g('--dim'), brightRed: g('--down'), brightGreen: g('--up'), brightYellow: g('--warn'), brightBlue: g('--blue'), brightMagenta: g('--mag'), brightCyan: g('--cyan'), brightWhite: g('--fg'),
|
||
};
|
||
}
|
||
|
||
function connectHost(host) {
|
||
const same = S.sessions.filter(s => s.host.id === host.id).length;
|
||
const key = 's' + Date.now() + Math.random().toString(36).slice(2, 6);
|
||
const el = h('div', { class: 'pane', id: 'pane-' + key });
|
||
const Term = window.Terminal;
|
||
const Fit = window.FitAddon && window.FitAddon.FitAddon;
|
||
const Links = window.WebLinksAddon && window.WebLinksAddon.WebLinksAddon;
|
||
if (!Term || !Fit) { toast('Terminal library failed to load.', true); return; }
|
||
const term = new Term({
|
||
fontFamily: getComputedStyle(document.body).fontFamily, fontSize: 14, cursorBlink: true, scrollback: 10000,
|
||
theme: xtermTheme(), allowProposedApi: true,
|
||
});
|
||
const fit = new Fit();
|
||
term.loadAddon(fit);
|
||
if (Links) term.loadAddon(new Links());
|
||
const s = { key, host, title: host.name + (same ? ` (${same + 1})` : ''), el, term, fit, ws: null, status: 'connecting', opened: false };
|
||
term.onData(d => {
|
||
if (s.ws && s.ws.readyState === 1) s.ws.send(enc.encode(d));
|
||
else if ((s.status === 'closed' || s.status === 'error') && d === '\r') openSocket(s);
|
||
});
|
||
term.onResize(({ cols, rows }) => {
|
||
if (s.ws && s.ws.readyState === 1) s.ws.send(JSON.stringify({ t: 'resize', cols, rows }));
|
||
});
|
||
term.attachCustomKeyEventHandler(e => {
|
||
if (e.type !== 'keydown') return true;
|
||
if (e.altKey && !e.ctrlKey && e.key.toLowerCase() === 'k') { openPalette(); return false; }
|
||
if (e.altKey && !e.ctrlKey && /^[1-5]$/.test(e.key)) { setView(VIEWS[Number(e.key) - 1][0]); return false; }
|
||
return true;
|
||
});
|
||
S.sessions.push(s);
|
||
S.active = key;
|
||
$('#panes').append(el);
|
||
setView('terminals');
|
||
}
|
||
|
||
function renderTerminals() {
|
||
const bar = $('#termbar');
|
||
bar.replaceChildren(
|
||
...S.sessions.map(s => h('div', {
|
||
class: 'tab' + (s.key === S.active ? ' on' : ''), role: 'tab', 'aria-selected': String(s.key === S.active),
|
||
on: { click: () => { S.active = s.key; renderTerminals(); }, auxclick: e => { if (e.button === 1) closeSession(s.key); } },
|
||
},
|
||
h('span', { class: 'dot ' + s.status, 'aria-hidden': 'true' }, '●'),
|
||
h('span', null, s.title),
|
||
h('button', { title: 'Close session', 'aria-label': 'Close ' + s.title, on: { click: e => { e.stopPropagation(); closeSession(s.key); } } }, '×'))),
|
||
h('div', { class: 'spacer' }),
|
||
h('div', { class: 'tools' },
|
||
h('button', { class: 'btn sm', title: 'Run a saved command in this terminal', on: { click: () => openPalette({ only: 'snippet' }) } }, 'snippets'),
|
||
h('button', { class: 'btn sm', title: 'Open another host', on: { click: () => openPalette({ only: 'host' }) } }, 'new session')));
|
||
for (const s of S.sessions) s.el.classList.toggle('on', s.key === S.active);
|
||
let empty = $('#term-empty');
|
||
if (!S.sessions.length) {
|
||
if (!empty) {
|
||
empty = h('div', { class: 'term-empty', id: 'term-empty' },
|
||
h('div', null, 'No open sessions.'),
|
||
S.hosts.length ? h('div', { class: 'hostlist' }, S.hosts.map(x => h('button', { on: { click: () => connectHost(x) } }, `${x.name} `, h('span', { class: 'dim' }, `${x.username}@${x.address}`)))) :
|
||
h('div', { class: 'hostlist' }, h('button', { on: { click: () => editHost(null) } }, 'Add a host first')));
|
||
$('#panes').append(empty);
|
||
}
|
||
} else if (empty) empty.remove();
|
||
updateStatus();
|
||
requestAnimationFrame(() => {
|
||
const s = activeSession();
|
||
if (!s) return;
|
||
if (!s.opened) {
|
||
s.term.open(s.el);
|
||
s.opened = true;
|
||
fitSession(s);
|
||
openSocket(s);
|
||
} else fitSession(s);
|
||
s.term.focus();
|
||
});
|
||
}
|
||
|
||
const activeSession = () => S.sessions.find(s => s.key === S.active);
|
||
function fitSession(s) { try { if (s && s.opened && s.el.offsetParent !== null) s.fit.fit(); } catch { /* not visible yet */ } }
|
||
function fitActive() { fitSession(activeSession()); }
|
||
addEventListener('resize', () => fitActive());
|
||
|
||
async function openSocket(s) {
|
||
const t = s.term;
|
||
s.status = 'connecting';
|
||
renderTermbarOnly();
|
||
let ticket;
|
||
try { ticket = (await api('POST', '/api/ws-ticket')).ticket; } catch (e) {
|
||
t.writeln('\x1b[31m' + e.message + '\x1b[0m');
|
||
s.status = 'error'; renderTermbarOnly();
|
||
return;
|
||
}
|
||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||
const ws = new WebSocket(`${proto}://${location.host}/ws/term/${s.host.id}?ticket=${encodeURIComponent(ticket)}&cols=${t.cols}&rows=${t.rows}`);
|
||
ws.binaryType = 'arraybuffer';
|
||
s.ws = ws;
|
||
let gotCtl = false;
|
||
ws.onmessage = ev => {
|
||
if (typeof ev.data !== 'string') { t.write(new Uint8Array(ev.data)); return; }
|
||
let m; try { m = JSON.parse(ev.data); } catch { return; }
|
||
gotCtl = true;
|
||
const line = (c, txt) => t.write(`\r\n\x1b[${c}m${txt.replace(/\r?\n/g, '\r\n')}\x1b[0m`);
|
||
if (m.t === 'info') line('2', '# ' + m.msg);
|
||
else if (m.t === 'prompt') t.write('\r\n\x1b[33m' + m.msg.replace(/\r?\n/g, '\r\n') + '\x1b[0m');
|
||
else if (m.t === 'error') { s.status = 'error'; line('31', m.msg); t.write('\r\n\x1b[2m# press Enter to reconnect\x1b[0m\r\n'); }
|
||
else if (m.t === 'closed') { s.status = 'closed'; line('2', '# ' + m.msg + ' - press Enter to reconnect'); t.write('\r\n'); }
|
||
else if (m.t === 'ready') { s.status = 'connected'; t.write('\r\n'); fitSession(s); if (s.ws === ws && ws.readyState === 1) ws.send(JSON.stringify({ t: 'resize', cols: t.cols, rows: t.rows })); }
|
||
renderTermbarOnly();
|
||
};
|
||
ws.onclose = () => {
|
||
if (s.ws !== ws) return;
|
||
if (s.status === 'connected' || s.status === 'connecting') {
|
||
s.status = 'closed';
|
||
t.write('\r\n\x1b[2m# connection lost' + (gotCtl ? '' : ' before the session started') + ' - press Enter to reconnect\x1b[0m\r\n');
|
||
}
|
||
renderTermbarOnly();
|
||
if (S.view === 'hosts') updateHosts();
|
||
};
|
||
ws.onerror = () => { /* onclose follows */ };
|
||
}
|
||
|
||
function renderTermbarOnly() {
|
||
document.querySelectorAll('#termbar .tab').forEach((tab, i) => {
|
||
const s = S.sessions[i];
|
||
const d = tab.querySelector('.dot');
|
||
if (s && d) d.className = 'dot ' + s.status;
|
||
});
|
||
updateStatus();
|
||
}
|
||
|
||
function closeSession(key) {
|
||
const i = S.sessions.findIndex(s => s.key === key);
|
||
if (i < 0) return;
|
||
const s = S.sessions[i];
|
||
try { if (s.ws) { s.ws.onclose = null; s.ws.close(); } } catch { /* ignore */ }
|
||
try { s.term.dispose(); } catch { /* ignore */ }
|
||
s.el.remove();
|
||
S.sessions.splice(i, 1);
|
||
if (S.active === key) S.active = S.sessions.length ? S.sessions[Math.min(i, S.sessions.length - 1)].key : null;
|
||
renderTerminals();
|
||
}
|
||
|
||
function runSnippet(sn) {
|
||
const s = activeSession();
|
||
if (S.view !== 'terminals' || !s || s.status !== 'connected' || !s.ws) { toast('Open a connected terminal first.', true); return; }
|
||
s.ws.send(enc.encode(sn.command.replace(/\r?\n/g, '\n') + '\n'));
|
||
s.term.focus();
|
||
}
|
||
|
||
addEventListener('beforeunload', e => {
|
||
if (S.sessions.some(s => s.status === 'connected')) { e.preventDefault(); e.returnValue = ''; }
|
||
});
|
||
|
||
// ------------------------------------------------------------------ events
|
||
async function buildEvents() {
|
||
const body = h('div', { id: 'list' });
|
||
$('#page').replaceChildren(
|
||
h('div', { class: 'prompt' }, h('span', { class: 'ps1' }, 'labdeck', h('i', null, ':'), '~', h('i', null, '$')), h('span', null, 'tail events'), h('span', { class: 'cursor' })),
|
||
h('div', { class: 'summary dim' }, 'Outages, recoveries, certificate warnings and SSH sessions.'),
|
||
h('div', { class: 'toolbar' }, h('button', { class: 'btn', on: { click: buildEvents } }, 'Refresh')),
|
||
body);
|
||
try {
|
||
const ev = await api('GET', '/api/events?limit=300');
|
||
if (!ev.length) { body.replaceChildren(h('div', { class: 'empty' }, 'Nothing has happened yet.')); return; }
|
||
body.replaceChildren(h('table', { class: 't' },
|
||
h('thead', null, h('tr', null, ['time', 'type', 'event', 'details'].map(x => h('th', null, x)))),
|
||
h('tbody', null, ev.map(e => h('tr', { class: 'row' },
|
||
h('td', { class: 'dim', style: 'white-space:nowrap' }, fmtTime(e.ts)),
|
||
h('td', null, h('span', { class: 'chip ' + e.kind }, e.kind.replace('_', ' '))),
|
||
h('td', null, e.title),
|
||
h('td', { class: 'dim' }, e.detail))))));
|
||
} catch (e) { fail(e); }
|
||
}
|
||
|
||
// ------------------------------------------------------------------ settings
|
||
async function buildSettings() {
|
||
const page = $('#page');
|
||
let st = { webhook_url: '', webhook_kind: 'json' };
|
||
try { st = await api('GET', '/api/settings'); await loadSnippets(); } catch (e) { fail(e); }
|
||
|
||
const themeBtns = h('div', { class: 'themes' }, THEMES.map(([id, c]) => h('button', {
|
||
class: 'btn' + (currentTheme() === id ? ' primary' : ''), on: { click: () => { applyTheme(id); buildSettings(); } },
|
||
}, h('span', { class: 'sw', style: 'background:' + c }), id)));
|
||
|
||
const kind = h('select', { id: 'wk' }, [['json', 'Generic JSON'], ['discord', 'Discord'], ['telegram', 'Telegram'], ['slack', 'Slack'], ['ntfy', 'ntfy']]
|
||
.map(([v, l]) => h('option', { value: v, selected: st.webhook_kind === v }, l)));
|
||
const HOOK_HELP = {
|
||
json: ['https://example.com/hook', 'POSTs {title, message, status, time, source} as JSON.'],
|
||
discord: ['https://discord.com/api/webhooks/<id>/<token>', 'Discord: channel settings > Integrations > Webhooks > New Webhook > Copy Webhook URL.'],
|
||
telegram: ['https://api.telegram.org/bot<TOKEN>/sendMessage?chat_id=<CHAT_ID>', 'Telegram: create a bot with @BotFather for the token, send the bot a message, then find your chat id via https://api.telegram.org/bot<TOKEN>/getUpdates (result > message > chat > id). Groups have negative ids.'],
|
||
slack: ['https://hooks.slack.com/services/T000/B000/XXXX', 'Slack: create an Incoming Webhook app and copy its URL.'],
|
||
ntfy: ['https://ntfy.sh/my-homelab-alerts', 'ntfy: the full topic URL.'],
|
||
};
|
||
const url = h('input', { type: 'text', id: 'wu', value: st.webhook_url, placeholder: HOOK_HELP[st.webhook_kind]?.[0] || '', spellcheck: false });
|
||
const hookHelp = h('p', { class: 'dim' }, HOOK_HELP[st.webhook_kind]?.[1] || '');
|
||
kind.addEventListener('change', () => { url.placeholder = HOOK_HELP[kind.value][0]; hookHelp.textContent = HOOK_HELP[kind.value][1]; });
|
||
const saveHook = async () => { await api('PUT', '/api/settings', { webhook_url: url.value, webhook_kind: kind.value }); };
|
||
|
||
const snipList = h('div', null, S.snippets.length ? S.snippets.map(sn => h('div', { class: 'row' },
|
||
h('b', null, sn.name), h('span', { class: 'dim trunc grow' }, sn.command.split('\n')[0]),
|
||
h('button', { class: 'btn sm', on: { click: () => editSnippet(sn) } }, 'Edit'))) : h('p', null, 'No snippets yet.'));
|
||
|
||
const importIn = h('input', {
|
||
type: 'file', accept: 'application/json,.json', class: 'hidden', on: {
|
||
change: async e => {
|
||
const f = e.target.files[0]; if (!f) return;
|
||
try {
|
||
const r = await api('POST', '/api/import', JSON.parse(await f.text()));
|
||
toast(`Imported ${r.services} services, ${r.hosts} hosts, ${r.snippets} snippets.`);
|
||
await Promise.all([loadServices(), loadHosts(), loadSnippets()]);
|
||
buildSettings();
|
||
} catch (ex) { fail(ex); }
|
||
e.target.value = '';
|
||
},
|
||
},
|
||
});
|
||
|
||
page.replaceChildren(
|
||
h('div', { class: 'prompt' }, h('span', { class: 'ps1' }, 'labdeck', h('i', null, ':'), '~', h('i', null, '$')), h('span', null, 'config'), h('span', { class: 'cursor' })),
|
||
h('div', { class: 'summary dim' }, 'labdeck ' + S.version),
|
||
h('section', { class: 'section' }, h('h2', null, 'Appearance'), themeBtns),
|
||
h('section', { class: 'section' }, h('h2', null, 'Alerts'),
|
||
h('p', null, 'Get a message when a service goes down or recovers, or when a certificate is about to expire.'),
|
||
h('div', { class: 'row' }, h('div', { style: 'width:180px' }, kind), h('div', { class: 'grow' }, url)),
|
||
hookHelp,
|
||
h('div', { class: 'row' },
|
||
h('button', { class: 'btn primary', on: { click: async () => { try { await saveHook(); toast('Alert settings saved.'); } catch (e) { fail(e); } } } }, 'Save'),
|
||
h('button', { class: 'btn', on: { click: async () => { try { await saveHook(); await api('POST', '/api/settings/test-webhook'); toast('Test message sent.'); } catch (e) { fail(e); } } } }, 'Send test message'))),
|
||
h('section', { class: 'section' }, h('h2', null, 'Snippets'),
|
||
h('p', null, 'Saved commands. In a terminal, pick one from the snippets button or the command palette and it is typed into the shell.'),
|
||
snipList,
|
||
h('button', { class: 'btn', on: { click: () => editSnippet(null) } }, 'Add snippet')),
|
||
h('section', { class: 'section' }, h('h2', null, 'Security'),
|
||
h('p', null, 'Changing the master password re-encrypts all stored SSH credentials.'),
|
||
h('div', { class: 'row' },
|
||
h('button', { class: 'btn', on: { click: changePassword } }, 'Change master password'),
|
||
h('button', { class: 'btn', on: { click: lock } }, 'Lock now'))),
|
||
h('section', { class: 'section' }, h('h2', null, 'Backup'),
|
||
h('p', null, 'The export contains services, hosts (without passwords or keys), snippets and alert settings.'),
|
||
h('div', { class: 'row' },
|
||
h('a', { class: 'btn', href: '/api/export', download: 'labdeck-export.json' }, 'Export configuration'),
|
||
h('button', { class: 'btn', on: { click: () => importIn.click() } }, 'Import configuration'), importIn),
|
||
h('p', null, 'The database file in the data directory also holds everything, with credentials encrypted.')),
|
||
);
|
||
}
|
||
|
||
function editSnippet(sn) {
|
||
const isNew = !sn;
|
||
formModal({
|
||
title: isNew ? 'Add snippet' : 'Edit snippet', values: sn ? { ...sn } : {}, submitLabel: isNew ? 'Add snippet' : 'Save changes',
|
||
fields: [
|
||
{ key: 'name', label: 'Name', placeholder: 'Docker status' },
|
||
{ key: 'command', label: 'Command', type: 'textarea', rows: 4, placeholder: 'docker ps --format "table {{.Names}}\\t{{.Status}}"', hint: 'Typed into the terminal followed by Enter.' },
|
||
],
|
||
onSubmit: async c => {
|
||
if (isNew) await api('POST', '/api/snippets', c); else await api('PUT', '/api/snippets/' + sn.id, c);
|
||
await loadSnippets();
|
||
if (S.view === 'settings') buildSettings();
|
||
},
|
||
extra: isNew ? [] : [h('button', {
|
||
class: 'btn danger', type: 'button', on: {
|
||
click: async () => {
|
||
try { await api('DELETE', '/api/snippets/' + sn.id); closeModal(); await loadSnippets(); if (S.view === 'settings') buildSettings(); } catch (e) { fail(e); }
|
||
},
|
||
},
|
||
}, 'Delete')],
|
||
});
|
||
}
|
||
|
||
function changePassword() {
|
||
formModal({
|
||
title: 'Change master password', values: {}, submitLabel: 'Change password',
|
||
fields: [
|
||
{ key: 'old', label: 'Current password', type: 'password' },
|
||
{ key: 'neu', label: 'New password (at least 10 characters)', type: 'password' },
|
||
{ key: 'neu2', label: 'Repeat new password', type: 'password' },
|
||
],
|
||
onSubmit: async c => {
|
||
if (c.neu !== c.neu2) throw new Error('The new passwords do not match.');
|
||
await api('POST', '/api/account/password', { old: c.old, new: c.neu });
|
||
toast('Master password changed.');
|
||
},
|
||
});
|
||
}
|
||
|
||
// ------------------------------------------------------------------ command palette
|
||
function paletteItems(only) {
|
||
const items = [];
|
||
const act = S.view === 'terminals' ? activeSession() : null;
|
||
if (!only || only === 'host') for (const x of S.hosts) items.push({ label: 'ssh ' + x.name, hint: `${x.username}@${x.address}`, kind: 'host', run: () => connectHost(x) });
|
||
if (!only || only === 'snippet') for (const sn of S.snippets) items.push({ label: 'run ' + sn.name, hint: sn.command.split('\n')[0].slice(0, 50), kind: 'snippet', run: () => runSnippet(sn) });
|
||
if (only) return items;
|
||
for (const [i, [id, label]] of VIEWS.entries()) items.push({ label: 'go to ' + label, hint: 'alt+' + (i + 1), kind: 'view', run: () => setView(id) });
|
||
items.push(
|
||
{ label: 'add service', hint: '', kind: 'action', run: () => editService(null) },
|
||
{ label: 'add ssh host', hint: '', kind: 'action', run: () => editHost(null) },
|
||
{ label: 'check all services now', hint: '', kind: 'action', run: checkAll },
|
||
{ label: 'lock vault', hint: '', kind: 'action', run: lock });
|
||
for (const [t] of THEMES) items.push({ label: 'theme ' + t, hint: '', kind: 'action', run: () => { applyTheme(t); if (S.view === 'settings') buildSettings(); } });
|
||
for (const s of S.services) {
|
||
items.push({ label: 'check ' + s.name, hint: s.live.status, kind: 'service', run: () => checkNow(s.id) });
|
||
if (s.kind === 'http') items.push({ label: 'open ' + s.name, hint: s.address, kind: 'service', run: () => window.open(/^https?:\/\//.test(s.address) ? s.address : 'http://' + s.address, '_blank', 'noopener,noreferrer') });
|
||
if (s.host_id) { const x = S.hosts.find(q => q.id === s.host_id); if (x) items.push({ label: 'ssh ' + x.name + ' (from ' + s.name + ')', hint: '', kind: 'service', run: () => connectHost(x) }); }
|
||
}
|
||
void act;
|
||
return items;
|
||
}
|
||
|
||
function openPalette(opts = {}) {
|
||
if (modals.length) return;
|
||
const all = paletteItems(opts.only);
|
||
let sel = 0, shown = [];
|
||
const input = h('input', { type: 'text', placeholder: opts.only === 'snippet' ? 'run a snippet...' : opts.only === 'host' ? 'connect to...' : 'type a command...', 'aria-label': 'Command', spellcheck: false });
|
||
const ul = h('ul', { role: 'listbox' });
|
||
const box = h('div', { class: 'palette' }, input, ul);
|
||
const ov = h('div', { class: 'overlay', on: { mousedown: e => { if (e.target === ov) closeModal(); } } }, box);
|
||
const prev = document.activeElement;
|
||
document.body.append(ov);
|
||
modals.push({ ov, prev });
|
||
|
||
function render() {
|
||
const toks = input.value.toLowerCase().split(/\s+/).filter(Boolean);
|
||
shown = all.map(it => {
|
||
const hay = (it.label + ' ' + it.hint).toLowerCase();
|
||
if (!toks.every(t => hay.includes(t))) return null;
|
||
return { it, score: (it.label.toLowerCase().startsWith(toks[0] || '') ? 0 : 1) + hay.indexOf(toks[0] || '') / 1000 };
|
||
}).filter(Boolean).sort((a, b) => a.score - b.score).slice(0, 40).map(x => x.it);
|
||
sel = Math.min(sel, Math.max(0, shown.length - 1));
|
||
ul.replaceChildren(...(shown.length ? shown.map((it, i) => h('li', {
|
||
class: i === sel ? 'on' : '', role: 'option', 'aria-selected': String(i === sel),
|
||
on: { click: () => choose(it), mousemove: () => { if (sel !== i) { sel = i; render(); } } },
|
||
}, h('span', null, it.label), h('span', { class: 'hint' }, it.hint))) : [h('li', { class: 'none' }, 'No match.')]));
|
||
const on = ul.querySelector('.on'); if (on) on.scrollIntoView({ block: 'nearest' });
|
||
}
|
||
function choose(it) { closeModal(); setTimeout(() => it.run(), 0); }
|
||
input.addEventListener('input', () => { sel = 0; render(); });
|
||
input.addEventListener('keydown', e => {
|
||
if (e.key === 'ArrowDown') { e.preventDefault(); sel = Math.min(shown.length - 1, sel + 1); render(); }
|
||
else if (e.key === 'ArrowUp') { e.preventDefault(); sel = Math.max(0, sel - 1); render(); }
|
||
else if (e.key === 'Enter') { e.preventDefault(); if (shown[sel]) choose(shown[sel]); }
|
||
});
|
||
render();
|
||
input.focus();
|
||
}
|
||
|
||
// ------------------------------------------------------------------ global keys
|
||
addEventListener('keydown', e => {
|
||
if (!S.ready) return;
|
||
if (e.key === 'Escape' && modals.length) { e.preventDefault(); closeModal(); return; }
|
||
const tag = document.activeElement && document.activeElement.tagName;
|
||
const inField = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
|
||
const inTerm = document.activeElement && document.activeElement.classList.contains('xterm-helper-textarea');
|
||
if (((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k' && !inTerm) || (e.altKey && e.key.toLowerCase() === 'k')) { e.preventDefault(); openPalette(); return; }
|
||
if (e.altKey && !e.ctrlKey && /^[1-5]$/.test(e.key)) { e.preventDefault(); if (!modals.length) setView(VIEWS[Number(e.key) - 1][0]); return; }
|
||
if (e.key === '/' && !inField && !modals.length && (S.view === 'dashboard' || S.view === 'hosts')) {
|
||
const f = $('#filter'); if (f) { e.preventDefault(); f.focus(); f.select(); }
|
||
}
|
||
});
|
||
|
||
boot();
|