Files
labdeck/web/app.js
T
2026-10-27 11:32:37 +01:00

1029 lines
52 KiB
JavaScript
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.
'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: 'login' },
h('h1', null, 'lab', h('b', null, 'deck'), ' ' + S.version),
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));
}
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'); }
// ------------------------------------------------------------------ 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()),
].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 === '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();