// 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; } // scope gives a module timers, requests and listeners that end with it. function scope() { const timers = new Set(), offs = [], ac = new AbortController(); return { timeout(fn, ms) { const id = setTimeout(() => { timers.delete(id); fn(); }, ms); timers.add(id); return id; }, interval(fn, ms) { const id = setInterval(fn, ms); timers.add(id); return id; }, clear(id) { clearTimeout(id); clearInterval(id); timers.delete(id); }, fetch(url, opt) { const o = Object.assign({credentials: 'same-origin'}, opt || {}); // A request with its own signal ends on either. o.signal = o.signal && AbortSignal.any ? AbortSignal.any([o.signal, ac.signal]) : (o.signal || ac.signal); return fetch(url, o); }, on(target, ev, fn, opt) { target.addEventListener(ev, fn, opt); offs.push(() => target.removeEventListener(ev, fn, opt)); }, signal: ac.signal, end() { timers.forEach(id => { clearTimeout(id); clearInterval(id); }); timers.clear(); offs.forEach(f => f()); ac.abort(); } }; } 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 => '

' + msg + '

'; 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}; })();