// Scripts of the pages the shell shows as fragments, as modules: mount(root, // S) runs when the fragment is put in place, with S the module's scope - // timers, requests and listeners that end when it is replaced or closed. (function () { 'use strict'; const CHECK = ''; function copy(text) { if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text); const ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0'; document.body.appendChild(ta); ta.select(); const ok = document.execCommand('copy'); ta.remove(); return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу')); } App.register('settings', { mount(root) { root.querySelectorAll('.copybox .copy').forEach(b => { const icon = b.innerHTML; b.onclick = () => copy(root.querySelector('#' + b.dataset.copy).textContent.trim()).then(() => { b.innerHTML = CHECK; b.classList.add('done'); b.title = 'Скопировано'; setTimeout(() => { b.innerHTML = icon; b.classList.remove('done'); b.title = 'Скопировать'; }, 1500); }, e => alert('Не скопировалось: ' + e.message)); }); if (window.UsersTable) window.UsersTable.mount(root); // Автообновление: look at GitLab now, and update now. const au = root.querySelector('#autoupdate .auto-check'); if (au) { const st = root.querySelector('#autoupdate .auto-status'), run = root.querySelector('#autoupdate .auto-run'); const post = u => fetch(u, {method: 'POST', credentials: 'same-origin'}).then(r => r.json()).catch(() => ({ok: false, error: 'нет связи с сервером'})); const say = (text, bad) => { st.textContent = ''; const s = document.createElement('span'); s.textContent = text; if (bad) s.className = 'bad'; st.appendChild(s); }; au.onclick = async () => { au.disabled = true; say('Проверяю…'); const r = await post('/api/panel/update/check'); au.disabled = false; if (!r.ok) { say(r.error, true); return; } say('Версия сервера: ' + r.current + ' · на GitLab: ' + r.latest + (r.newer ? ' - вышла новая' : ' - это последняя')); run.hidden = !r.newer; }; run.onclick = async () => { if (!await UI.confirm('Обновить сервер сейчас? Панель будет недоступна около минуты.', {ok: 'Обновить'})) return; run.disabled = true; say('Запускаю обновление…'); const r = await post('/api/panel/update/run'); run.disabled = false; if (!r.ok) { say(r.error, true); return; } run.hidden = true; say('Обновление до ' + r.latest + ' запущено: сервер перезапустится в течение минуты - потом обновите страницу.'); }; } // ИИ-ассистент: a card per model, added from the blank one and taken // away by its «Удалить» (the last one stays). const list = root.querySelector('.ai-models'), blank = root.querySelector('template.ai-model-new'); if (list && blank) { root.querySelector('.ai-model-add').onclick = () => { const card = blank.content.firstElementChild.cloneNode(true); list.appendChild(card); card.querySelector('[name=m_name]').focus(); }; list.addEventListener('click', ev => { const del = ev.target.closest('.ai-model-del'); if (del && list.children.length > 1) del.closest('.ai-model').remove(); }); // A folded card's line follows its fields: the name, the kind and model. list.addEventListener('input', ev => { const card = ev.target.closest('.ai-model'); if (!card) return; const v = n => card.querySelector('[name=' + n + ']').value.trim(); card.querySelector('.ai-model-title').textContent = v('m_name') || 'Модель'; card.querySelector('.ai-model-sub').textContent = v('m_type') + (v('m_model') ? ' · ' + v('m_model') : ''); }); // A field to fix in a folded card: the card opens to show it. list.closest('form').addEventListener('invalid', ev => { const d = ev.target.closest('details'); if (d) d.open = true; }, true); } } }); // The machines list keeps its status column live; nothing is asked while // the tab is hidden. App.register('dashboard', { mount(root, S) { // Terminal and files buttons open a tab in the workspace, no reload. S.on(root, 'click', function (ev) { var b = ev.target.closest('[data-open]'); if (!b || !window.Workspace || !Workspace.paneTypes[b.dataset.open]) return; ev.preventDefault(); Workspace.open(b.dataset.open, {id: b.dataset.id, name: b.dataset.name, host: b.dataset.host, user: b.dataset.user}); }); var setTimeout = S.timeout, clearTimeout = S.clear, fetch = S.fetch; var rows = {}; root.querySelectorAll('tr[data-agent]').forEach(function (tr) { rows[tr.dataset.agent] = tr; }); var timer = null, busy = false; function schedule() { clearTimeout(timer); if (!document.hidden) timer = setTimeout(load, 5000); } function load() { if (busy || document.hidden) return; busy = true; fetch('/api/panel/agents/presence', {credentials: 'same-origin'}) .then(function (r) { return r.ok ? r.json() : []; }) .then(function (list) { list.forEach(function (a) { var tr = rows[a.id]; if (!tr) return; tr.querySelector('.dot').className = 'dot ' + (a.online ? 'on' : 'off'); tr.querySelector('.state').textContent = a.online ? 'online' : 'offline'; tr.querySelector('.seen').textContent = a.seen; }); }) .catch(function () {}) .then(function () { busy = false; schedule(); }); } S.on(document, 'visibilitychange', function () { if (document.hidden) clearTimeout(timer); else load(); }); if (Object.keys(rows).length) schedule(); } }); // Ports: the note under the mode follows the chosen mode. App.register('forwards', { mount(root) { var notes = { http: 'Трафик идёт через соединение агента с панелью (порт ' + root.dataset.panelPort + '): по WebSocket, без него - long-poll. Порт ' + root.dataset.tunnelPort + ' не требуется.', tcp: 'Требуется открытый порт ' + root.dataset.tunnelPort + '/tcp на хосте. Самая низкая задержка.', udp: 'Требуется открытый порт ' + root.dataset.tunnelPort + '/udp на хосте.' }; var sel = root.querySelector('#proto'), note = root.querySelector('#note'); if (!sel || !note) return; function upd() { note.textContent = notes[sel.value] || ''; } sel.onchange = upd; upd(); } }); // Status: meters, live speed and the speed test of a machine. App.register('status', { mount(root, S) { // Timers, requests and listeners end with the page. var setTimeout = S.timeout, setInterval = S.interval, clearTimeout = S.clear, clearInterval = S.clear, fetch = S.fetch; var base = '/api/panel/agents/' + encodeURIComponent(root.dataset.agent); var $ = function (id) { return root.querySelector('#' + id); }; var errBox = $('err'); function size(n) { var u = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ'], i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return n.toFixed(n < 10 && i > 0 ? 1 : 0) + ' ' + u[i]; } function rateParts(bps) { var u = ['бит/с', 'Кбит/с', 'Мбит/с', 'Гбит/с'], i = 0; while (bps >= 1000 && i < u.length - 1) { bps /= 1000; i++; } return [bps.toFixed(bps < 100 && i > 0 ? 1 : 0), u[i]]; } function rate(bps) { return rateParts(bps).join(' '); } function meter(name, pct, sub) { pct = Math.max(0, Math.min(100, pct)); $(name + '-val').textContent = Math.round(pct) + '%'; var bar = $(name + '-bar'); bar.style.transform = 'scaleX(' + pct / 100 + ')'; bar.className = pct >= 90 ? 'crit' : pct >= 75 ? 'warn' : ''; $(name + '-sub').textContent = sub; } function showErr(msg) { errBox.textContent = msg || ''; errBox.hidden = !msg; } function cores(n) { var d = n % 10, h = n % 100; return n + (d === 1 && h !== 11 ? ' ядро' : d >= 2 && d <= 4 && (h < 12 || h > 14) ? ' ядра' : ' ядер'); } // One request at a time, and none while the tab is hidden: the client is // sampled only for a page somebody is looking at. var timer = null, busy = false; function schedule(ms) { clearTimeout(timer); if (!document.hidden) timer = setTimeout(load, ms); } function load() { if (busy || document.hidden) return; busy = true; var next = 3000; fetch(base + '/stat', {credentials: 'same-origin'}) .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) .then(function (res) { if (!res.ok) { showErr(res.error || 'ошибка'); next = 10000; return; } showErr(''); var d = res.data; meter('cpu', d.cpu, cores(d.ncpu)); meter('mem', d.mem_total ? 100 * d.mem_used / d.mem_total : 0, 'занято ' + size(d.mem_used) + ' из ' + size(d.mem_total) + ' · свободно ' + size(d.mem_total - d.mem_used)); meter('disk', d.disk_total ? 100 * (d.disk_total - d.disk_free) / d.disk_total : 0, 'свободно ' + size(d.disk_free) + ' из ' + size(d.disk_total)); }) .catch(function (e) { showErr('Нет связи с панелью: ' + e.message); next = 10000; }) .then(function () { busy = false; schedule(next); }); } S.on(document, 'visibilitychange', function () { if (document.hidden) clearTimeout(timer); else load(); }); load(); var btn = $('speed-btn'), note = $('speed-note'); function json(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); } function post(path) { return fetch(base + path, {method: 'POST', credentials: 'same-origin'}).then(json); } // The browser's round trip to the panel: the first request may pay for a // new connection, so keep the best. function panelPing() { var best = Infinity, left = 4; function one() { var t = performance.now(); return fetch('/api/panel/ping', {credentials: 'same-origin', cache: 'no-store'}) .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); }) .then(function () { best = Math.min(best, performance.now() - t); return --left > 0 ? one() : best; }); } return one(); } // Keeps the client's download fed until stopped or the panel says the // client has read enough; the pieces grow while they go through quickly. function feed(stop) { var block = new Uint8Array(65536), n = 4; crypto.getRandomValues(block); function one() { var parts = [], t = performance.now(); for (var i = 0; i < n; i++) parts.push(block); return fetch(base + '/speed/down', {method: 'POST', body: new Blob(parts), credentials: 'same-origin', signal: stop.signal}) .then(function (r) { if (r.status !== 204) return; if (performance.now() - t < 500 && n < 256) n *= 2; return one(); }); } one().catch(function () {}); } // Reads what the client sends and times it from the first byte. It stops by // itself a little after the client's window, so bytes still queued on a slow // link don't hold the result back. st.n is the count so far, for the live // reading. function drain(st, onFirst) { var ac = new AbortController(), last = 0; st.stop = function () { ac.abort(); }; function done() { return st.start && last > st.start ? st.n * 8000 / (last - st.start) : 0; } return fetch(base + '/speed/up', {credentials: 'same-origin', cache: 'no-store', signal: ac.signal}).then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); var rd = r.body.getReader(); function pump() { return rd.read().then(function (c) { if (c.done) return done(); last = performance.now(); if (!st.start) { st.start = last; setTimeout(st.stop, Number(root.dataset.relayUpMs)); onFirst(); } else st.n += c.value.length; return pump(); }); } return pump(); }).catch(function (e) { if (st.start) return done(); throw e; }); } // The test through the browser: the client's download is what this page // uploads, and its upload is timed here, where it arrives. function relayTest(st) { return panelPing().then(function (ping) { return post('/speed/relay').then(function (res) { if (!res.ok) return res; var stop = new AbortController(); var up = drain(st, function () { stop.abort(); }); up.catch(function () {}); feed(stop); return post('/speed?relay=1').then(function (res) { stop.abort(); // No upload under way by now: none is coming. if (!res.ok || !st.start) { st.stop(); } if (!res.ok) return res; return up.catch(function () { return 0; }).then(function (bps) { res.data.ping_ms += ping; if (bps) res.data.up_bps = bps; return res; }); }, function (e) { stop.abort(); st.stop(); throw e; }); }); }); } // The live reading: the rate over the last second, from the byte counts the // panel keeps for the running test - or, for the client's upload in a test // through the browser, from what has arrived here. var ARC = 251.4, lv = null; function liveStart(relay, st) { lv = {relay: relay, st: st, phase: '', pts: [], hist: [], busy: false}; $('live').hidden = false; liveShow('ping', 0); lv.timer = setInterval(liveTick, 200); } function liveStop() { if (!lv) return; clearInterval(lv.timer); lv = null; $('live').hidden = true; } function liveTick() { var cur = lv; if (cur.relay && cur.st.start) { livePoint(cur, 'up', cur.st.n, performance.now()); return; } if (cur.busy) return; cur.busy = true; fetch(base + '/speed/progress', {credentials: 'same-origin', cache: 'no-store'}) .then(json) .then(function (d) { if (cur !== lv || !d.phase || (cur.relay && cur.st.start)) return; livePoint(cur, d.phase, d.phase === 'up' ? d.up : d.down, d.ms); }) .catch(function () {}) .then(function () { cur.busy = false; }); } function livePoint(cur, phase, bytes, ms) { if (phase !== cur.phase) { cur.phase = phase; cur.pts = []; cur.hist = []; } if (phase === 'ping') { liveShow(phase, 0); return; } var pts = cur.pts; pts.push([ms, bytes]); while (pts.length > 2 && ms - pts[0][0] > 1000) pts.shift(); if (pts.length < 2 || ms <= pts[0][0]) return; var bps = (bytes - pts[0][1]) * 8000 / (ms - pts[0][0]); cur.hist.push(bps); liveShow(phase, bps, cur); } function liveShow(phase, bps, cur) { var relay = lv && lv.relay, p = rateParts(bps); $('live').className = 'live' + (phase === 'up' ? ' up' : ''); $('lv-ico').textContent = phase === 'up' ? '↑' : phase === 'down' ? '↓' : ''; $('lv-phase').textContent = phase === 'ping' ? 'Задержка…' : phase === 'up' ? (relay ? 'клиент → браузер' : 'клиент → хост') : (relay ? 'браузер → клиент' : 'хост → клиент'); $('lv-num').textContent = phase === 'ping' ? '…' : p[0]; $('lv-unit').textContent = phase === 'ping' ? '' : p[1]; // A log scale up to 1 Гбит/с, so a slow link moves the needle too. var f = Math.min(1, Math.log(1 + bps / 1e6) / Math.log(1001)); $('lv-arc').style.strokeDashoffset = ARC * (1 - f); var h = cur ? cur.hist : [], max = 1.25 * Math.max.apply(null, h.concat(1)), line = ''; for (var i = 0; i < h.length; i++) { line += (h.length > 1 ? 100 * i / (h.length - 1) : 0).toFixed(1) + ',' + (98 - 94 * h[i] / max).toFixed(1) + ' '; } $('lv-line').setAttribute('points', line); $('lv-area').setAttribute('points', h.length > 1 ? '0,100 ' + line + '100,100' : ''); } btn.onclick = function () { var relay = $('speed-relay').checked, st = {n: 0, start: 0, stop: function () {}}; btn.disabled = true; note.textContent = 'Идёт замер…'; $('speed').hidden = true; liveStart(relay, st); (relay ? relayTest(st) : post('/speed')) .then(function (res) { if (!res.ok) { note.textContent = res.error || 'ошибка'; return; } var d = res.data; $('sp-ping').textContent = d.ping_ms ? d.ping_ms.toFixed(d.ping_ms < 10 ? 1 : 0) + ' мс' : '-'; $('sp-down').textContent = rate(d.down_bps); $('sp-up').textContent = d.up_bps ? rate(d.up_bps) : '-'; $('sp-down-lbl').textContent = relay ? 'браузер → клиент' : 'хост → клиент'; $('sp-up-lbl').textContent = relay ? 'клиент → браузер' : 'клиент → хост'; $('speed').hidden = false; note.textContent = d.partial ? 'Замер остановлен по времени - показано то, что успело набраться' : ''; }) .catch(function (e) { note.textContent = 'Не удалось: ' + e.message; }) .then(function () { liveStop(); btn.disabled = false; }); }; } }); })();