'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//', 'Discord: channel settings > Integrations > Webhooks > New Webhook > Copy Webhook URL.'], telegram: ['https://api.telegram.org/bot/sendMessage?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/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();