127 lines
5.9 KiB
JavaScript
127 lines
5.9 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', '/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}));
|
|
}
|
|
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);
|
|
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', () => 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() {
|
|
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};
|
|
})();
|