Files
lpra/internal/server/web/static/app.js
T

133 lines
6.4 KiB
JavaScript

// The panel is one page: the header and three views (Машины, Терминал,
// Настройки). Moving between them changes the address without a reload, so
// the terminal view - and every session in it - lives as long as the tab.
// Машины and Настройки come from the server as fragments; their forms are
// sent from here and the fragment drawn again from the answer. A fragment's
// script is a module registered here, mounted on the fragment's root and
// unmounted - its timers, requests and listeners dropped - when it goes.
(function () {
'use strict';
const views = {'/': 'machines', '/workspace': 'terminal', '/terminal': 'terminal', '/settings': 'settings'};
const modules = {};
const mounted = new Map(); // area -> {mod, scope}
const $ = s => document.querySelector(s);
const viewOf = path => views[path] || null;
function register(name, mod) { modules[name] = mod; }
const scope = WSUtil.scope;
function unmount(area) {
const m = mounted.get(area);
if (!m) return;
mounted.delete(area);
try { if (m.mod.unmount) m.mod.unmount(m.root); } finally { m.scope.end(); }
}
function mount(area) {
const root = area.querySelector('[data-module]');
const mod = root && modules[root.dataset.module];
if (!mod) return;
const sc = scope();
mounted.set(area, {mod, scope: sc, root});
mod.mount(root, sc);
}
// put draws html in area, the old module unmounted and the new one mounted.
function put(area, html) {
unmount(area);
area.innerHTML = html;
mount(area);
}
const failHTML = msg => '<div class="wrap"><p class="err">' + msg + ' <button type="button" class="btn ghost small" data-retry>Повторить</button></p></div>';
function withFrag(url) { const u = new URL(url, location.href); u.searchParams.set('frag', '1'); return u; }
// A request that ended on the sign-in page: the session is over.
function signedOut(r) { return r.redirected && new URL(r.url).pathname === '/login'; }
async function load(area, url) {
let r;
try { r = await fetch(withFrag(url), {credentials: 'same-origin', cache: 'no-store'}); } catch (e) { r = null; }
if (r && signedOut(r)) { location.href = '/login'; return; }
if (!r || !r.ok) {
put(area, failHTML(r ? 'не загрузилось: ошибка ' + r.status : 'нет связи с сервером'));
area.querySelector('[data-retry]').onclick = () => load(area, url);
return;
}
put(area, await r.text());
}
function show(view) {
document.querySelectorAll('main#app > .view').forEach(v => { v.hidden = v.dataset.view !== view; });
document.querySelectorAll('[data-nav]').forEach(a => a.classList.toggle('on', a.dataset.nav === view));
$('main#app').dataset.view = view;
document.dispatchEvent(new CustomEvent('app:view', {detail: view}));
}
// The address the shell shows; a history step that keeps it (a chat on a
// phone closing by «назад») moves no view.
let shown = location.pathname + location.search;
async function go(url, push = true) {
const u = new URL(url, location.href);
const view = viewOf(u.pathname);
if (!view || u.origin !== location.origin) { location.href = u.href; return; }
if (push && u.pathname + u.search !== location.pathname + location.search) history.pushState({}, '', u.pathname + u.search + u.hash);
shown = u.pathname + u.search;
show(view);
if (view !== 'terminal') await load($('.view[data-view="' + view + '"]'), u.pathname + u.search);
}
// Links to a view move to it; any other link behaves as a link.
document.addEventListener('click', ev => {
const a = ev.target.closest('a[href]');
if (!a || ev.defaultPrevented || ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || a.target || a.hasAttribute('download')) return;
const u = new URL(a.href, location.href);
if (u.origin === location.origin && viewOf(u.pathname)) { ev.preventDefault(); go(u.href); }
});
window.addEventListener('popstate', () => { if (location.pathname + location.search !== shown) go(location.href, false); });
// A form in a fragment is sent from here: the answer's address says what
// to draw. /logout and forms outside the views go the usual way; a form
// whose onsubmit said no (a confirm) is not sent.
document.addEventListener('submit', async ev => {
const f = ev.target;
const area = f.closest('.view, .frag-area');
if (!area || ev.defaultPrevented || (f.method || '').toLowerCase() !== 'post' || /\/logout$/.test(f.action)) return;
ev.preventDefault();
let r;
try { r = await fetch(f.action, {method: 'POST', body: new FormData(f), credentials: 'same-origin'}); }
catch (e) { alert('Нет связи с сервером'); return; }
if (signedOut(r)) { location.href = '/login'; return; }
const to = new URL(r.url);
// From a dialog to a view (a machine deleted): the dialog closes.
if (!area.classList.contains('view') && viewOf(to.pathname)) {
const d = area.closest('dialog');
if (d) d.close();
go(to.href);
return;
}
if (area.classList.contains('view')) {
const v = viewOf(to.pathname);
if (v && v !== area.dataset.view) { go(to.href); return; }
history.replaceState({}, '', to.pathname + to.search + to.hash);
}
// The answer is the page whole (the shell): ask for its fragment.
await load(area, to.pathname + to.search);
if (to.hash) { const t = area.querySelector(to.hash); if (t) t.scrollIntoView(); }
});
// Leaving the page drops the terminals: the browser asks while one is alive.
window.addEventListener('beforeunload', ev => {
if (window.Workspace && window.Workspace.hasLiveSessions()) { ev.preventDefault(); ev.returnValue = ''; }
});
// start mounts what the server drew, once every script has registered.
function start() {
// The old address of the workspace shows as the new one.
if (location.pathname === '/terminal') { history.replaceState({}, '', '/workspace' + location.search); shown = '/workspace' + location.search; }
document.querySelectorAll('main#app > .view').forEach(mount);
show({dashboard: 'machines', settings: 'settings'}[$('main#app').dataset.view] || 'terminal');
// /agents/{id}/terminal or /files: that pane, at once.
const o = window.APP && window.APP.open;
if (o && window.Workspace) window.Workspace.open(o.kind, o.agent);
}
window.App = {go, load, register, put, start, scope};
})();