diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f750da7..c604563 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,3 +62,55 @@ jobs: # Same script developers run locally via ./tools/run-tests.sh, so green here == green there. # shellcheck is pre-installed on the ubuntu runners. run: PYTHON=python bash tools/run-tests.sh + + coverage: + name: coverage + runs-on: ubuntu-latest + # Informational, and deliberately a SEPARATE job: it re-runs the suites under tracing, which is + # slower, and the gate above should stay the fast answer. Codacy has a 60% coverage goal that + # has always read as "not reported" because nothing ever produced a report. + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Install dependencies + run: python -m pip install --quiet -r requirements.txt coverage + + - name: Measure + # --parallel-mode + combine: each suite is its own process, and smoke/rbac fork threads. + # || true on the suites themselves — the gate job decides pass/fail, this one only measures. + run: | + for suite in unit template_actions smoke rbac; do + rm -f data/panel.db data/panel.db-shm data/panel.db-wal data/panel.db.backup + python -m coverage run --parallel-mode --source=. \ + --omit="./tests/*,./tools/*,./.venv/*" "tests/${suite}_test.py" >/dev/null 2>&1 || true + done + python -m coverage combine + python -m coverage xml -o coverage.xml + python -m coverage report --sort=miss > coverage.txt + { + echo '### Coverage' + echo '' + echo '```' + tail -n 20 coverage.txt + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload the report + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: coverage + path: | + coverage.xml + coverage.txt + + # Codacy's coverage goal only lights up once a report is uploaded, which needs a project + # token this repo does not have. To enable: add CODACY_PROJECT_TOKEN as a repository secret + # and uncomment. Left off rather than half-wired, so the job never fails on a missing secret. + # - name: Send to Codacy + # env: + # CODACY_PROJECT_TOKEN: ${{ secrets.CODACY_PROJECT_TOKEN }} + # run: bash <(curl -Ls https://coverage.codacy.com/get.sh) report -r coverage.xml diff --git a/static/js/dashboard.js b/static/js/dashboard.js new file mode 100644 index 0000000..ad7a6ba --- /dev/null +++ b/static/js/dashboard.js @@ -0,0 +1,509 @@ +function titleCase(s){ return s ? s.charAt(0).toUpperCase()+s.slice(1) : ''; } + +// Client-side filter of the server list (name / short name / type / connect address). +// Hides remote cards with no matching rows, and shows an empty-state when nothing matches. +// Tag ids currently toggled on in the filter bar. Deliberately not persisted: a filter is a +// momentary lens, and a saved one would leave a user staring at a half-empty dashboard after a +// week away with no memory of why. +var _tagFilter = []; + +// Toggle one tag in the filter, then re-run the single filter pass below so tag + text compose. +window.toggleTagFilter = function(tagId, btn){ + var at = _tagFilter.indexOf(tagId); + if (at === -1) _tagFilter.push(tagId); else _tagFilter.splice(at, 1); + var on = at === -1; + btn.classList.toggle('active', on); + btn.setAttribute('aria-pressed', on ? 'true' : 'false'); + filterServers(); +}; + +function rowHasEveryTag(tr){ + if (!_tagFilter.length) return true; + var have = Array.prototype.map.call(tr.querySelectorAll('.tag-chip'), + function(c){ return parseInt(c.getAttribute('data-tag-id'), 10); }); + // AND, not OR: picking "production" + "modded" should narrow to servers that are both, which is + // what makes the filter useful as a bulk-action selector. + return _tagFilter.every(function(id){ return have.indexOf(id) !== -1; }); +} + +function filterServers(){ + var input = document.getElementById('srv-search'); + var q = input ? input.value.trim().toLowerCase() : '', anyVisible = false; + document.querySelectorAll('.server-remote-card').forEach(function(card){ + var cardHas = false; + card.querySelectorAll('tbody tr').forEach(function(tr){ + var match = (!q || tr.textContent.toLowerCase().indexOf(q) !== -1) && rowHasEveryTag(tr); + tr.style.display = match ? '' : 'none'; + if(match) cardHas = true; + }); + card.style.display = cardHas ? '' : 'none'; + if(cardHas) anyVisible = true; + }); + var none = document.getElementById('srv-none'); + // Show the empty-state when EITHER lens is active and matched nothing — a tag filter that hides + // everything used to leave a blank page with no explanation. + if(none) none.style.display = ((q || _tagFilter.length) && !anyVisible) ? '' : 'none'; +} + +// Tick every row the current filters leave visible, so "filter by tag, then act on all of them" is +// two clicks. The bulk endpoint still re-checks the action permission and per-server access, so +// this only ever selects rows the user can already see and act on. +window.selectAllShown = function(){ + document.querySelectorAll('.srv-check').forEach(function(cb){ + var tr = cb.closest('tr'); + cb.checked = !!(tr && tr.style.display !== 'none'); + }); + updateBulkBar(); +}; + +// The set of game-server ids currently rendered on this page (sorted, comma-joined), +// so we can detect when another user has added or removed one. +function renderedServerIds() { + return Array.prototype.map.call(document.querySelectorAll('[id^="status-"]'), + function(el){ return el.id.slice('status-'.length); }).sort().join(','); +} +var _serverIdsBaseline = null; + +// Live status: update the count tiles + each server row's status cell. +function refreshStatus() { + fetch(MOUNT + '/api/servers') + .then(r => r.json()) + .then(data => { + // If the set of servers changed underneath us (another user installed/uninstalled one), the + // rows are server-rendered — pull the fresh cards into #server-cards in place (no reload). + var liveIds = data.map(function(s){ return String(s.id); }).sort().join(','); + if (_serverIdsBaseline !== null && liveIds !== _serverIdsBaseline) { + _serverIdsBaseline = liveIds; // adopt now so this doesn't re-fire while the swap is in flight + window.refreshSection('#server-cards', 'afterDashRefresh'); + return; + } + var online = data.filter(s => s.status === 'online').length; + var offline = data.filter(s => s.status === 'offline').length; + var oc = document.getElementById('online-count'); + var fc = document.getElementById('offline-count'); + var tc = document.getElementById('total-servers'); + if (oc) oc.innerHTML = ' ' + online; + if (fc) fc.innerHTML = ' ' + offline; + if (tc) tc.textContent = data.length; + // Total online / total capacity = sums of the known per-server values (unknowns excluded). + var totalPlayers = data.reduce(function(a, s){ return a + (typeof s.players === 'number' ? s.players : 0); }, 0); + var totalMax = data.reduce(function(a, s){ return a + (typeof s.max_players === 'number' ? s.max_players : 0); }, 0); + var tp = document.getElementById('total-players'); + if (tp) tp.innerHTML = ' ' + totalPlayers + (totalMax > 0 ? ' / ' + totalMax : ''); + data.forEach(function(s){ + var pcell = document.getElementById('players-' + s.id); + if (pcell) { + pcell.innerHTML = (typeof s.players === 'number') + ? ' ' + s.players + (typeof s.max_players === 'number' && s.max_players > 0 ? ' / ' + s.max_players : '') + : ''; + } + var cell = document.getElementById('status-' + s.id); + if (cell && cell.dataset.status !== s.status) { + cell.dataset.status = s.status; + var cls = (s.status === 'online' || s.status === 'offline') ? s.status : 'unknown'; + cell.innerHTML = ' ' + titleCase(s.status); + } + var conn = document.getElementById('connect-' + s.id); + var key = (s.connect || '') + '|' + (s.connect_url || ''); + if (conn && s.connect && conn.dataset.addr !== key) { + conn.dataset.addr = key; + // Build via textContent + a listener (NOT innerHTML with the raw address) so a + // hostile connect address can't inject HTML/script into the dashboard. + conn.textContent = ''; + var code = document.createElement('code'); + code.className = 'text-info'; + code.style.cssText = 'font-size:.78rem;cursor:pointer;'; + code.title = 'Click to copy'; + code.textContent = s.connect; + code.addEventListener('click', function(){ copyAddr(s.connect); }); + conn.appendChild(code); + // One-click join link (steam://connect/…) for games that support it. + if (s.connect_url) { + var join = document.createElement('a'); + join.className = 'btn btn-success btn-sm py-0 px-1 ms-2 join-link'; + join.style.fontSize = '.7rem'; + join.rel = 'noopener'; + join.title = 'Launch the game and join (on phones, taps to copy the address)'; + join.href = s.connect_url; // href property assignment — no HTML parsing + if (s.connect) join.setAttribute('data-addr', s.connect); // touch fallback: copy addr + join.innerHTML = ' Join'; + conn.appendChild(join); + } + } + // In-game server name (the hostname players see), from gamedig. textContent, never innerHTML, + // so a hostile server name can't inject markup. Keep the old value when none is reported. + var nm = document.getElementById('game-name-' + s.id); + if (nm && s.game_name && nm.textContent !== s.game_name) nm.textContent = s.game_name; + // Keep the row's sort keys in sync with live status/players so a re-sort reflects reality. + var row = cell ? cell.closest('tr') : null; + if (row) { + row.setAttribute('data-status', s.status); + row.setAttribute('data-players', typeof s.players === 'number' ? s.players : -1); + // Disable start/restart/stop + console while a server is installing/configuring (or not + // installed yet), and re-enable them live the moment it's ready — no page reload needed. + var busy = !s.installed || s.status === 'installing' || s.status === 'configuring'; + row.querySelectorAll('.srv-ctl').forEach(function(b){ b.disabled = busy; }); + var con = row.querySelector('.srv-console'); + if (con) { + con.classList.toggle('disabled', busy); + if (busy) { con.setAttribute('tabindex', '-1'); con.setAttribute('aria-disabled', 'true'); } + else { con.removeAttribute('tabindex'); con.removeAttribute('aria-disabled'); } + } + } + }); + }) + .catch(() => {}); +} + +// Click-to-sort a single host card's table (the dashboard is already grouped by host into cards, so +// each card sorts its own servers). Toggles asc/desc per column; players numeric, others +// case-insensitive. Rows are moved with appendChild — cells keep their ids so the poller still finds +// them. +window.sortDashCol = function(key, th){ + var table = th.closest('table'); if (!table) return; + var tb = table.querySelector('tbody'); if (!tb) return; + var dir = (table.dataset.sortKey === key) ? -(parseInt(table.dataset.sortDir || '1', 10)) : 1; + table.dataset.sortKey = key; table.dataset.sortDir = String(dir); + Array.prototype.slice.call(tb.querySelectorAll('tr')).sort(function(a, b){ + if (key === 'players') { + return ((parseInt(a.getAttribute('data-players'), 10)) - (parseInt(b.getAttribute('data-players'), 10))) * dir; + } + return (a.getAttribute('data-' + key) || '').localeCompare(b.getAttribute('data-' + key) || '', undefined, {sensitivity: 'base', numeric: true}) * dir; + }).forEach(function(r){ tb.appendChild(r); }); + table.querySelectorAll('th[data-sortkey]').forEach(function(h){ + var c = h.querySelector('.dash-caret'); + if (c) c.textContent = (h.getAttribute('data-sortkey') === key) ? (dir > 0 ? ' ▲' : ' ▼') : ''; + }); +}; +_serverIdsBaseline = renderedServerIds(); // what this page was rendered with +refreshStatus(); // populate status + connect immediately +pollWhenVisible(refreshStatus, 8000); + +// Live resource metrics (per-host CPU/RAM/disk in each card header + the summary tile, and per-server +// CPU/RAM/uptime in the Resources column). Heavier than the status feed (an SSH sample per server), +// so it polls on a slower cadence. +function _fmtUptimeShort(s){ + s = Math.max(0, s|0); + var d = Math.floor(s/86400), h = Math.floor((s%86400)/3600), m = Math.floor((s%3600)/60); + return d ? (d+'d '+h+'h') : (h ? (h+'h '+m+'m') : (m+'m')); +} +function refreshMetrics(){ + fetch(MOUNT + '/api/dashboard/metrics').then(function(r){ return r.ok ? r.json() : null; }) + .then(function(d){ + if(!d) return; + var hosts = d.hosts || {}, servers = d.servers || {}, summary = null; + Object.keys(hosts).forEach(function(rid){ + var h = hosts[rid], el = document.getElementById('host-metrics-' + rid); + if(el) el.textContent = 'CPU ' + h.cpu + '% · RAM ' + h.ram_pct + '% · Disk ' + h.disk_pct + '%'; + if(h.local || summary === null) summary = h; + }); + var sum = document.getElementById('host-summary'); + if(sum) sum.innerHTML = summary + ? ' ' + summary.cpu + '% · ' + summary.ram_pct + '%' + : ' '; + Object.keys(servers).forEach(function(sid){ + var s = servers[sid], cell = document.getElementById('res-' + sid); + if(cell) cell.innerHTML = s.up + ? (' ' + s.cpu + '% · ' + s.ram_mb + ' MB' + + (s.uptime ? ' · ' + _fmtUptimeShort(s.uptime) + '' : '')) + : ''; + var mapEl = document.getElementById('map-' + sid); + if(mapEl){ + if(s.up && s.map){ mapEl.innerHTML = ' ' + s.map; mapEl.classList.remove('d-none'); } + else { mapEl.classList.add('d-none'); } + } + }); + }).catch(function(){}); +} +refreshMetrics(); +pollWhenVisible(refreshMetrics, 10000); +// Instant reaction when another user adds/removes a server (poll above is the fallback). +if (window.onServersChanged) onServersChanged(refreshStatus); + +// After #server-cards is swapped in place (a server appeared/disappeared elsewhere), re-apply the +// search filter and repaint live statuses/connect cells onto the fresh rows — no full-page reload. +// Bind drag to every reorderable region on this page. Same save path as the arrow buttons, so a +// dragged order is server-rendered afterwards exactly like a clicked one. +function bindDragRegions(){ + if (!window.makeSortable) return; + makeSortable(document.getElementById('dash-tiles'), + {itemSelector: '[data-panel]', axis: 'x', onDrop: saveHostOrder}); + makeSortable(document.getElementById('server-cards'), + {itemSelector: '.server-remote-card', axis: 'y', onDrop: saveHostOrder}); + document.querySelectorAll('.server-remote-card tbody').forEach(function(tb){ + makeSortable(tb, {itemSelector: 'tr[data-server-id]', axis: 'y', onDrop: saveHostOrder}); + }); +} + +window.afterDashRefresh = function(){ + if (typeof filterServers === 'function') filterServers(); + refreshStatus(); + // #server-cards itself survives a refreshSection (only its innerHTML is replaced), so its own + // listener persists — but the tbody elements inside are new objects and need binding again. + bindDragRegions(); +}; + +// ── Per-user host-card order ──────────────────────────────────────────────────────────────── +// The server renders the saved order, so this only has to (a) move the node for instant feedback +// and (b) persist the new order. #server-cards is swapped wholesale by refreshSection() on a poll +// or another user's install, and the replacement HTML already carries the saved order — which is +// why nothing here needs re-applying afterwards. +var _orderSaveTimer = null; + +function hostCards(){ + // #srv-none is a sibling sentinel inside #server-cards — never treat it as a host card. + return Array.prototype.slice.call(document.querySelectorAll('#server-cards > .server-remote-card')); +} + +// Both orders are sent together on any change: they are one layout, the endpoint validates each +// key independently, and sending both keeps them from drifting apart if a save is ever dropped. +// ── Movable panels (the stat tiles today; any [data-region] container tomorrow) ───────────────── +// A region is a container with data-region; its children carry data-panel="". The SERVER +// renders the saved order — this only moves the node for instant feedback and persists the result. +function collectPanels(){ + var panels = {}, hidden = {}; + document.querySelectorAll('[data-region]').forEach(function(region){ + var key = region.getAttribute('data-region'); + panels[key] = Array.prototype.slice.call(region.querySelectorAll(':scope > [data-panel]')) + .map(function(el){ return el.getAttribute('data-panel'); }); + // Hidden keys live on the restore bar, not in the region — they are not rendered at all. + var bar = document.getElementById(region.id + '-hidden'); + hidden[key] = bar ? Array.prototype.slice.call(bar.querySelectorAll('[data-action="showPanel"]')) + .map(function(b){ return JSON.parse(b.getAttribute('data-args'))[1]; }) : []; + }); + // Every key this page rendered a control for, so the server can keep the ones it did not. + return {panels: panels, hidden: hidden, + declared: Object.keys(panels).reduce(function(acc, k){ + acc[k] = panels[k].concat(hidden[k] || []); return acc; }, {})}; +} + +window.movePanel = function(dir, btn){ + var panel = btn.closest('[data-panel]'); + var region = panel && panel.closest('[data-region]'); + if (!region) return; + var sibs = Array.prototype.slice.call(region.querySelectorAll(':scope > [data-panel]')); + var at = sibs.indexOf(panel), to = at + (dir < 0 ? -1 : 1); + if (at < 0 || to < 0 || to >= sibs.length) return; + if (dir < 0) region.insertBefore(panel, sibs[to]); + else region.insertBefore(sibs[to], panel); + var keep = panel.querySelector('.panel-tools button:not([disabled])'); + if (keep) keep.focus(); + saveHostOrder(); +}; + +// Hiding removes the node and drops a restore chip, so the next server render agrees with what the +// user is looking at right now (the server skips hidden panels entirely). +window.hidePanel = function(btn){ + var panel = btn.closest('[data-panel]'); + var region = panel && panel.closest('[data-region]'); + if (!region) return; + var key = panel.getAttribute('data-panel'); + var label = (panel.querySelector('.text-secondary.small') || {}).textContent || key; + var bar = document.getElementById(region.id + '-hidden'); + if (!bar){ + bar = document.createElement('div'); + bar.id = region.id + '-hidden'; + bar.className = 'mb-4 d-flex align-items-center gap-2 flex-wrap'; + var lead = document.createElement('span'); + lead.className = 'text-secondary small'; + lead.textContent = 'Hidden:'; + bar.appendChild(lead); + region.parentNode.insertBefore(bar, region.nextSibling); + } + var chip = document.createElement('button'); + chip.type = 'button'; + chip.className = 'btn btn-sm btn-outline-secondary'; + chip.setAttribute('data-action', 'showPanel'); + chip.setAttribute('data-args', JSON.stringify([region.getAttribute('data-region'), key, '@self'])); + chip.title = 'Show this panel again'; + chip.textContent = label.trim(); + bar.appendChild(chip); + panel.remove(); + saveHostOrder(); +}; + +// Restoring needs the panel's markup back, which only the server has — so save first, then reload +// once the save is acknowledged. Reloading before it lands would resurrect the old layout. +window.showPanel = function(region, key, btn){ + btn.remove(); + saveHostOrder(function(){ location.reload(); }); +}; + +function collectLayout(){ + var host_order = [], server_order = {}; + hostCards().forEach(function(card){ + var rid = parseInt(card.getAttribute('data-remote-id'), 10); + if (isNaN(rid)) return; + host_order.push(rid); + server_order[rid] = Array.prototype.slice + .call(card.querySelectorAll('tbody tr[data-server-id]')) + .map(function(tr){ return parseInt(tr.getAttribute('data-server-id'), 10); }) + .filter(function(n){ return !isNaN(n); }); + }); + var p = collectPanels(); + return {host_order: host_order, server_order: server_order, + panels: p.panels, hidden: p.hidden, declared: p.declared}; +} + +// `then` runs only after the server has ACKNOWLEDGED the save — showPanel needs that, because it +// reloads to get the restored panel's markup and a reload before the save lands would undo it. +function saveHostOrder(then){ + clearTimeout(_orderSaveTimer); // one request per burst of clicks, not per click + _orderSaveTimer = setTimeout(function(){ + fetch(MOUNT + '/api/account/ui-order', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, // the wrapper adds CSRF, not this + body: JSON.stringify(collectLayout()) + }) + // r.ok first: an expired session answers 302 -> HTML login page and a CSRF failure answers + // an HTML 400, either of which would explode inside r.json() and look like success. + .then(function(r){ if(!r.ok) throw 0; return r.json(); }) + .then(function(d){ if(!d || d.success === false) throw 0; if (then) then(); }) + .catch(function(){ + if (window.toast) toast('Could not save your layout — it will revert on reload.', 'danger'); + }); + }, 400); +} + +// Move one host card up (dir<0) or down (dir>0). MOVES the existing node rather than rebuilding: +// the cells carry ids the pollers address (status-, res-) and the delegated handlers walk +// up with closest(), both of which survive a move and neither of which survives a re-render. +window.moveHostCard = function(dir, btn){ + var card = btn.closest('.server-remote-card'); + var wrap = document.getElementById('server-cards'); + if (!card || !wrap) return; + var cards = hostCards(), at = cards.indexOf(card), to = at + (dir < 0 ? -1 : 1); + if (at < 0 || to < 0 || to >= cards.length) return; // already at the end: no-op + if (dir < 0) wrap.insertBefore(card, cards[to]); + else wrap.insertBefore(cards[to], card); + // Keep keyboard focus travelling with the card, so repeated arrow presses keep working. Guarded: + // if a later change disables the end buttons, this must not throw mid-reorder. + var keep = card.querySelector('.host-move button:not([disabled])'); + if (keep) keep.focus(); + saveHostOrder(); +}; + +// Move one server row within its own host card. Rows hidden by the search filter are SKIPPED, so a +// move always lands where the user can see it — swapping with an invisible neighbour looks like the +// button did nothing. +window.moveServerRow = function(dir, btn){ + var row = btn.closest('tr[data-server-id]'); + if (!row) return; + var body = row.parentNode; + var rows = Array.prototype.slice.call(body.querySelectorAll('tr[data-server-id]')) + .filter(function(tr){ return tr === row || tr.style.display !== 'none'; }); + var at = rows.indexOf(row), to = at + (dir < 0 ? -1 : 1); + if (at < 0 || to < 0 || to >= rows.length) return; // already first/last visible: no-op + if (dir < 0) body.insertBefore(row, rows[to]); + else body.insertBefore(rows[to], row); + var keep = row.querySelector('.srv-move button:not([disabled])'); + if (keep) keep.focus(); + saveHostOrder(); +}; + +function copyAddr(addr) { + window.copyText(addr, 'Copied'); +} + +// Inline server actions (no page reload). +function doAction(id, action, btn) { + var original = btn.innerHTML; + btn.disabled = true; btn.innerHTML = ''; + fetch(MOUNT + '/api/server/' + id + '/action', { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ action: action }) + }) + .then(r => r.json()) + .then(d => { + toast(d.message || (d.success ? ('Server ' + action + ' issued') : 'Action failed'), d.success ? 'success' : 'danger'); + setTimeout(refreshStatus, 1500); + }) + .catch(() => toast('Action failed', 'danger')) + .finally(() => { btn.disabled = false; btn.innerHTML = original; }); +} + +// ── Bulk actions ────────────────────────────────────────────── +// Run one action across every checked server in a single request. The server +// dispatches each in the background, so this returns immediately and the live +// status poll below reflects the outcome per row. +function selectedChecks() { + return Array.prototype.slice.call(document.querySelectorAll('.srv-check:checked')); +} +function selectedIds() { + return selectedChecks().map(function (c) { return c.value; }); +} +function updateBulkBar() { + var checks = selectedChecks(), n = checks.length; + document.querySelectorAll('.bulk-count').forEach(function (el) { el.textContent = n; }); + document.querySelectorAll('.bulk-bar').forEach(function (bar) { bar.style.display = n ? '' : 'none'; }); + // Hide the Update button when NONE of the selected servers support updating (e.g. a lone cod + // server, which has no LinuxGSM update command) — no point offering an action that can't run. + var anyUpdatable = checks.some(function (c) { return c.getAttribute('data-supports-update') !== 'false'; }); + document.querySelectorAll('.bulk-update-btn').forEach(function (b) { b.style.display = anyUpdatable ? '' : 'none'; }); +} +function toggleAll(cb) { + var card = cb.closest('.server-remote-card'); + if (!card) return; + card.querySelectorAll('.srv-check').forEach(function (c) { + if (c.closest('tr').style.display !== 'none') c.checked = cb.checked; // visible rows only + }); + updateBulkBar(); +} +function clearSelection() { + document.querySelectorAll('.srv-check, .srv-check-all').forEach(function (c) { c.checked = false; }); + updateBulkBar(); +} +function bulkAction(action) { + var checks = selectedChecks(); + var ids = checks.map(function (c) { return Number(c.value); }); + if (!ids.length) return; + var skippedNoUpdate = 0; + if (action === 'update') { + // Mixed selection: only update servers whose game has a LinuxGSM update command; skip the + // rest instead of failing them. The server enforces this too (defence in depth). + var updatable = checks.filter(function (c) { return c.getAttribute('data-supports-update') !== 'false'; }); + skippedNoUpdate = ids.length - updatable.length; + ids = updatable.map(function (c) { return Number(c.value); }); + if (!ids.length) { toast('None of the selected servers support updating.', 'warning'); return; } + } + var run = function () { + var btns = document.querySelectorAll('.bulk-bar button'); + btns.forEach(function (b) { b.disabled = true; }); + fetch(MOUNT + '/api/servers/bulk-action', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: action, server_ids: ids }) + }) + .then(r => r.json()) + .then(d => { + var nq = (d.queued || []).length, skip = (d.skipped || []).length + skippedNoUpdate; + var extra = skip ? ' (' + skip + ' skipped)' : ''; + if (nq) toast(titleCase(action) + ' started on ' + nq + ' server' + (nq === 1 ? '' : 's') + extra, 'success'); + else toast((d.message || 'Nothing to do') + extra, 'warning'); + clearSelection(); + setTimeout(refreshStatus, 1500); + }) + .catch(() => toast('Bulk action failed', 'danger')) + .finally(() => { btns.forEach(function (b) { b.disabled = false; }); }); + }; + if (action === 'stop' || action === 'update') { + confirmDialog({ + title: titleCase(action) + ' ' + ids.length + ' server' + (ids.length === 1 ? '' : 's'), + icon: 'exclamation-triangle', confirmClass: 'btn-warning', confirmLabel: titleCase(action), + bodyText: 'Run "' + action + '" on ' + ids.length + ' server' + (ids.length === 1 ? '' : 's') + '?', + onConfirm: run + }); + } else { + run(); + } +} +// Keep the bar's count in sync as individual boxes are toggled. +document.addEventListener('change', function (e) { + if (e.target && e.target.classList && e.target.classList.contains('srv-check')) updateBulkBar(); +}); + +// makeSortable lives in base.html, whose footer script runs after this block is parsed — so wait. +if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bindDragRegions); +else bindDragRegions(); + +// Minimal toast helper. +// Uses the global window.toast (base.html) — one implementation, consistent independent timing. diff --git a/static/js/manage_remotes.js b/static/js/manage_remotes.js new file mode 100644 index 0000000..a970ae1 --- /dev/null +++ b/static/js/manage_remotes.js @@ -0,0 +1,611 @@ +// ── Live stats for each remote card ────────────────────── +document.addEventListener('DOMContentLoaded', function() { + var statEls = document.querySelectorAll('[id^="live-stats-"]'); + statEls.forEach(function(el) { + var id = el.id.replace('live-stats-', ''); + if (id) loadLiveStats(id); + }); + // Re-attach to any in-progress bootstrap (persists across page reloads / closing). + document.querySelectorAll('[id^="bootstrap-card-"]').forEach(function(el) { + var id = el.id.replace('bootstrap-card-', ''); + if (id) watchBootstrap(id); + }); +}); + +// ── Persistent bootstrap progress on the card ──────────── +var _bsTimers = {}; +function watchBootstrap(remoteId) { + if (_bsTimers[remoteId]) return; + function tick() { + fetch(MOUNT + '/api/remote/' + remoteId + '/bootstrap-status') + .then(r => r.json()) + .then(s => { + var card = document.getElementById('bootstrap-card-' + remoteId); + if (!card) return; + if (s.status === 'none') { card.style.display = 'none'; stopWatch(remoteId); return; } + card.style.display = 'block'; + var step = document.getElementById('bs-step-' + remoteId); + var bar = document.getElementById('bs-bar-' + remoteId); + var pct = document.getElementById('bs-pct-' + remoteId); + var log = document.getElementById('bs-log-' + remoteId); + if (pct) pct.textContent = (s.total ? s.step + '/' + s.total + ' · ' : '') + s.percent + '% · ' + s.elapsed + 's'; + if (bar) bar.style.width = s.percent + '%'; + if (log && s.log) { log.textContent = s.log.join('\n'); if (log.style.display !== 'none') log.scrollTop = log.scrollHeight; } + if (s.status === 'rebooting') { + bar.className = 'progress-bar progress-bar-striped progress-bar-animated bg-warning'; + step.innerHTML = ' ' + s.step_name; + } else if (s.status === 'done') { + bar.className = 'progress-bar bg-success'; bar.style.width = '100%'; + step.innerHTML = ' ' + (s.message || 'Prepared & secured!'); + var dz = document.getElementById('bs-dismiss-' + remoteId); if (dz) dz.style.display = 'inline'; + stopWatch(remoteId); + // Update just this remote's live figures instead of reloading the whole page. + if (typeof loadLiveStats === 'function') loadLiveStats(remoteId); + } else if (s.status === 'failed') { + bar.className = 'progress-bar bg-danger'; + step.innerHTML = ' ' + (s.message || 'Bootstrap failed'); + var df = document.getElementById('bs-dismiss-' + remoteId); if (df) df.style.display = 'inline'; + stopWatch(remoteId); + } else { + step.innerHTML = ' ' + s.step_name; + } + }) + .catch(function(){}); + } + tick(); + _bsTimers[remoteId] = setInterval(tick, 3000); +} +function stopWatch(id) { if (_bsTimers[id]) { clearInterval(_bsTimers[id]); delete _bsTimers[id]; } } +function toggleBsLog(id) { var l = document.getElementById('bs-log-' + id); if (l) l.style.display = l.style.display === 'none' ? 'block' : 'none'; } +function dismissBootstrap(id) { + stopWatch(id); + var card = document.getElementById('bootstrap-card-' + id); + if (card) card.style.display = 'none'; + fetch(MOUNT + '/api/remote/' + id + '/bootstrap-dismiss', { method: 'POST' }).catch(function(){}); +} + +function showAddRemote() { + var el = document.getElementById('add-remote-form'); + el.style.display = el.style.display === 'none' ? '' : 'none'; +} + +function loadLiveStats(remoteId) { + var el = document.getElementById('live-stats-' + remoteId); + if (!el) return; + fetch(MOUNT + '/api/remote/' + remoteId + '/live-stats') + .then(r => r.json()) + .then(data => { + if (data.success) { + el.innerHTML = ' CPU: ' + data.cpu_percent + '%' + + ' · RAM: ' + data.memory + + ' · ' + data.disk + + ' · ' + data.uptime; + el.dataset.loaded = '1'; + } else if (el.dataset.loaded !== '1') { + el.innerHTML = 'Stats unavailable'; + } // else: keep the last good stats on a transient failure + }) + .catch(function() { + // A blip shouldn't wipe good numbers — only show "Offline" if we never loaded. + if (el.dataset.loaded !== '1') el.innerHTML = 'Offline'; + }); +} + +// ── Live refresh local server stats ────────────────────── +pollWhenVisible(function() { + var statEls = document.querySelectorAll('[id^="live-stats-"]'); + statEls.forEach(function(el) { + var id = el.id.replace('live-stats-', ''); + if (id) { loadLiveStats(id); } // update in place; no "Refreshing…" flicker + }); + // Also refresh CPU/RAM on local server management page + var cpuEl = document.getElementById('cpu-pct'); + if (cpuEl) refreshLocalStats(); +}, 15000); + +function refreshLocalStats() { + fetch(MOUNT + '/api/server-management') + .then(r => r.json()) + .then(data => { + var u = data.uptime; + if (!u) return; // no fresh stats -> keep the values already on screen + var updateEl = function(id, val) { + var el = document.getElementById(id); + if (el) el.textContent = val; + }; + updateEl('uptime', u.uptime); + updateEl('cpu-pct', u.cpu_percent); + updateEl('mem-pct', u.memory_percent); + updateEl('memory', u.memory); + updateEl('mem-detail', u.memory); + updateEl('disk', u.disk_root); + updateEl('kernel', u.kernel); + updateEl('load-1', u.load_1m); + updateEl('load-5', u.load_5m); + updateEl('load-15', u.load_15m); + // Update progress bars separately + var cpuBar = document.getElementById('cpu-bar'); + if (cpuBar) cpuBar.style.width = parseFloat(u.cpu_percent) + '%'; + var memBar = document.getElementById('mem-bar'); + if (memBar) memBar.style.width = parseFloat(u.memory_percent) + '%'; + var cpuDetail = document.getElementById('cpu-detail'); + if (cpuDetail) { + var txt = ''; + if (u.cpu_per_core) txt += u.cpu_per_core + '%/core · '; + txt += u.cpu_cores + ' cores'; + cpuDetail.innerHTML = txt; + } + }) + .catch(function() {}); +} + +function setBar(pct) { + var pctFloat = parseFloat(pct); + if (isNaN(pctFloat)) return; + var cpuBar = document.getElementById('cpu-bar'); + var memBar = document.getElementById('mem-bar'); + if (cpuBar) cpuBar.style.width = pctFloat + '%'; + if (memBar) memBar.style.width = pctFloat + '%'; +} + +function toggleCreds(select) { + var group = document.getElementById('credential-group'); + var label = document.getElementById('cred-label'); + var input = select.closest('form').querySelector('[name="credential"]'); + if (select.value === 'password') { + group.style.display = ''; + label.textContent = 'Password'; + input.placeholder = 'Enter SSH password'; + input.value = ''; + } else if (select.value === 'tailscale') { + group.style.display = 'none'; + input.value = ''; + } else { + group.style.display = ''; + label.textContent = 'SSH Key Path'; + input.placeholder = '~/.ssh/id_rsa'; + input.value = '~/.ssh/id_rsa'; + } +} +function toggleEditCreds(select, id) { + var group = document.getElementById('edit-cred-group-' + id); + var input = select.closest('form').querySelector('[name="credential"]'); + if (select.value === 'tailscale') { + group.style.display = 'none'; + input.value = ''; + } else { + group.style.display = ''; + input.placeholder = 'Key path or password'; + } +} + +// ── Tailscale Bootstrap ───────────────────────────────── + +function checkTailscale(remoteId, name) { + var safe = escapeHtml(name); // name comes from a data-attribute; escape at every HTML sink + var html = '
Checking Tailscale status on ' + safe + '...
'; + showModal('Tailscale Setup: ' + safe, html); + + fetch(MOUNT + '/api/remote/' + remoteId + '/tailscale-check') + .then(r => r.json()) + .then(data => { + if (data.success) { + renderTailscaleStatus(remoteId, name, data); + } else { + setModalBody('
Error: ' + (data.error || 'Unknown') + '
'); + } + }) + .catch(function() { + setModalBody('
Connection failed. Is the remote reachable?
'); + }); +} + +function renderTailscaleStatus(remoteId, name, status) { + var installed = status.installed; + var running = status.running; + var safe = escapeHtml(name); // name is from a data-attribute — escape at each HTML sink + + var html = ''; + + // Status badge + if (installed && running) { + html += '
Tailscale is installed and running on ' + safe + '.
'; + if (status.tailscale_ip) html += '

IP: ' + status.tailscale_ip + '

'; + if (status.dns_name) html += '

DNS: ' + status.dns_name + '

'; + html += '
'; + } else if (installed) { + html += '
Tailscale is installed but not running.
'; + html += renderAuthKeyForm(remoteId, name); + } else { + html += '
Tailscale is not installed on ' + safe + '.
'; + html += ''; + html += '
'; + } + + setModalBody(html); +} + +function renderAuthKeyForm(remoteId, name) { + return '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + // Primary: browser login link (no key needed) + + '' + + '
' + // Fallback: pre-auth key + + '' + + '' + + '
'; +} + +function tailscaleUp(remoteId) { + var ssh = document.getElementById('ts-ssh').checked; + var routes = document.getElementById('ts-routes').value.trim(); + var el = document.getElementById('ts-up-result'); + el.innerHTML = ' Starting Tailscale… getting your login link (a few seconds)…'; + fetch(MOUNT + '/api/remote/' + remoteId + '/tailscale-up', { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ enable_ssh: ssh, advertise_routes: routes }) + }) + .then(r => r.json()) + .then(d => { + if (!d.success) { el.innerHTML = '' + (d.message || 'Failed') + ''; return; } + if (d.connected) { el.innerHTML = ' Already connected to your tailnet.'; return; } + el.innerHTML = '
' + + '1. Open this link in your browser and approve the machine:
' + + '' + d.url + '' + + ' ' + + '
2. Waiting for you to authorize…
'; + // poll for connection + var t = setInterval(function() { + fetch(MOUNT + '/api/remote/' + remoteId + '/tailscale-check').then(r => r.json()).then(s => { + if (s.running) { + clearInterval(t); + var w = document.getElementById('ts-wait-' + remoteId); + if (w) w.innerHTML = ' Connected! Finalizing (UFW)…'; + // Always allow the tailscale0 interface in UFW, then offer to switch the + // panel's connect address to Tailscale and drop public SSH. + fetch(MOUNT + '/api/remote/' + remoteId + '/tailscale-finalize', {method:'POST'}) + .then(r => r.json()).then(f => { + var ip = ((f.tailscale_ip || s.tailscale_ip || '').split(',')[0] || '').trim(); + if (w) w.innerHTML = ' Connected! IP: ' + ip + '' + + '
UFW now allows the tailscale0 interface.
' + + '
'; + }) + .catch(function(){ if (w) w.innerHTML = ' Connected! IP: ' + (s.tailscale_ip || '') + ''; }); + } + }).catch(function(){}); + }, 4000); + }) + .catch(function() { el.innerHTML = 'Connection failed starting Tailscale.'; }); +} + +function installTailscale(remoteId, name) { + var logEl = document.getElementById('install-log'); + logEl.innerHTML = ' Installing Tailscale on ' + escapeHtml(name) + '...'; + + fetch(MOUNT + '/api/remote/' + remoteId + '/tailscale-install', {method: 'POST'}) + .then(r => r.json()) + .then(data => { + if (data.success) { + logEl.innerHTML = ' ' + escapeHtml(data.message) + ''; + // Show auth form + var extra = renderAuthKeyForm(remoteId, name); + logEl.insertAdjacentHTML('afterend', extra); + } else { + logEl.innerHTML = ' ' + escapeHtml(data.message) + '' + + (data.log ? '
' + escapeHtml(data.log.slice(-2000)) + '
' : ''); + } + }) + .catch(function() { + logEl.innerHTML = 'Error installing Tailscale'; + }); +} + +function bootstrapTailscale(remoteId) { + var key = document.getElementById('ts-auth-key').value.trim(); + var ssh = document.getElementById('ts-ssh').checked; + var routes = document.getElementById('ts-routes').value.trim(); + var logEl = document.getElementById('bootstrap-log'); + + if (!key) { + logEl.innerHTML = 'Auth key is required'; + return; + } + logEl.innerHTML = ' Authenticating to tailnet...'; + + fetch(MOUNT + '/api/remote/' + remoteId + '/tailscale-bootstrap', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({auth_key: key, enable_ssh: ssh, advertise_routes: routes}), + }) + .then(r => r.json()) + .then(data => { + if (data.success) { + logEl.innerHTML = ' ' + escapeHtml(data.message) + '' + + '
'; + } else { + logEl.innerHTML = ' ' + escapeHtml(data.message) + '' + + (data.log ? '
' + escapeHtml(data.log.slice(-2000)) + '
' : ''); + } + }) + .catch(function() { + logEl.innerHTML = 'Error during bootstrap'; + }); +} + +function migrateToTailscale(remoteId) { + confirmDialog({title:'Migrate to Tailscale SSH', icon:'arrow-repeat', confirmClass:'btn-success', confirmLabel:'Migrate', + bodyText:'Switch this remote to use Tailscale SSH? The connection will be updated to use the Tailscale IP/DNS name.', + onConfirm:function(){ _migrateToTailscale(remoteId); }}); +} +function _migrateToTailscale(remoteId) { + setModalBody('
Migrating connection...
'); + + fetch(MOUNT + '/api/remote/' + remoteId + '/tailscale-migrate', {method: 'POST'}) + .then(r => r.json()) + .then(data => { + if (data.success) { + setModalBody('
' + escapeHtml(data.message) + '
' + + '

Old host: ' + data.old_host + '
' + + 'New host: ' + data.new_host + '
' + + (data.tailscale_ip ? 'Tailscale IP: ' + data.tailscale_ip + '
' : '') + + (data.dns_name ? 'DNS: ' + data.dns_name + '' : '') + + '

' + + '
' + + '' + + '' + + '
'); + } else { + setModalBody('
' + escapeHtml(data.message) + '
'); + } + }) + .catch(function() { + setModalBody('
Migration failed
'); + }); +} + +function closePort22(remoteId) { + confirmDialog({title:'Close port 22', icon:'shield-lock', confirmClass:'btn-danger', confirmLabel:'Close port 22', + bodyText:'Remove port 22 from UFW? This disables public SSH access. Only do this if Tailscale SSH is working.', + onConfirm:function(){ _closePort22(remoteId); }}); +} +function _closePort22(remoteId) { + setModalBody('
Removing port 22 rule...
'); + fetch(MOUNT + '/api/remote/' + remoteId + '/close-port-22', {method: 'POST'}) + .then(r => r.json()) + .then(data => { + setModalBody('
' + escapeHtml(data.message) + '
' + + ''); + }) + .catch(function() { + setModalBody('
Failed to close port 22
'); + }); +} + +// ── VPS Bootstrap ──────────────────────────────────────── + +function showBootstrap(remoteId, name) { + var html = '

Configure this VPS with one click: system updates, firewall, security hardening, and essential packages.

' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
Creates a locked system user for LinuxGSM.
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
Runs a full apt upgrade and (if enabled) reboots the server. This can take 5-15 minutes — you can watch progress live below.
' + + '' + + '' + + ''; + + showModal('Prepare & Secure: ' + escapeHtml(name), html); // name from a data-attribute +} + +var _bootstrapPoll = null; + +function runBootstrap(remoteId) { + var tz = document.getElementById('bootstrap-tz').value.trim() || 'UTC'; + var username = document.getElementById('bootstrap-user').value.trim(); + var ufw = document.getElementById('bootstrap-ufw').checked; + var lgsm = document.getElementById('bootstrap-lgsm').checked; + var fb = document.getElementById('bootstrap-fail2ban') ? document.getElementById('bootstrap-fail2ban').checked : true; + var reboot = document.getElementById('bootstrap-reboot') ? document.getElementById('bootstrap-reboot').checked : true; + var btn = document.getElementById('bootstrap-run-btn'); + if (btn) { btn.disabled = true; btn.innerHTML = ' Running…'; } + + document.getElementById('bootstrap-progress-wrap').style.display = 'block'; + var logEl = document.getElementById('bootstrap-log'); + logEl.style.display = 'block'; + logEl.textContent = ''; + + fetch(MOUNT + '/api/remote/' + remoteId + '/bootstrap', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({timezone: tz, enable_ufw: ufw, install_lgsm_deps: lgsm, lgsm_user: username, install_fail2ban: fb, reboot: reboot}), + }) + .then(r => r.json()) + .then(data => { + if (!data.success) { + document.getElementById('bootstrap-step').innerHTML = '' + (data.message || 'Failed to start') + ''; + if (btn) { btn.disabled = false; btn.innerHTML = ' Prepare & Secure Server'; } + return; + } + pollBootstrap(remoteId, btn); + watchBootstrap(remoteId); // also show it on the card so it survives closing the modal + }) + .catch(function() { + document.getElementById('bootstrap-step').innerHTML = 'Connection failed starting bootstrap'; + if (btn) { btn.disabled = false; btn.innerHTML = ' Prepare & Secure Server'; } + }); +} + +function pollBootstrap(remoteId, btn) { + if (_bootstrapPoll) clearInterval(_bootstrapPoll); + function tick() { + fetch(MOUNT + '/api/remote/' + remoteId + '/bootstrap-status') + .then(r => r.json()) + .then(s => { + if (s.status === 'none') return; + var stepEl = document.getElementById('bootstrap-step'); + var barEl = document.getElementById('bootstrap-bar'); + var pctEl = document.getElementById('bootstrap-pct'); + var logEl = document.getElementById('bootstrap-log'); + var elEl = document.getElementById('bootstrap-elapsed'); + if (!stepEl) return; // modal closed + pctEl.textContent = (s.total ? (s.step + '/' + s.total) : '') + ' ' + s.percent + '%'; + barEl.style.width = s.percent + '%'; + elEl.textContent = 'Elapsed: ' + s.elapsed + 's'; + if (s.log && s.log.length) { + logEl.textContent = s.log.join('\n'); + logEl.scrollTop = logEl.scrollHeight; + } + if (s.status === 'rebooting') { + barEl.className = 'progress-bar progress-bar-striped progress-bar-animated bg-warning'; + stepEl.innerHTML = ' ' + s.step_name; + } else if (s.status === 'done') { + clearInterval(_bootstrapPoll); _bootstrapPoll = null; + barEl.className = 'progress-bar bg-success'; barEl.style.width = '100%'; + stepEl.innerHTML = ' ' + (s.message || 'Server prepared & secured!'); + pctEl.textContent = '100%'; + if (btn) { btn.disabled = false; btn.innerHTML = ' Done — Run Again'; } + } else if (s.status === 'failed') { + clearInterval(_bootstrapPoll); _bootstrapPoll = null; + barEl.className = 'progress-bar bg-danger'; + stepEl.innerHTML = ' ' + (s.message || 'Bootstrap failed'); + if (btn) { btn.disabled = false; btn.innerHTML = ' Retry'; } + } else { + stepEl.innerHTML = ' ' + s.step_name; + } + }) + .catch(function(){ /* transient poll error, keep going */ }); + } + tick(); + _bootstrapPoll = setInterval(tick, 2500); +} + +// ── Modal helpers ────────────────────────────────────────── + +function showModal(title, body) { + var existing = document.getElementById('ts-modal'); + if (existing) existing.remove(); + + var modal = document.createElement('div'); + modal.id = 'ts-modal'; + modal.className = 'modal fade'; + modal.setAttribute('tabindex', '-1'); + modal.innerHTML = ''; + document.body.appendChild(modal); + new bootstrap.Modal(modal).show(); +} + +function setModalBody(html) { + var el = document.getElementById('ts-modal-body'); + if (el) el.innerHTML = html; +} + +// Close the action modal and pull the fresh remote list into #remotes-list in place — replaces the +// old "Refresh Page" full reload after a Tailscale migrate / close-port-22. +function refreshRemotesAndClose(){ + var modal = document.getElementById('ts-modal'); + if (modal && window.bootstrap){ var mi = bootstrap.Modal.getInstance(modal); if (mi) mi.hide(); } + if (window.refreshSection) window.refreshSection('#remotes-list', 'afterRemotesRefresh'); +} + +// The per-remote Tailscale / Prepare buttons (and the generated "Install Tailscale" button) carry +// their remote id + name in data-attributes instead of an inline onclick — so a remote NAME can +// never break out of an attribute/handler, and there's no Jinja inside a JS-parsed context. The id +// is coerced to a Number here (it's only ever used to build request URLs / handler calls). +document.addEventListener('click', function(e){ + var t = e.target; + var c = t.closest && t.closest('[data-ts-check]'); + if (c) { checkTailscale(Number(c.getAttribute('data-remote-id')), c.getAttribute('data-remote-name')); return; } + var b = t.closest && t.closest('[data-ts-bootstrap]'); + if (b) { showBootstrap(Number(b.getAttribute('data-remote-id')), b.getAttribute('data-remote-name')); return; } + var i = t.closest && t.closest('[data-install-ts]'); + if (i) { installTailscale(Number(i.getAttribute('data-ts-remote')), i.getAttribute('data-ts-name')); return; } + var rm = t.closest && t.closest('.remove-remote-btn'); + if (rm && !rm.disabled) { removeRemote(rm); return; } +}); + +// AJAX remove remote — styled confirm, then remove the card in place (no full-page reload). +function removeRemote(btn){ + var id = btn.getAttribute('data-remote-id'); + var name = btn.getAttribute('data-remote-name') || ''; + confirmDialog({ + title: 'Delete remote', icon: 'trash', confirmLabel: 'Delete', confirmClass: 'btn-danger', + body: 'Delete ' + escapeHtml(name) + '? This removes it and all of its game servers from the panel.', + requirePassword: true, + requireLabel: 'Enter your account password to confirm:', + onConfirm: function(pw, api){ + fetch(MOUNT + '/remotes/' + id + '/delete', { + method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ password: pw }) + }) + .then(function(r){ return r.json().then(function(d){ return { status: r.status, d: d || {} }; }); }) + .then(function(res){ + if (res.status === 200 && res.d.success){ + api.close(); + var card = document.getElementById('remote-card-' + id); + if (card){ card.style.transition = 'opacity .3s'; card.style.opacity = '0'; + setTimeout(function(){ card.remove(); }, 300); } + if (window.toast) toast(res.d.message || 'Deleted', 'success'); + } else if (res.status === 403){ + api.error(res.d.message || 'Incorrect password.'); + } else { + api.error(res.d.message || 'Delete failed.'); + } + }) + .catch(function(){ api.error('Delete request failed.'); }); + } + }); +} + +// After the remotes list is swapped in place (AJAX add / edit / test), re-arm the per-card +// live-stats pollers and re-attach to any in-progress bootstrap on the fresh DOM nodes. +// (loadLiveStats just fetches; watchBootstrap guards against a duplicate timer — both safe to re-run.) +window.afterRemotesRefresh = function(){ + document.querySelectorAll('[id^="live-stats-"]').forEach(function(el){ + var id = el.id.replace('live-stats-', ''); if (id) loadLiveStats(id); + }); + document.querySelectorAll('[id^="bootstrap-card-"]').forEach(function(el){ + var id = el.id.replace('bootstrap-card-', ''); if (id) watchBootstrap(id); + }); +}; diff --git a/static/js/manage_servers.js b/static/js/manage_servers.js new file mode 100644 index 0000000..c61b16c --- /dev/null +++ b/static/js/manage_servers.js @@ -0,0 +1,308 @@ +// Click a connect address to copy the full ip:port. +document.addEventListener('click', function(ev){ + var el = ev.target.closest('.copy-addr'); if(!el) return; + if(window.copyText) window.copyText(el.getAttribute('data-copy'), 'Copied ' + el.getAttribute('data-copy')); +}); + +// Auto-populate a sensible default port when the game changes. This is only a +// hint — after install the panel detects LinuxGSM's real port(s) and opens all of +// them, so it self-corrects even for games not listed here. +var PORTS = { + "gmod":27015,"cs":27015,"css":27015,"cs2":27015,"csgo":27015,"tf2":27015, + "hl2dm":27015,"hldm":27015,"hldms":27015,"dods":27015,"ins":27015,"insurgency":27015, + "nmrih":27015,"l4d":27015,"l4d2":27015,"zps":27015,"fof":27015,"gesource":27015, + "cscz":27015,"tfc":27015,"ns":27015,"ricochet":27015,"dmc":27015,"sfc":27015, + "bb2":27015,"unturned":27015,"bt":27015, + "cod":28960,"coduo":28960,"cod2":28960,"cod4":28960,"codwaw":28960, + "mc":25565,"pmc":25565,"spigot":25565,"paper":25565,"bukkit":25565,"mcbe":19132,"mcb":19132, + "rust":28015,"sdtd":26900,"7d2d":26900,"valheim":2456,"vh":2456,"ark":7777, + "pz":16261,"projectzomboid":16261,"terraria":7777,"tshock":7777,"factorio":34197, + "avorion":27000,"eco":3000,"vs":42420, + "arma3":2302,"squad":7787,"mordhau":7777,"kf":7707,"kf2":7777, + "q2":27910,"q3":27960,"ql":27960,"et":27960,"etl":27960,"rtcw":27960, + "xonotic":26000,"ut99":7777,"ut2k4":7777, + "mumble":64738,"ts3":9987,"samp":7777,"mta":22003,"openttd":3979, +}; +function updatePort() { + var select = document.getElementById('game-type-select'); + var port = document.getElementById('port-input'); + if (PORTS[select.value]) { + port.value = PORTS[select.value]; + } + // GMod-only: offer to mount CS:S content (it's the one game that needs mounted content). + var contentOpt = document.getElementById('gmod-content-opt'); + if (contentOpt) contentOpt.style.display = (select.value === 'gmod') ? '' : 'none'; + suggestFreePort(); +} +// Once a target host + game are chosen, ask the panel for a free port near the default and bump the +// field if the default is already taken (the install auto-resolves too — this just shows it up front). +function suggestFreePort() { + var game = (document.getElementById('game-type-select') || {}).value; + var remote = (document.getElementById('remote-select') || {}).value; + var portEl = document.getElementById('port-input'); + var hint = document.getElementById('port-hint'); + if (hint) hint.textContent = ''; + if (!game || !remote || !portEl) return; + var desired = portEl.value || PORTS[game] || 27015; + fetch(MOUNT + '/api/free-port?remote_id=' + encodeURIComponent(remote) + + '&game=' + encodeURIComponent(game) + '&desired=' + encodeURIComponent(desired)) + .then(function(r){ return r.ok ? r.json() : null; }) + .then(function(d){ + if (!d || !d.port || !hint) return; + if (d.changed) { + portEl.value = d.port; + hint.textContent = 'Port ' + desired + ' is in use — using free port ' + d.port + '.'; // textContent: never treat the port value as HTML + } + }) + .catch(function(){}); +} + +// Live per-server install progress (step-by-step, mirrors the VPS bootstrap). +document.addEventListener('DOMContentLoaded', function() { + document.querySelectorAll('.install-progress-row[data-installing="1"]').forEach(function(row) { + watchInstall(row.id.substring('install-row-'.length)); + }); +}); + +var _instTimers = {}; +function watchInstall(id) { + if (_instTimers[id]) return; + function tick() { + fetch(MOUNT + '/api/server/' + id + '/install-status') + .then(r => r.json()) + .then(s => { + var row = document.getElementById('install-row-' + id); + if (!row) return; + if (s.status === 'none') { row.style.display = 'none'; stopInstall(id); return; } + row.style.display = ''; + var step = document.getElementById('inst-step-' + id); + var bar = document.getElementById('inst-bar-' + id); + var pct = document.getElementById('inst-pct-' + id); + var badge = document.querySelector('[data-server-id="' + id + '"]'); + if (pct) pct.textContent = (s.total ? s.step + '/' + s.total + ' · ' : '') + s.percent + '% · ' + s.elapsed + 's'; + if (bar) bar.style.width = s.percent + '%'; + if (s.installed) enableServerLinks(id); // files have landed — Console + Files are usable now + if (s.status === 'done') { + // Installed, but with a caveat (warn) — e.g. the files installed yet the server didn't + // start. Show that as a yellow warning with the reason, not a clean green success. + var warn = !!s.warn; + bar.className = 'progress-bar ' + (warn ? 'bg-warning' : 'bg-success'); bar.style.width = '100%'; + step.innerHTML = ' ' + (s.message || 'Installed'); + if (badge) { badge.className = 'badge ' + (warn ? 'bg-warning text-dark' : 'bg-success'); badge.textContent = 'Installed'; } + var dz = document.getElementById('inst-dismiss-' + id); if (dz) dz.style.display = 'inline'; + stopInstall(id); + } else if (s.status === 'failed') { + bar.className = 'progress-bar bg-danger'; + step.innerHTML = ' ' + (s.message || 'Install failed'); + if (badge) { badge.className = 'badge bg-danger'; badge.textContent = 'Failed'; } + var df = document.getElementById('inst-dismiss-' + id); if (df) df.style.display = 'inline'; + stopInstall(id); + } else if (s.status === 'interrupted') { + if (bar) bar.className = 'progress-bar bg-warning'; + step.innerHTML = ' ' + (s.message || 'Install status unknown'); + if (badge) { badge.className = 'badge bg-warning text-dark'; badge.textContent = 'Unknown'; } + var di = document.getElementById('inst-dismiss-' + id); if (di) di.style.display = 'inline'; + stopInstall(id); + } else { + // Running (steps 1-8). Once the game files land (s.installed), the server IS installed + // but still finishing config/start — show "Finishing setup…" rather than a premature + // "Installed", with the bar tracking the remaining steps. + step.innerHTML = ' ' + s.step_name; + if (badge) { + badge.className = 'badge bg-info text-dark'; + badge.textContent = s.installed ? 'Finishing setup…' : 'Installing…'; + } + } + }) + .catch(function() {}); + } + tick(); + _instTimers[id] = setInterval(tick, 2500); +} +function stopInstall(id) { + if (_instTimers[id]) { clearInterval(_instTimers[id]); delete _instTimers[id]; } + // Install has ended (done/failed/gone) — re-enable its Uninstall button (disabled during install). + var ub = document.getElementById('uninstall-btn-' + id); if (ub) ub.disabled = false; +} +// Turn the Console + Files links from disabled back into working links once the server is installed. +function enableServerLinks(id) { + [['console-btn-', 'Console, commands, restart'], ['files-btn-', 'Edit config & browse/upload files']] + .forEach(function(x) { + var a = document.getElementById(x[0] + id); + if (a && a.classList.contains('disabled')) { + if (a.dataset.href) a.setAttribute('href', a.dataset.href); + a.classList.remove('disabled'); a.removeAttribute('tabindex'); a.removeAttribute('aria-disabled'); + a.title = x[1]; + } + }); +} +function dismissInstall(id) { + stopInstall(id); + var row = document.getElementById('install-row-' + id); if (row) row.style.display = 'none'; + fetch(MOUNT + '/api/server/' + id + '/install-dismiss', { method: 'POST' }).catch(function() {}); +} + +// ── AJAX uninstall: styled confirm, then remove the row in place — no full-page reload ────── +document.addEventListener('click', function(e){ + var b = e.target.closest && e.target.closest('.uninstall-btn'); + if (!b || b.disabled) return; + var name = b.getAttribute('data-server-name') || ''; + var short = b.getAttribute('data-server-short') || ''; + confirmDialog({ + title: 'Uninstall server', icon: 'trash', confirmLabel: 'Uninstall', confirmClass: 'btn-danger', + body: 'Uninstall ' + escapeHtml(name) + '? This permanently deletes the server, ' + + 'all its files, AND every backup it has — this cannot be undone.', + requireText: short, + requireLabel: 'Type the server’s username (' + short + ') to confirm:', + onConfirm: function(){ doUninstall(b.getAttribute('data-server-id'), name, b); } + }); +}); +// Drop a server's rows (main row + its install-progress row) in place, with a short fade — no +// full-page reload. Safe to call twice (getElementById returns null once a row is gone). +function removeServerRow(id){ + var prog = document.getElementById('install-row-' + id); if (prog) prog.remove(); + var row = document.getElementById('server-row-' + id); + if (row){ row.style.transition = 'opacity .3s'; row.style.opacity = '0'; + setTimeout(function(){ if (row.parentNode) row.remove(); }, 300); } +} +function doUninstall(id, name, btn){ + var orig = btn.innerHTML; + btn.disabled = true; btn.innerHTML = ''; + // Disable this row's other actions (Console, Files) while it's being torn down — the server is + // going away, so those shouldn't be clickable. Bootstrap's .disabled kills pointer events on links. + var row = document.getElementById('server-row-' + id); + var others = row ? Array.prototype.slice.call(row.querySelectorAll('a.btn, button.btn')).filter(function(el){ return el !== btn; }) : []; + function setRowDisabled(off){ + others.forEach(function(el){ + if (off){ el.classList.add('disabled'); el.setAttribute('aria-disabled','true'); el.setAttribute('tabindex','-1'); } + else { el.classList.remove('disabled'); el.removeAttribute('aria-disabled'); el.removeAttribute('tabindex'); } + }); + } + setRowDisabled(true); + if (window.toast) toast('Uninstalling ' + name + '…', 'info'); + fetch(MOUNT + '/servers/' + id + '/delete', { method: 'POST' }) + .then(function(r){ return r.json(); }) + .then(function(d){ + if (d && d.success){ + removeServerRow(id); + _svrBaseline = serverIdsOnPage(); // adopt the reduced set so live-sync doesn't force a reload + if (window.toast) toast(d.message || 'Uninstalled', 'success'); + } else { + btn.disabled = false; btn.innerHTML = orig; setRowDisabled(false); + if (window.toast) toast((d && d.message) || 'Uninstall failed', 'danger'); + } + }) + .catch(function(){ btn.disabled = false; btn.innerHTML = orig; setRowDisabled(false); if (window.toast) toast('Uninstall request failed', 'danger'); }); +} + +// Power controls (start/stop/restart) for a row — POST the action, toast the result, then refresh +// the live cells. Mirrors the dashboard's doAction, but refreshes via this page's reconcile poll. +function msrvAction(id, action, btn){ + var orig = btn.innerHTML; + btn.disabled = true; btn.innerHTML = ''; + fetch(MOUNT + '/api/server/' + id + '/action', {method:'POST', headers:{'Content-Type':'application/json'}, + body: JSON.stringify({action: action})}) + .then(function(r){ return r.json(); }) + .then(function(d){ + if(window.toast) toast(d.message || (d.success ? ('Server ' + action + ' issued') : 'Action failed'), d.success ? 'success' : 'danger'); + setTimeout(reconcileServerList, 1500); + }) + .catch(function(){ if(window.toast) toast('Action failed', 'danger'); }) + .finally(function(){ btn.disabled = false; btn.innerHTML = orig; }); +} + +// ── Live sync: reflect servers other users add/remove ────────────────────── +// Removed servers are dropped in place (no reload). Added servers need server-rendered markup, so +// those reload — held off while the user is mid-way through the install form (so we don't wipe +// their selection). +function serverIdsOnPage() { + return Array.prototype.map.call(document.querySelectorAll('[id^="install-row-"]'), + function(el){ return el.id.slice('install-row-'.length); }).sort().join(','); +} +var _svrBaseline = serverIdsOnPage(); +function editingInstallForm() { + var f = document.getElementById('install-form'); + return f && f.contains(document.activeElement) + && /^(INPUT|SELECT|TEXTAREA)$/.test((document.activeElement.tagName || '')); +} +function reconcileServerList() { + fetch(MOUNT + '/api/servers') + .then(function(r){ return r.ok ? r.json() : null; }) + .then(function(data){ + if (!data) return; + // Refresh the live player-count cell for every row on every poll (independent of whether the + // set of servers changed). + data.forEach(function(s){ + var pc = document.getElementById('msrv-players-' + s.id); + if (pc) { + pc.innerHTML = (typeof s.players === 'number') + ? ' ' + s.players + (typeof s.max_players === 'number' && s.max_players > 0 ? ' / ' + s.max_players : '') + : ''; + } + }); + var liveIds = data.map(function(s){ return String(s.id); }); + if (liveIds.slice().sort().join(',') === _svrBaseline) return; + var baseIds = _svrBaseline ? _svrBaseline.split(',').filter(Boolean) : []; + // Servers that vanished (uninstalled here or elsewhere) — drop their rows in place, never a + // full-page reload. This is also what stops our OWN uninstall's servers_changed broadcast + // from reloading the page out from under us. + baseIds.filter(function(id){ return liveIds.indexOf(id) === -1; }) + .forEach(function(id){ removeServerRow(id); }); + // Servers that appeared need server-rendered markup we don't have client-side, so those still + // require a reload — but hold off while the user is filling in the install form. + var added = liveIds.filter(function(id){ return baseIds.indexOf(id) === -1; }); + if (added.length) { + if (editingInstallForm()) return; // don't yank the form out from under the user + // Pull the freshly server-rendered rows into #servers-list in place (no full reload); + // afterServerRefresh re-arms install pollers and re-adopts the baseline. + window.refreshSection('#servers-list', 'afterServerRefresh'); + return; + } + _svrBaseline = serverIdsOnPage(); // removals only → adopt the reduced set, no reload + }) + .catch(function(){}); +} +pollWhenVisible(reconcileServerList, 8000); +if (window.onServersChanged) onServersChanged(reconcileServerList); + +// Live per-server resources (CPU/RAM/uptime) in the Resources column — a slower, heavier poll (an +// SSH sample per server) than the status reconcile above. +function _fmtUptimeShort(s){ + s = Math.max(0, s|0); + var d = Math.floor(s/86400), h = Math.floor((s%86400)/3600), m = Math.floor((s%3600)/60); + return d ? (d+'d '+h+'h') : (h ? (h+'h '+m+'m') : (m+'m')); +} +function refreshMsrvMetrics(){ + fetch(MOUNT + '/api/dashboard/metrics').then(function(r){ return r.ok ? r.json() : null; }) + .then(function(d){ + if(!d) return; + var servers = d.servers || {}; + Object.keys(servers).forEach(function(sid){ + var s = servers[sid], cell = document.getElementById('msrv-res-' + sid); + if(cell) cell.innerHTML = s.up + ? (' ' + s.cpu + '% · ' + s.ram_mb + ' MB' + + (s.uptime ? ' · ' + _fmtUptimeShort(s.uptime) + '' : '')) + : ''; + var mapEl = document.getElementById('msrv-map-' + sid); + if(mapEl){ + if(s.up && s.map){ mapEl.innerHTML = ' ' + s.map; mapEl.classList.remove('d-none'); } + else { mapEl.classList.add('d-none'); } + } + }); + }).catch(function(){}); +} +refreshMsrvMetrics(); +pollWhenVisible(refreshMsrvMetrics, 10000); + +// After the servers table is swapped in place (AJAX install), re-arm the install-progress pollers +// for any new "installing" rows and adopt the new server set, so the live-sync poller above doesn't +// see a difference and force a full-page reload. +window.afterServerRefresh = function(){ + document.querySelectorAll('.install-progress-row[data-installing="1"]').forEach(function(row){ + watchInstall(row.id.substring('install-row-'.length)); + }); + _svrBaseline = serverIdsOnPage(); + // The refresh rebuilt #servers-list from scratch (flat table) — re-apply the per-host boxes if the + // "Group by host" switch is on, so they survive another user adding a server. + if (window.regroupAfterRefresh) window.regroupAfterRefresh(); +}; diff --git a/static/js/remote_firewall.js b/static/js/remote_firewall.js new file mode 100644 index 0000000..14b408c --- /dev/null +++ b/static/js/remote_firewall.js @@ -0,0 +1,191 @@ +function refreshFirewall() { + fetch(MOUNT + '/api/remote/' + remoteId + '/firewall') + .then(r => r.json()) + .then(data => { + var badge = document.getElementById('ufw-badge'); + badge.textContent = data.enabled ? 'Active' : 'Inactive'; + badge.className = 'badge ' + (data.enabled ? 'bg-success' : 'bg-secondary'); + + var listEl = document.getElementById('rules-list'); + var groups = data.groups || []; + var openGroups = groups.filter(function(g) { return !g.is_block; }); + var blockGroups = groups.filter(function(g) { return g.is_block; }); + if (openGroups.length) { + var html = '' + + ''; + openGroups.forEach(function(g) { + var port = g.is_iface + ? '' + esc(g.port_num) + '' + : '' + esc(g.port_num) + ''; + var scope = (g.action !== 'ALLOW' ? '' + esc(g.action) + '' : '') + esc(g.scope); + html += '' + + '' + + '' + + '' + + '' + + ''; + }); + html += '
PortProtocolForScopeIP
' + port + '' + protoBadge(g.proto_label) + '' + esc(g.comment || '—') + '' + scope + '' + esc(g.family_label) + '' + (g.protected + ? '' + : '') + + '
'; + listEl.innerHTML = html; + } else { + listEl.innerHTML = '
No open ports yet.
'; + } + document.getElementById('rules-count').textContent = openGroups.length + (openGroups.length === 1 ? ' rule' : ' rules'); + + // Blocked IPs (separate card) + var blocksEl = document.getElementById('blocks-list'); + if (blocksEl) { + if (blockGroups.length) { + var bh = '' + + ''; + blockGroups.forEach(function(g) { + bh += '' + + '' + + '' + + ''; + }); + bh += '
IP addressSourceFamily
' + esc(g.block_ip) + '' + blockBadge(g.comment) + '' + esc(g.family_label) + '
'; + blocksEl.innerHTML = bh; + } else { + blocksEl.innerHTML = '
No IPs are blocked.
'; + } + var bc = document.getElementById('blocks-count'); + if (bc) bc.textContent = blockGroups.length + ' blocked'; + } + }); +} + +function blockBadge(c) { + if (c === 'panel-autoblock') return 'Auto'; + if (c === 'panel-block') return 'Manual'; + return c ? '' + esc(c) + '' : ''; +} + +function blockIp() { + var ip = document.getElementById('block-ip').value.trim(); + if (!ip) return; + var resultEl = document.getElementById('block-result'); + resultEl.innerHTML = ' Blocking…'; + fetch(MOUNT + '/api/remote/' + remoteId + '/security/block', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ip: ip}), + }) + .then(r => r.json()) + .then(data => { + if (data.success) { + resultEl.innerHTML = '✅ ' + esc(data.message) + ''; + document.getElementById('block-ip').value = ''; + refreshFirewall(); + } else { + resultEl.innerHTML = '❌ ' + esc(data.message) + ''; + } + }) + .catch(function() { resultEl.innerHTML = '❌ Request failed'; }); +} + +function unblockIp(ip, btn) { + confirmDialog({title: 'Unblock IP', icon: 'shield-check', confirmClass: 'btn-warning', confirmLabel: 'Unblock', + bodyText: 'Remove the firewall block on ' + ip + '?', + onConfirm: function() { + if (btn) { btn.disabled = true; btn.innerHTML = ''; } + fetch(MOUNT + '/api/remote/' + remoteId + '/security/block', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ip: ip, unblock: true}), + }) + .then(r => r.json()) + .then(function(d) { if (!d.success && window.toast) toast(d.message || 'Failed to unblock', 'danger'); refreshFirewall(); }) + .catch(function() { refreshFirewall(); }); + }}); +} + +function esc(s){ return window.escapeHtml(s); } + +function protoBadge(p) { + if (p === 'TCP') return 'TCP'; + if (p === 'UDP') return 'UDP'; + if (p === 'BOTH') return 'Both'; + return ''; +} + +function openPort() { + var port = document.getElementById('new-port').value.trim(); + var proto = document.getElementById('new-proto').value; + var comment = document.getElementById('new-comment').value.trim(); + if (!port) return; + var resultEl = document.getElementById('port-result'); + resultEl.innerHTML = ' Opening...'; + + fetch(MOUNT + '/api/remote/' + remoteId + '/firewall/open', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({port: parseInt(port), protocol: proto, comment: comment}), + }) + .then(r => r.json()) + .then(data => { + if (data.success) { + resultEl.innerHTML = '✅ ' + esc(data.message) + ''; + document.getElementById('new-port').value = ''; + document.getElementById('new-comment').value = ''; + refreshFirewall(); + } else { + resultEl.innerHTML = '❌ ' + esc(data.message) + ''; + } + }); +} + +// Delete a whole rule group (its IPv4 + IPv6 entries). UFW renumbers rules above a +// deleted one, so delete highest-number-first to keep the remaining indices valid. +function deleteGroup(nums, btn, warn, reason) { + if (!nums || !nums.length) return; + var msg = warn ? ((reason || 'This may affect your access.') + '\n\nRemove this rule anyway?') + : 'Remove this firewall rule?'; + confirmDialog({title:'Remove firewall rule', icon:'shield-exclamation', + confirmClass: warn ? 'btn-danger' : 'btn-warning', confirmLabel:'Remove', bodyText: msg, + onConfirm: function(){ + if (btn) { btn.disabled = true; btn.innerHTML = ''; } + var ordered = nums.slice().sort(function(a, b) { return b - a; }); + (function next(i) { + if (i >= ordered.length) { refreshFirewall(); return; } + fetch(MOUNT + '/api/remote/' + remoteId + '/firewall/delete-rule', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({num: ordered[i]}), + }) + .then(r => r.json()) + .then(function(d) { if (!d.success && window.toast) toast(d.message || 'Failed to delete rule', 'danger'); next(i + 1); }) + .catch(function() { next(i + 1); }); + })(0); + }}); +} + +function syncPorts(serverId, btn) { + var orig = btn.innerHTML; + btn.disabled = true; btn.innerHTML = ' Detecting…'; + fetch(MOUNT + '/api/server/' + serverId + '/sync-ports', { method: 'POST' }) + .then(r => r.json()) + .then(data => { + if (data.success) { if(window.toast) toast(data.message, 'success'); refreshFirewall(); } + else if(window.toast) { toast(data.message || 'Failed', 'danger'); } + }) + .catch(function(){ if(window.toast) toast('Request failed', 'danger'); }) + .finally(function(){ btn.disabled = false; btn.innerHTML = orig; }); +} + +// Enter key opens port +document.getElementById('new-port').addEventListener('keydown', function(e) { + if (e.key === 'Enter') openPort(); +}); + +// Enter key blocks the typed IP +var blockIpInput = document.getElementById('block-ip'); +if (blockIpInput) { + blockIpInput.addEventListener('keydown', function(e) { + if (e.key === 'Enter') blockIp(); + }); +} diff --git a/static/js/remote_manage.js b/static/js/remote_manage.js new file mode 100644 index 0000000..c9edbb9 --- /dev/null +++ b/static/js/remote_manage.js @@ -0,0 +1,1309 @@ +// Reboot-required banner for THIS remote (the panel host is shown globally by base.html, so skip it +// here to avoid a duplicate). +if (!IS_LOCAL && window.rebootNagCheck) { window.rebootNagCheck(REMOTE_ID, REMOTE_NAME); } + +// ── Section tabs: show only the cards for the selected group (Overview / Host Controls / +// Maintenance). Cards keep their place/markup/JS; we just toggle visibility. The sidebar's +// ── Security tab: fail2ban bans (panel + ssh), recent events, raw logs ────────── +// Panel host hits /api/panel/security/*; a remote hits /api/remote//security/* (over SSH). +function secBase(){ return IS_LOCAL ? MOUNT+'/api/panel/security' : MOUNT+'/api/remote/'+REMOTE_ID+'/security'; } +function loadSecurity(){ loadSecurityBans(); loadSecurityTopIps(); loadSecurityEvents(); } +function loadSecurityTopIps(){ + var el=document.getElementById('sec-top'); if(!el) return; + fetch(secBase()+'/top-ips').then(function(r){return r.json();}).then(function(d){ + var tog=document.getElementById('sec-autoblock'); if(tog) tog.checked=!!(d&&d.autoblock); + var th=document.getElementById('sec-threshold'); if(th && d && d.threshold) th.value=d.threshold; + renderWhitelist((d&&d.whitelist)||[]); + var ips=(d&&d.ips)||[]; + if(!ips.length){ el.innerHTML='
No fail2ban activity logged yet.
'; return; } + var rows=ips.map(function(o,i){ + var badge = o.banned_now ? 'banned now' + : (o.bans>0 ? ''+o.bans+' ban'+(o.bans===1?'':'s')+'' : ''); + var block = o.blocked + ? ' blocked' + +'' + : ''; + // Which fail2ban jail(s) caught this IP (e.g. sshd, recidive, the panel-login jail). + var jails=(o.jails||[]); + var jailCell = jails.length + ? jails.map(function(j){ return ''+escapeHtml(j)+''; }).join('') + : ''; + return ''+(i+1)+'' + +''+escapeHtml(o.ip)+'' + +''+(o.attempts||0)+'' + +''+(o.bans||0)+'' + +''+jailCell+'' + +''+badge+'' + +''+block+''; + }).join(''); + el.innerHTML='
' + +'' + +'' + +''+rows+'
#IPAttemptsBansJailStatusFirewall
'; + }).catch(function(){ el.innerHTML='
Could not load top offenders.
'; }); +} +function blockOffender(ip, btn){ + confirmDialog({title:'Block IP', icon:'shield-lock', confirmClass:'btn-danger', confirmLabel:'Block (all ports)', + bodyText:'Firewall-block '+ip+' on ALL ports (UFW)? It won\'t be able to reach SSH, the panel, or any game server on this host until you unblock it.', + onConfirm:function(){ + if(btn) btn.disabled=true; + fetch(secBase()+'/block',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ip:ip})}) + .then(function(r){return r.json();}).then(function(d){ if(window.toast) toast(d.message||(d.success?'Blocked':'Failed'), d.success?'success':'danger'); loadSecurityTopIps(); }) + .catch(function(){ if(window.toast) toast('Block failed','danger'); if(btn) btn.disabled=false; }); + }}); +} +function unblockOffender(ip, btn){ + if(btn) btn.disabled=true; + fetch(secBase()+'/block',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ip:ip,unblock:true})}) + .then(function(r){return r.json();}).then(function(d){ if(window.toast) toast(d.message||(d.success?'Unblocked':'Failed'), d.success?'success':'info'); loadSecurityTopIps(); }) + .catch(function(){ if(window.toast) toast('Unblock failed','danger'); if(btn) btn.disabled=false; }); +} +function toggleAutoblock(cb){ + var on=!!(cb&&cb.checked); + fetch(secBase()+'/autoblock',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:on})}) + .then(function(r){return r.json();}).then(function(d){ + if(window.toast) toast(on?'Auto-block on — applying now…':'Auto-block off', on?'success':'info'); + setTimeout(loadSecurityTopIps, on?2500:300); // give the immediate reconcile a moment + }).catch(function(){ if(window.toast) toast('Couldn\'t change auto-block','danger'); if(cb) cb.checked=!on; }); +} +function saveThreshold(btn){ + var inp=document.getElementById('sec-threshold'); var v=parseInt(inp&&inp.value,10); + if(!v||v<1){ if(window.toast) toast('Enter a number of attempts (1 or more)','info'); return; } + var on=!!(document.getElementById('sec-autoblock')||{}).checked; // preserve the on/off state + if(btn) btn.disabled=true; + fetch(secBase()+'/autoblock',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:on,threshold:v})}) + .then(function(r){return r.json();}).then(function(d){ + if(btn) btn.disabled=false; + if(window.toast) toast('Threshold saved — auto-blocking IPs with '+(d.threshold||v)+'+ attempts / 7 days.', 'success'); + setTimeout(loadSecurityTopIps, on?2500:300); + }).catch(function(){ if(btn) btn.disabled=false; if(window.toast) toast('Couldn\'t save the threshold','danger'); }); +} +function renderWhitelist(list){ + var el=document.getElementById('sec-wl-list'); if(!el) return; + if(!list.length){ el.innerHTML='Nothing whitelisted. Your Tailscale IPs are always exempt.'; return; } + el.innerHTML=list.map(function(ip){ + return '' + +''+escapeHtml(ip)+'' + +''; + }).join(''); +} +function addWhitelist(btn){ + var inp=document.getElementById('sec-wl-input'); var ip=((inp&&inp.value)||'').trim(); + if(!ip){ if(window.toast) toast('Enter an IP or CIDR first','info'); return; } + if(btn) btn.disabled=true; + fetch(secBase()+'/whitelist',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ip:ip})}) + .then(function(r){return r.json();}).then(function(d){ + if(btn) btn.disabled=false; + if(d.success){ if(inp) inp.value=''; renderWhitelist(d.whitelist||[]); + if(window.toast) toast(d.added+' whitelisted — it won\'t be banned or blocked.', 'success'); loadSecurityTopIps(); } + else if(window.toast){ toast(d.message||'Could not add it','danger'); } + }).catch(function(){ if(btn) btn.disabled=false; if(window.toast) toast('Could not add it','danger'); }); +} +function removeWhitelist(ip){ + fetch(secBase()+'/whitelist',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ip:ip,remove:true})}) + .then(function(r){return r.json();}).then(function(d){ + renderWhitelist(d.whitelist||[]); + if(window.toast) toast((d.removed||ip)+' removed from the whitelist','info'); + }).catch(function(){ if(window.toast) toast('Could not remove it','danger'); }); +} +function loadSecurityBans(){ + var el=document.getElementById('sec-bans'); if(!el) return; + fetch(secBase()+'/bans').then(function(r){return r.json();}).then(function(d){ + if(!d.installed){ el.innerHTML='
fail2ban isn\'t installed on this host.
'; return; } + if(!d.jails||!d.jails.length){ el.innerHTML='
No fail2ban jails found.
'; return; } + el.innerHTML=d.jails.map(function(j){ + var head='
'+escapeHtml(j.jail)+' ' + +''+j.currently_banned+' banned ' + +''+j.total_banned+' total · '+j.total_failed+' failed ' + +'
'; + var body=(j.banned_ips&&j.banned_ips.length) + ? '
'+j.banned_ips.map(function(ip){ + return ''+escapeHtml(ip) + +' '; }).join('')+'
' + : '
No IPs banned right now.
'; + return '
'+head+body+'
'; + }).join(''); + }).catch(function(){ el.innerHTML='
Could not load bans.
'; }); +} +function unbanIp(jail, ip, btn){ + if(btn) btn.disabled=true; + fetch(secBase()+'/unban',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({jail:jail,ip:ip})}) + .then(function(r){return r.json();}).then(function(d){ + if(window.toast) toast(d.message||(d.success?'Unbanned':'Failed'), d.success?'success':'danger'); + loadSecurityBans(); + }).catch(function(){ if(window.toast) toast('Unban request failed','danger'); if(btn) btn.disabled=false; }); +} +function loadSecurityEvents(){ + var el=document.getElementById('sec-events'); if(!el) return; + fetch(MOUNT+'/api/panel/security/events').then(function(r){return r.json();}).then(function(d){ + var ev=d.events||[]; + if(!ev.length){ el.innerHTML='
No security events yet.
'; return; } + var rows=ev.map(function(e){ + var t=e.time?new Date(e.time).toLocaleString():''; + var cls=e.action==='fail2ban_ban'?'text-danger':(e.action==='login_blocked'?'text-warning':'text-secondary'); + return ''+escapeHtml(t)+'' + +''+escapeHtml(e.action)+'' + +''+escapeHtml(e.user||'')+'' + +''+escapeHtml(e.detail||'')+'' + +''+escapeHtml(e.ip||e.target||'')+''; + }).join(''); + el.innerHTML='' + +''+rows+'
WhenEventUserDetailIP
'; + }).catch(function(){ el.innerHTML='
Could not load events.
'; }); +} +function loadSecurityLog(which, jail){ + var el=document.getElementById('sec-log'); if(!el) return; + var hdr=document.getElementById('sec-log-title'); + if(hdr) hdr.textContent = (which==='fail2ban' && jail) ? ('fail2ban activity — '+jail) : ''; + el.textContent='Loading…'; + var url=secBase()+'/log?which='+encodeURIComponent(which); + if(jail) url += '&jail='+encodeURIComponent(jail); + fetch(url).then(function(r){return r.json();}).then(function(d){ + el.textContent=d.text||'(log is empty)'; el.scrollTop=el.scrollHeight; // textContent: raw log is never HTML + if(el.scrollIntoView) el.scrollIntoView({behavior:'smooth',block:'nearest'}); + }).catch(function(){ el.textContent='Could not read the log.'; }); +} + +// "update available" badge links to #updates, so a hash pointing at a card opens its tab and +// scrolls to it. ── +(function(){ + var nav = document.getElementById('mtab-nav'); if(!nav) return; + var TABS = ['overview','controls','maintenance','security']; + var CARD_TAB = {updates:'maintenance', backups:'maintenance', diagnostics:'maintenance'}; + var _secLoaded = false; + function show(tab){ + document.querySelectorAll('[data-mtab]').forEach(function(el){ + el.style.display = (el.getAttribute('data-mtab') === tab) ? '' : 'none'; + }); + nav.querySelectorAll('[data-mtab-btn]').forEach(function(b){ + b.classList.toggle('active', b.getAttribute('data-mtab-btn') === tab); + }); + if (tab === 'security' && !_secLoaded && window.loadSecurity) { _secLoaded = true; loadSecurity(); } + try { history.replaceState(null, '', '#' + tab); } catch(e){} + } + nav.addEventListener('click', function(e){ + var b = e.target.closest('[data-mtab-btn]'); if(b) show(b.getAttribute('data-mtab-btn')); + }); + var h = (location.hash || '').replace('#',''); + if (TABS.indexOf(h) >= 0) { show(h); } + else if (CARD_TAB[h]) { show(CARD_TAB[h]); var el = document.getElementById(h); if(el) el.scrollIntoView(); } + else { show('overview'); } + // Added as an "existing" remote → land on Host Controls and auto-run the LinuxGSM scan. + try { + if (new URLSearchParams(location.search).get('scan') === '1') { + show('controls'); + var sc = document.getElementById('disc-scan-btn'); if (sc) sc.scrollIntoView({block:'center'}); + setTimeout(function(){ if (window.scanExisting) scanExisting(); }, 250); + } + } catch(e){} +})(); +// Click a game server's connect address to copy the full ip:port. +document.addEventListener('click', function(ev){ + var el = ev.target.closest('.copy-addr'); if(!el) return; + if(window.copyText) window.copyText(el.getAttribute('data-copy'), 'Copied ' + el.getAttribute('data-copy')); +}); +function barColor(p){ if(p>=85) return '#f85149'; if(p>=60) return '#d29922'; return '#3fb950'; } +function fmtGB(b){ return (b/1073741824).toFixed(1)+' GB'; } + +var coresBuilt = 0; +function buildCores(n){ + var w = document.getElementById('cpu-cores'); w.innerHTML=''; + for(var i=0;i' + +'' + +'0%'; + w.appendChild(r); + } + coresBuilt=n; +} +function pollLive(){ + fetch(MOUNT + '/api/remote/'+REMOTE_ID+'/live').then(r=>r.json()).then(d=>{ + if(d.error) return; + var ov=d.cpu_overall||0; + document.getElementById('cpu-overall-val').textContent=ov; + var ob=document.getElementById('cpu-overall-bar'); ob.style.width=ov+'%'; ob.style.backgroundColor=barColor(ov); + document.getElementById('cpu-cores-label').textContent=(d.core_count||(d.cpu_cores||[]).length)+' cores'; + var cores=d.cpu_cores||[]; + if(cores.length!==coresBuilt) buildCores(cores.length); + cores.forEach(function(p,i){ + var f=document.getElementById('core-fill-'+i), v=document.getElementById('core-val-'+i); + if(f){ f.style.width=p+'%'; f.style.backgroundColor=barColor(p); } + if(v) v.textContent=p+'%'; + }); + var rp=d.ram_percent||0; + document.getElementById('ram-val').textContent=rp; + var rb=document.getElementById('ram-bar'); rb.style.width=rp+'%'; rb.style.backgroundColor=barColor(rp); + document.getElementById('ram-detail').textContent=fmtGB(d.ram_used||0)+' / '+fmtGB(d.ram_total||0)+' used'; + var sp=d.swap_percent||0; + document.getElementById('swap-val').textContent=sp; + var sb=document.getElementById('swap-bar'); sb.style.width=sp+'%'; sb.style.backgroundColor=barColor(sp); + document.getElementById('swap-detail').textContent=d.swap_total?(fmtGB(d.swap_used||0)+' / '+fmtGB(d.swap_total)+' used'):'No swap configured'; + var dp=d.disk_percent||0; + document.getElementById('disk-val').textContent=dp; + var db=document.getElementById('disk-bar'); db.style.width=dp+'%'; db.style.backgroundColor=barColor(dp); + document.getElementById('disk-detail').textContent=d.disk_total?(fmtGB(d.disk_used||0)+' / '+fmtGB(d.disk_total)+' used'):'—'; + }).catch(function(){}); +} +function checkUpdates(){ + var el=document.getElementById('update-info'); el.textContent='Checking…'; + fetch(MOUNT+'/api/remote/'+REMOTE_ID+'/check-updates').then(r=>r.json()) + .then(d=>{ + var n=d.count||0, pkgs=d.packages||[]; + if(!n){ el.textContent='System is up to date.'; return; } + var head='
'+n+' update'+(n===1?'':'s')+' available:
'; + var rows=pkgs.map(function(p){ + var ver = p.from ? (escapeHtml(p.from)+' → '+escapeHtml(p.version)+'') + : escapeHtml(p.version||''); + return '
' + + ''+escapeHtml(p.name)+' '+ver+'
'; + }).join(''); + el.innerHTML=head+'
'+rows+'
'; + }) + .catch(()=>el.textContent='Check failed'); +} +var _osuTimer=null, _osuStale=0; +function runUpdates(){ + confirmDialog({title:'Install updates', icon:'arrow-up-circle', confirmClass:'btn-primary', confirmLabel:'Install updates', + bodyText:'Install all available updates on this host? You can watch it live in a popup; it runs ' + + 'unattended and answers prompts safely (keeps your config files, assumes yes), so it never ' + + 'gets stuck waiting.', + onConfirm:_startOsUpdate}); +} +function _startOsUpdate(){ + if(_osuTimer){ clearTimeout(_osuTimer); _osuTimer=null; } + _osuStale=0; + var logEl=document.getElementById('osu-log'), stEl=document.getElementById('osu-state'), + spin=document.getElementById('osu-spin'); + if(logEl) logEl.textContent=''; + if(stEl){ stEl.className='small mb-2 text-secondary'; stEl.innerHTML=' Starting…'; } + if(spin) spin.style.display=''; + var m=document.getElementById('os-update-modal'); + if(m && window.bootstrap) bootstrap.Modal.getOrCreateInstance(m).show(); + var info=document.getElementById('update-info'); if(info) info.textContent='Installing updates… (watch the popup)'; + fetch(MOUNT+'/api/remote/'+REMOTE_ID+'/os-update/start',{method:'POST'}).then(function(r){return r.json();}) + .then(function(d){ + if(!d.success){ if(spin) spin.style.display='none'; + if(stEl){ stEl.className='small mb-2 text-danger'; stEl.textContent=d.message||'Couldn\'t start.'; } return; } + _pollOsUpdate(); + }).catch(function(){ if(spin) spin.style.display='none'; + if(stEl){ stEl.className='small mb-2 text-danger'; stEl.textContent='Couldn\'t start the update.'; } }); +} +function _pollOsUpdate(){ + fetch(MOUNT+'/api/remote/'+REMOTE_ID+'/os-update/status').then(function(r){return r.json();}) + .then(function(d){ + var logEl=document.getElementById('osu-log'), stEl=document.getElementById('osu-state'), + spin=document.getElementById('osu-spin'); + if(logEl){ var atBottom=logEl.scrollTop+logEl.clientHeight >= logEl.scrollHeight-30; + logEl.textContent=d.log||''; // textContent: apt output is never HTML + if(atBottom) logEl.scrollTop=logEl.scrollHeight; } + if(d.done){ + if(spin) spin.style.display='none'; + var ok=(d.rc===0); + if(stEl){ stEl.className='small mb-2 '+(ok?'text-success':'text-danger'); + stEl.innerHTML=ok?' Updates installed.' + :' Finished with errors (exit '+d.rc+') — see the log above.'; } + var info=document.getElementById('update-info'); + if(info) info.innerHTML=ok?' Updates installed — re-checking…' + :'Update finished with errors — see the popup.'; + // Re-check after a beat so the "installed" note is readable; a full-upgrade should now show 0. + if(ok && typeof checkUpdates==='function') setTimeout(checkUpdates, 1500); + if(window.rebootNagCheck){ if(IS_LOCAL && window.LOCAL_HOST_ID!=null) rebootNagCheck(window.LOCAL_HOST_ID,'the panel host'); + else if(!IS_LOCAL) rebootNagCheck(REMOTE_ID, REMOTE_NAME); } // kernel update -> banner + return; + } + _osuStale = d.running ? 0 : (_osuStale+1); + if(_osuStale>=3){ if(spin) spin.style.display='none'; + if(stEl){ stEl.className='small mb-2 text-warning'; + stEl.innerHTML=' The update process ended without a completion marker — check the log.'; } + return; } + if(stEl && d.running) stEl.innerHTML=' Installing… (safe to close this popup — it keeps running)'; + _osuTimer=setTimeout(_pollOsUpdate, 1500); + }).catch(function(){ _osuTimer=setTimeout(_pollOsUpdate, 3000); }); +} +function rebootRemote(){ + // Check for players on ANY game server on this host first — a reboot disconnects them all. + fetch(MOUNT+'/api/remote/'+REMOTE_ID+'/players').then(r=>r.json()).then(function(d){ + var busy=(d&&d.busy)||[], total=(d&&d.total)||0; + var base = IS_LOCAL ? 'Reboot the PANEL HOST now? The panel and all its game servers will go down briefly.' + : 'Reboot this server now? It will be briefly unreachable.'; + var q = base; + if(total>0){ + var list = busy.map(function(b){ return b.name+' ('+b.players+')'; }).join(', '); + q = '⚠ '+total+' player'+(total===1?' is':'s are')+' currently connected across '+busy.length+' server'+(busy.length===1?'':'s')+':\n '+list + + '\n\nRebooting will DISCONNECT all of them. '+base+'\n\nAre you sure?'; + } + _confirmReboot(q); + }).catch(function(){ + // Couldn't check — fall back to the plain confirm rather than blocking. + var q = IS_LOCAL ? 'Reboot the PANEL HOST now? The panel and all its game servers will go down briefly.' + : 'Reboot this server now? It will be briefly unreachable.'; + _confirmReboot(q); + }); +} +function _confirmReboot(q){ + confirmDialog({title:'Reboot server', icon:'arrow-clockwise', confirmClass:'btn-warning', confirmLabel:'Reboot', + bodyText:q, onConfirm:doRebootRemote}); +} +function doRebootRemote(){ + fetch(MOUNT+'/api/remote/'+REMOTE_ID+'/reboot',{method:'POST'}).then(r=>r.json()) + .then(d=>{ if(window.toast) toast(d.message||'Reboot requested','info'); }).catch(()=>{ if(window.toast) toast('Reboot failed','danger'); }); +} +function rebootRemoteWhenEmpty(){ + // Schedule a reboot once every game server on this host is empty (reuses the shared banner flow). + if (window.rebootNagWhenEmpty) window.rebootNagWhenEmpty(REMOTE_ID, IS_LOCAL ? 'the panel host' : REMOTE_NAME); +} +// ── SSH / connection ── +function retrustHostKey(){ + confirmDialog({title:'Clear pinned host key', icon:'key', confirmClass:'btn-warning', confirmLabel:'Clear host key', + bodyText:'Clear the pinned SSH host key for this server?\n\nOnly do this if YOU reinstalled or rebuilt the server. The next connection will trust and pin whatever host key the server presents.', + onConfirm:function(){ + var m=document.getElementById('hostkey-msg'); m.innerHTML=' Clearing…'; + fetch(MOUNT+'/api/remote/'+REMOTE_ID+'/retrust-hostkey',{method:'POST'}).then(r=>r.json()) + .then(d=>{ m.innerHTML=''+(d.message||(d.success?'Done — the next connection re-pins the key':'Failed'))+''; }) + .catch(()=>{ m.innerHTML='Request failed.'; }); + }}); +} +var SSH_LABELS={allow:'open (allow)',limit:'rate-limited (limit)',off:'disabled — tailnet only'}; +function loadSshStatus(){ + var el=document.getElementById('ssh-mode'); if(!el) return; + fetch(MOUNT+'/api/remote/'+REMOTE_ID+'/ssh-status').then(r=>r.json()) + .then(d=>{ + el.textContent = d.error?'unknown':(SSH_LABELS[d.mode]||d.mode||'unknown'); + // Reflect the CURRENT public-SSH mode on the buttons: the active mode's button is + // marked active + disabled (you're already in that state — clicking it is a no-op). + // Other buttons become clickable again, EXCEPT the "off" button when it's disabled + // by the lock-out guard (data-lockdown), which must stay disabled. + document.querySelectorAll('[data-ssh-btn]').forEach(function(b){ + var isCur = !d.error && b.getAttribute('data-ssh-btn') === d.mode; + b.classList.toggle('active', isCur); + if (isCur) { b.disabled = true; b.setAttribute('aria-current', 'true'); } + else if (!b.hasAttribute('data-lockdown')) { b.disabled = false; b.removeAttribute('aria-current'); } + }); + // "Close public panel port" is a no-op once the port is already closed — disable it. + var cp = document.getElementById('close-panel-btn'); + if (cp && !cp.hasAttribute('data-hard-disabled')) { + if (d.panel_port_open === false) { + cp.disabled = true; + cp.title = 'The public panel port is already closed — the panel is tailnet-only.'; + } else if (d.panel_port_open === true) { + cp.disabled = false; + cp.title = ''; + } + } + }) + .catch(()=>{ el.textContent='unknown'; }); +} +function sshMode(mode){ + var run = function(){ + var el=document.getElementById('ssh-msg'); el.textContent='Applying…'; el.className='small mt-1 text-secondary'; + fetch(MOUNT+'/api/remote/'+REMOTE_ID+'/ssh-mode',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode:mode})}) + .then(r=>r.json()).then(d=>{ el.textContent=(d.success?'✓ ':'✗ ')+(d.message||''); el.className='small mt-1 '+(d.success?'text-success':'text-danger'); loadSshStatus(); }) + .catch(()=>{ el.textContent='✗ Failed'; el.className='small mt-1 text-danger'; }); + }; + if(mode==='off'){ + confirmDialog({title:'Disable public SSH', icon:'shield-lock', confirmClass:'btn-danger', confirmLabel:'Disable public SSH', + bodyText:'Disable PUBLIC SSH (port 22)? SSH will only work over Tailscale after this. Make sure Tailscale SSH works first!', + onConfirm:run}); + } else { run(); } +} +function closePanelPort(){ + confirmDialog({title:'Close public panel port', icon:'shield-lock', confirmClass:'btn-danger', confirmLabel:'Close public port', + bodyText:'Close the public web port so the panel is reachable ONLY over your tailnet?\n\nMake sure you can already reach the panel at your ts.net URL (Tailscale Serve) — this removes the public way in.', + onConfirm:function(){ + var m=document.getElementById('panel-port-msg'); m.innerHTML=' Closing…'; + fetch(MOUNT+'/api/remote/'+REMOTE_ID+'/close-panel-port',{method:'POST'}).then(r=>r.json()) + .then(d=>{ m.innerHTML=''+(window.escapeHtml?escapeHtml(d.message):(d.message||''))+''; if(d.success) loadSshStatus(); }) + .catch(()=>{ m.innerHTML='Request failed.'; }); + }}); +} +function onBindSelect(){ + var sel=document.getElementById('panel-bind-select'); + var wrap=document.getElementById('panel-bind-custom-wrap'); + if(sel && wrap) wrap.style.display = (sel.value === '__custom__') ? '' : 'none'; +} +function changePanelBinding(){ + var pinp=document.getElementById('panel-port-input'); if(!pinp) return; + var p=parseInt(pinp.value,10); + if(!(p>=1024 && p<=65535)){ if(window.toast) toast('Pick a port between 1024 and 65535.','warning'); return; } + var sel=document.getElementById('panel-bind-select'); + var bind = sel ? sel.value : '0.0.0.0'; + if(bind === '__custom__'){ + bind = (document.getElementById('panel-bind-custom').value || '').trim(); + if(!bind){ if(window.toast) toast('Enter the IP address to bind to.','warning'); return; } + } + var pretty = bind + ':' + p; + confirmDialog({title:'Change panel binding', icon:'hdd-network', confirmClass:'btn-warning', confirmLabel:'Change binding', + bodyText:'Change the panel binding to '+pretty+'?\n\nThe panel will briefly restart to apply it. ' + +'Make sure you can still reach it afterward (over Tailscale, or on the new address/port).', + onConfirm:function(){ _changePanelBinding(p, bind); }}); +} +function _changePanelBinding(p, bind){ + var m=document.getElementById('panel-port-change-msg'); + m.innerHTML=' Applying…'; + fetch(MOUNT+'/api/panel/change-port',{method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({port:p, bind_host:bind})}) + .then(r=>r.json()).then(d=>{ + if(!d.success){ m.innerHTML=''+(window.escapeHtml?escapeHtml(d.message):(d.message||'Failed'))+''; return; } + // The panel is restarting. Work out where it'll be reachable afterward. + var target; + if(d.served_over_tailscale || !location.port){ + target = location.href; // same URL (Tailscale Serve / no explicit port) + } else if(d.port_changed){ + var u=new URL(location.href); u.port=d.new_port; target=u.href; // direct access → new port + } else { + target = location.href; // only the bind changed; same URL + } + m.innerHTML='Panel restarting on '+(window.escapeHtml?escapeHtml(d.new_bind):d.new_bind)+':'+d.new_port+'… reconnecting shortly.'; + setTimeout(function(){ location.href=target; }, 7000); // give the service time to rebind + }) + .catch(function(){ + // The restart may cut the connection before the response arrives. + m.innerHTML='The panel is restarting — reconnect in a moment' + + (location.port ? ' on the new address/port' : '') + '.'; + }); +} +function changeSshPort(){ + var inp=document.getElementById('ssh-port-input'); if(!inp) return; + var p=parseInt(inp.value,10); + if(!(p>=1 && p<=65535)){ if(window.toast) toast('Enter a port between 1 and 65535.','warning'); return; } + var bindEl=document.getElementById('ssh-bind-input'); + var bind=bindEl ? (bindEl.value||'').trim() : ''; + var bindNote = bind ? (' and bind it to '+bind+' (the panel will roll back if it can\'t reach the host there)') : ''; + confirmDialog({title:'Change SSH port', icon:'door-closed', confirmClass:'btn-primary', confirmLabel:'Change SSH port', + bodyText:'Move SSH to port '+p+bindNote+'?\n\nLockout-safe: the current binding stays in place as a fallback, and the ' + +'firewall and fail2ban are updated to the new port. Once you\'ve confirmed you can reach SSH on ' + +p+', close the old port from the Firewall page.', + onConfirm:function(){ + var m=document.getElementById('ssh-port-msg'); + if(m) m.innerHTML=' Applying (opening the port, updating sshd + fail2ban, restarting sshd)…'; + fetch(MOUNT+'/api/remote/'+REMOTE_ID+'/ssh-port',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({port:p, bind:bind})}) + .then(function(r){return r.json();}).then(function(d){ + if(m) m.innerHTML=''+(window.escapeHtml?escapeHtml(d.message||''):(d.message||''))+''; + if(window.toast) toast(d.message||(d.success?'SSH port changed':'Failed'), d.success?'success':'danger'); + }) + .catch(function(){ if(m) m.innerHTML='Request failed.'; }); + }}); +} +function switchToTailscale(){ + confirmDialog({title:'Migrate to Tailscale SSH', icon:'arrow-repeat', confirmClass:'btn-primary', confirmLabel:'Migrate', + bodyText:'Switch the panel to Tailscale SSH for this server?\n\nThe connection address becomes the Tailscale IP/DNS and auth switches to Tailscale SSH. Requires Tailscale to be running on the remote.', + onConfirm:_switchToTailscale}); +} +function _switchToTailscale(){ + var el=document.getElementById('migrate-msg'); el.textContent='Migrating…'; el.className='small mt-1 text-secondary'; + fetch(MOUNT+'/api/remote/'+REMOTE_ID+'/tailscale-migrate',{method:'POST'}).then(r=>r.json()) + .then(d=>{ if(d.success){ el.textContent='✓ '+(d.message||'Migrated'); el.className='small mt-1 text-success'; setTimeout(function(){window.refreshSection('#conn-ssh-card');},1200); } else { el.textContent='✗ '+(d.message||'Failed'); el.className='small mt-1 text-danger'; } }) + .catch(()=>{ el.textContent='✗ Migration failed'; el.className='small mt-1 text-danger'; }); +} +// ── System specs (static — fetched once) ── +function specEsc(s){ return window.escapeHtml(s); } +function renderSpecs(d, el){ + if(!el) return; + if(!d || d.error){ el.innerHTML = '
Specs unavailable'+(d&&d.error?': '+specEsc(d.error):'')+'
'; return; } + function tile(label, val, cls){ + if(!val) return ''; + return '
'+label+'
' + +'
'+specEsc(val)+'
'; + } + var cpu = d.cpu + (d.cpu_speed ? ' · ' + d.cpu_speed : ''); + var html = tile('Operating System', d.os, 'col-12 col-md-6') + + tile('Processor', cpu, 'col-12 col-md-6') + + tile('CPU Cores', d.cores, 'col-6 col-md-3') + + tile('Memory', d.ram, 'col-6 col-md-3') + + tile('Disk (/)', d.disk, 'col-6 col-md-3') + + tile('Kernel', d.kernel, 'col-6 col-md-3') + + tile('Architecture', d.arch, 'col-6 col-md-3') + + tile('Virtualization', d.virt, 'col-6 col-md-3') + + tile('Hostname', d.hostname, 'col-6 col-md-3'); + el.innerHTML = html || '
No spec data.
'; +} +fetch(MOUNT + '/api/remote/' + REMOTE_ID + '/specs').then(r=>r.json()) + .then(d=>renderSpecs(d, document.getElementById('specs-body'))) + .catch(()=>renderSpecs({error:'request failed'}, document.getElementById('specs-body'))); + +// Ubuntu Pro card (shared widget from base.html). Pass the persisted status so the card +// paints instantly instead of blanking to "Checking Ubuntu Pro…"; it then refreshes silently. + +// ── Panel-host-only controls (Tailscale SSH, UFW-allow, self-update) ── +function tsSshEnable(){ + confirmDialog({title:'Enable Tailscale SSH', icon:'shield-check', confirmClass:'btn-primary', confirmLabel:'Enable', + bodyText:'Enable Tailscale SSH? This re-authenticates Tailscale with SSH support enabled.', + onConfirm:function(){ + fetch(MOUNT+'/api/server-management/ts-ssh-enable',{method:'POST'}).then(r=>r.json()) + .then(d=>{ if(window.toast) toast(d.message||(d.success?'Enabled':'Failed'), d.success?'success':'danger'); if(d.success) setTimeout(function(){window.refreshSection('#conn-ssh-card');},800); }) + .catch(()=>{ if(window.toast) toast('Failed to enable Tailscale SSH','danger'); }); + }}); +} +function tsSshDisable(){ + confirmDialog({title:'Disable Tailscale SSH', icon:'shield-slash', confirmClass:'btn-danger', confirmLabel:'Disable', + bodyText:'Disable Tailscale SSH?', + onConfirm:function(){ + fetch(MOUNT+'/api/server-management/ts-ssh-disable',{method:'POST'}).then(r=>r.json()) + .then(d=>{ if(window.toast) toast(d.message||(d.success?'Disabled':'Failed'), d.success?'success':'danger'); if(d.success) setTimeout(function(){window.refreshSection('#conn-ssh-card');},800); }) + .catch(()=>{ if(window.toast) toast('Failed to disable Tailscale SSH','danger'); }); + }}); +} +function ufwAllowTailscale(){ + fetch(MOUNT+'/api/server-management/ufw-allow-tailscale',{method:'POST'}).then(r=>r.json()) + .then(d=>{ if(window.toast) toast(d.message||(d.success?'Allowed':'Failed'), d.success?'success':'danger'); if(d.success) setTimeout(function(){window.refreshSection('#conn-ssh-card');},800); }) + .catch(()=>{ if(window.toast) toast('Failed to configure UFW','danger'); }); +} +function renderUpdate(d){ + var st=document.getElementById('pu-status'); if(!st) return; + var btn=document.getElementById('pu-update-btn'); var changes=document.getElementById('pu-changes'); + var cur=document.getElementById('pu-current'); if(cur) cur.textContent='v'+(d.current_version||'?'); + if(d.git===false){ st.innerHTML=' '+(d.message||'Self-update unavailable (not a git checkout).'); btn.style.display='none'; changes.style.display='none'; return; } + if(d.fetched===false){ st.innerHTML=' '+(d.message||'Couldn\'t reach the update source.')+''; btn.style.display='none'; changes.style.display='none'; return; } + if(d.update_available){ + st.innerHTML=' Update available: v'+(d.remote_version||'?')+' ('+d.behind+' commit'+(d.behind===1?'':'s')+' behind).'; + btn.style.display=''; + var ul=document.getElementById('pu-changes-list'); ul.innerHTML=''; + (d.changes||[]).forEach(function(c){ var li=document.createElement('li'); li.textContent=c; ul.appendChild(li); }); + changes.style.display=(d.changes&&d.changes.length)?'':'none'; + } else { + // No VERIFIED update ahead — show it as up to date. If a newer commit exists but is still + // being verified (or failed a check), we deliberately DON'T surface a "being verified" + // state; the update only appears once a commit has fully passed every check. + st.innerHTML=' You\'re up to date'+(d.current_sha?' ('+d.current_sha+')':'')+'.'; + btn.style.display='none'; changes.style.display='none'; + } +} +function checkPanelUpdate(force){ + var st=document.getElementById('pu-status'); if(!st) return; + st.innerHTML=' Checking for updates…'; + fetch(MOUNT+'/api/panel/update-status'+(force?'?force=1':'')).then(function(r){return r.json();}).then(renderUpdate) + .catch(function(){ st.innerHTML='Update check failed.'; }); +} +// Render the streamed self-update log with per-line styling (steps / ok / warn / error). +function renderPuLog(lines){ + var body=document.getElementById('pu-log-body'); if(!body) return; + var atBottom = body.scrollTop + body.clientHeight >= body.scrollHeight - 30; + var html=(lines||[]).map(function(ln){ + var cls='text-secondary'; + if(/^\[\d+\/\d+\]/.test(ln)) cls='text-info fw-semibold'; + else if(/^✓|health check passed|update complete|rollback succeeded|is responding|now running version/i.test(ln)) cls='text-success'; + else if(/\[error\]|health check failed|rolling back|could not/i.test(ln)) cls='text-danger'; + else if(/^\[!\]|warn/i.test(ln)) cls='text-warning'; + return '
'+(window.escapeHtml?escapeHtml(ln):ln)+'
'; + }).join(''); + body.innerHTML = html || 'Starting…'; + if(atBottom) body.scrollTop = body.scrollHeight; // stay pinned to the newest line +} +function doPanelUpdate(){ + confirmDialog({title:'Update the panel', icon:'arrow-up-circle', confirmClass:'btn-primary', confirmLabel:'Update now', + bodyText:'Update the panel to the latest version now?\n\nIt will back up, pull new code, install any new dependencies, and restart — briefly unavailable (a few seconds). Live progress shows below.', + onConfirm:_doPanelUpdate}); +} +function _doPanelUpdate(){ + var msg=document.getElementById('pu-msg'), btn=document.getElementById('pu-update-btn'); + var logWrap=document.getElementById('pu-log'), body=document.getElementById('pu-log-body'); + btn.disabled=true; logWrap.style.display=''; + body.innerHTML='Starting the updater…'; + msg.innerHTML=' Updating…'; + // Remember THIS process's boot_id — the run is finished once the panel has actually + // restarted (boot_id changed), not when the git SHA moves (that happens mid-update). + fetch(MOUNT+'/api/panel/update-status').then(function(r){return r.json();}).then(function(before){ + var beforeBoot=(before&&before.boot_id)||''; + fetch(MOUNT+'/api/panel/update',{method:'POST'}).then(function(r){return r.json();}).then(function(d){ + if(!d.success){ msg.innerHTML=''+(window.escapeHtml?escapeHtml(d.message||'Update failed'):(d.message||'Update failed'))+''; btn.disabled=false; return; } + watchPanelRestart(beforeBoot, msg, 'Update complete'); + }).catch(function(){ msg.innerHTML='Update request failed.'; btn.disabled=false; }); + }).catch(function(){ msg.innerHTML='Couldn\'t read the current version.'; btn.disabled=false; }); +} + +// Watch for the panel restarting after a detached update/branch-switch: stream the installer's +// step log, and finish when boot_id flips (the new process is live), then reload. Shared by the +// "Update now" and "Switch branch" flows since both back up → change code → restart. +function watchPanelRestart(beforeBoot, msg, doneLabel){ + var tries=0, restarted=false, done=false; + var iv=setInterval(function(){ + tries++; + fetch(MOUNT+'/api/panel/update-log').then(function(r){ return r.ok?r.json():null; }) + .then(function(l){ if(l && l.lines && l.lines.length) renderPuLog(l.lines); }).catch(function(){}); + fetch(MOUNT+'/api/panel/update-status').then(function(r){ return r.ok?r.json():null; }).then(function(s){ + if(!s){ if(!restarted){ restarted=true; msg.innerHTML=' Restarting the panel…'; } return; } + if(s.boot_id && beforeBoot && s.boot_id!==beforeBoot && !done){ + done=true; clearInterval(iv); + fetch(MOUNT+'/api/panel/update-log').then(function(r){ return r.ok?r.json():null; }).then(function(l){ + if(l && l.lines) renderPuLog(l.lines); + msg.innerHTML=' '+escapeHtml(doneLabel||'Done')+' — reloading…'; + setTimeout(function(){ location.reload(); }, 2500); + }); + } + }).catch(function(){ if(!restarted){ restarted=true; msg.innerHTML=' Restarting the panel…'; } }); + if(tries>120){ clearInterval(iv); msg.innerHTML='Still working — reload the page to check.'; } + }, 1500); +} +// Populate the branch selector with the remote branches + the currently tracked one. +function loadPanelBranches(){ + var sel=document.getElementById('pu-branch-select'), cur=document.getElementById('pu-branch-current'); + if(!sel) return; + fetch(MOUNT+'/api/panel/branches').then(function(r){return r.json();}).then(function(d){ + var branches=d.branches||[], current=d.current||'main'; + if(cur) cur.textContent=current; + sel.innerHTML=''; + branches.forEach(function(b){ + var o=document.createElement('option'); o.value=b; o.textContent=b; + if(b===current) o.selected=true; sel.appendChild(o); + }); + }).catch(function(){}); +} +function switchPanelBranch(){ + var sel=document.getElementById('pu-branch-select'), msg=document.getElementById('pu-branch-msg'); + var btn=document.getElementById('pu-branch-switch'); + if(!sel||!sel.value){ return; } + var branch=sel.value; + var logWrap=document.getElementById('pu-log'), body=document.getElementById('pu-log-body'); + confirmDialog({title:'Switch panel branch', icon:'diagram-3', confirmClass:'btn-danger', confirmLabel:'Switch branch', + bodyText:'Switch the panel to branch "'+branch+'"?\n\nIt backs up, checks out that branch and restarts. If it fails to boot it rolls back automatically. Non-main branches are unverified code — use for testing.', + onConfirm:function(){ + btn.disabled=true; if(logWrap) logWrap.style.display=''; + if(body) body.innerHTML='Starting…'; + msg.innerHTML=' Switching…'; + fetch(MOUNT+'/api/panel/update-status').then(function(r){return r.json();}).then(function(before){ + var beforeBoot=(before&&before.boot_id)||''; + fetch(MOUNT+'/api/panel/switch-branch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({branch:branch})}) + .then(function(r){return r.json();}).then(function(d){ + if(!d.success){ msg.innerHTML=''+(window.escapeHtml?escapeHtml(d.message||'Switch failed'):(d.message||'Switch failed'))+''; btn.disabled=false; return; } + watchPanelRestart(beforeBoot, msg, 'Switched to '+branch); + }).catch(function(){ msg.innerHTML='Switch request failed.'; btn.disabled=false; }); + }).catch(function(){ msg.innerHTML='Couldn\'t read the current version.'; btn.disabled=false; }); + }}); +} + +// ── Diagnostics & file integrity (panel host only) ────────────────── +var DIAG_BADGE = { ok:'bg-success', warn:'bg-warning text-dark', fail:'bg-danger' }; +var DIAG_ICON = { ok:'bi-check-circle-fill text-success', + warn:'bi-exclamation-triangle-fill text-warning', + fail:'bi-x-circle-fill text-danger' }; + +function runDiagnostics(){ + var btn=document.getElementById('diag-run-btn'); + var msg=document.getElementById('diag-msg'); + var list=document.getElementById('diag-list'); + if(btn){ btn.disabled=true; } + if(msg){ msg.innerHTML=' Running…'; } + fetch(MOUNT+'/api/panel/diagnostics').then(function(r){return r.json();}).then(function(d){ + // Build rows with textContent (file details can contain paths) — no innerHTML injection. + list.textContent=''; + (d.checks||[]).forEach(function(c){ + var row=document.createElement('div'); + row.className='d-flex align-items-start gap-2 py-1'; + var ic=document.createElement('i'); + ic.className='bi '+(DIAG_ICON[c.level]||DIAG_ICON.warn); + ic.style.marginTop='2px'; + var txt=document.createElement('div'); + var name=document.createElement('strong'); name.textContent=c.name+': '; + var det=document.createElement('span'); det.className='text-secondary'; det.textContent=c.detail||''; + txt.appendChild(name); txt.appendChild(det); + row.appendChild(ic); row.appendChild(txt); + list.appendChild(row); + }); + var badge=document.getElementById('diag-summary'); + if(badge){ + var s=d.summary||'fail'; + badge.className='badge '+(DIAG_BADGE[s]||DIAG_BADGE.fail); + badge.textContent = s==='ok' ? 'all healthy' : (s==='warn' ? (d.warn+' warning(s)') : (d.fail+' problem(s)')); + } + if(msg) msg.textContent=''; + loadIntegrity(); // refresh the file list + repair button + loadDbStats(); // refresh DB size + audit row count + loadAutoUpd(); // refresh automatic-security-updates status + }).catch(function(){ + if(msg) msg.innerHTML='Diagnostics failed — check the panel logs.'; + }).finally(function(){ if(btn) btn.disabled=false; }); +} + +function loadIntegrity(){ + fetch(MOUNT+'/api/panel/integrity').then(function(r){return r.json();}).then(function(d){ + var wrap=document.getElementById('diag-integrity'); + var clean=document.getElementById('diag-integrity-clean'); + var bad=document.getElementById('diag-integrity-bad'); + if(!wrap) return; + wrap.style.display=''; + if(!d.git){ clean.style.display='none'; bad.style.display='none'; return; } + if(d.clean){ clean.style.display=''; bad.style.display='none'; return; } + clean.style.display='none'; bad.style.display=''; + document.getElementById('diag-bad-count').textContent=d.count; + var ul=document.getElementById('diag-bad-list'); ul.textContent=''; + (d.modified||[]).forEach(function(m){ + var li=document.createElement('li'); + var st=document.createElement('span'); + st.className = m.status==='deleted' ? 'text-danger' : 'text-warning'; + st.textContent='['+m.status+'] '; + var p=document.createElement('span'); p.textContent=m.path; // textContent — never innerHTML + li.appendChild(st); li.appendChild(p); + ul.appendChild(li); + }); + }).catch(function(){}); +} + +function repairPanel(){ + confirmDialog({title:'Restore panel files', icon:'arrow-counterclockwise', confirmClass:'btn-danger', confirmLabel:'Restore files', + bodyText:'Restore all modified/deleted panel files to their installed version? Your database, keys and config are not affected.', + onConfirm:_repairPanel}); +} +function _repairPanel(){ + var btn=document.getElementById('diag-repair-btn'); + if(btn){ btn.disabled=true; btn.innerHTML=' Restoring…'; } + fetch(MOUNT+'/api/panel/repair',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}) + .then(function(r){return r.json();}).then(function(d){ + var msg=document.getElementById('diag-msg'); + if(msg){ + msg.className='small'; + msg.innerHTML = d.success + ? ' '+ (d.message||'Restored.') +'' + : ''+ (d.message||'Repair failed.') +''; + } + loadIntegrity(); + if(d.success) runDiagnostics(); + }).catch(function(){ + var msg=document.getElementById('diag-msg'); + if(msg) msg.innerHTML='Repair request failed.'; + }).finally(function(){ + if(btn){ btn.disabled=false; btn.innerHTML=' Restore all from installed version'; } + }); +} + +// ── Database maintenance (panel host only) ────────────────────────── +function fmtBytes(b){ + if(b == null) return '—'; + if(b >= 1099511627776) return (b/1099511627776).toFixed(2)+' TB'; + if(b >= 1073741824) return (b/1073741824).toFixed(1)+' GB'; + if(b >= 1048576) return (b/1048576).toFixed(1)+' MB'; + if(b >= 1024) return Math.max(1, Math.round(b/1024))+' KB'; + return b+' B'; +} +function loadDbStats(){ + fetch(MOUNT+'/api/panel/db-stats').then(function(r){return r.json();}).then(function(d){ + var el=document.getElementById('diag-db-stats'); if(!el) return; + if(d.error){ el.textContent='Could not read database stats.'; return; } + el.textContent='Size '+fmtBytes(d.size)+' · WAL '+fmtBytes(d.wal_size)+ + ' · audit rows '+(d.audit_rows==null?'?':d.audit_rows); + }).catch(function(){}); +} +function optimizeDb(){ + var btn=document.getElementById('diag-optimize-btn'); + var msg=document.getElementById('diag-db-msg'); + if(btn){ btn.disabled=true; btn.innerHTML=' Optimizing…'; } + fetch(MOUNT+'/api/panel/optimize-db',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}) + .then(function(r){return r.json();}).then(function(d){ + if(msg){ + msg.className='small'; + if(d.success){ + var freed=d.freed||0; + msg.innerHTML=' Optimized'+ + (freed>0?(' — reclaimed '+fmtBytes(freed)):'')+'.'; + } else { + msg.innerHTML=''+(d.message||'Optimize failed.')+''; + } + } + loadDbStats(); + }).catch(function(){ if(msg) msg.innerHTML='Optimize request failed.'; }) + .finally(function(){ if(btn){ btn.disabled=false; btn.innerHTML=' Optimize database'; } }); +} +function checkDbHealth(){ + var btn=document.getElementById('diag-dbhealth-btn'); + var out=document.getElementById('diag-dbhealth-result'); + if(btn){ btn.disabled=true; btn.innerHTML=' Checking…'; } + fetch(MOUNT+'/api/panel/db-health').then(function(r){return r.json();}).then(function(d){ + if(!out) return; + var rbtn=document.getElementById('diag-repair-btn'); + if(d.healthy===true){ + out.innerHTML=' Database is healthy — integrity check passed.'; + if(rbtn) rbtn.style.display='none'; + } else if(d.healthy===false){ + out.innerHTML=' Integrity check flagged a problem: '+escapeHtml(d.detail||'')+'.'+ + '
Click Repair database — it rebuilds the readable data, or restores the last healthy backup; your data is copied aside first, never deleted. The panel briefly restarts.
'; + if(rbtn) rbtn.style.display=''; + } else { + out.innerHTML='Could not run the health check right now.'; + } + }).catch(function(){ if(out) out.innerHTML='Health check request failed.'; }) + .finally(function(){ if(btn){ btn.disabled=false; btn.innerHTML=' Check health'; } }); +} + +// Repair a flagged database on-demand: stops the panel, repairs offline, restarts (~1 min). +function repairDb(){ + confirmDialog({title:'Repair database', icon:'wrench-adjustable', confirmClass:'btn-warning', confirmLabel:'Repair & restart', + bodyText:'The panel will stop, repair the database offline (your data is copied aside first — never deleted), then restart. This takes about a minute. Continue?', + onConfirm:function(){ + var rb=document.getElementById('diag-repair-btn'), msg=document.getElementById('diag-db-msg'); + if(rb){ rb.disabled=true; rb.innerHTML=' Repairing…'; } + fetch(MOUNT+'/api/panel/repair-db',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}) + .then(function(r){return r.json();}).then(function(d){ + if(msg){ msg.className='small'; + msg.innerHTML = d.success + ? ' '+(window.escapeHtml?escapeHtml(d.message||'Repair started.'):(d.message||'Repair started.'))+'' + : ''+(window.escapeHtml?escapeHtml(d.message||'Failed.'):(d.message||'Failed.'))+''; } + }).catch(function(){ if(msg) msg.innerHTML='Request failed.'; }) + .finally(function(){ if(rb){ rb.disabled=false; rb.innerHTML=' Repair database'; } }); + }}); +} + +// ── Automatic security updates (panel host only) ──────────────────── +function loadAutoUpd(){ + fetch(MOUNT+'/api/panel/auto-updates').then(function(r){return r.json();}).then(function(d){ + var el=document.getElementById('diag-autoupd-status'); + var btn=document.getElementById('diag-autoupd-btn'); + if(!el) return; + if(d.error){ el.textContent='Could not check update status.'; return; } + el.innerHTML = d.enabled + ? ' '+ (d.detail||'Enabled.') +'' + : ' '+ (d.detail||'Not enabled.') +''; + if(btn) btn.style.display = d.enabled ? 'none' : ''; + }).catch(function(){}); +} +function enableAutoUpdates(){ + confirmDialog({title:'Enable automatic security updates', icon:'shield-check', confirmClass:'btn-primary', confirmLabel:'Enable', + bodyText:'Install and enable automatic security updates (unattended-upgrades)? This changes the system update settings.', + onConfirm:_enableAutoUpdates}); +} +function _enableAutoUpdates(){ + var btn=document.getElementById('diag-autoupd-btn'), msg=document.getElementById('diag-autoupd-msg'); + if(btn){ btn.disabled=true; btn.innerHTML=' Enabling…'; } + fetch(MOUNT+'/api/panel/enable-auto-updates',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}) + .then(function(r){return r.json();}).then(function(d){ + if(msg){ + msg.className='small'; + msg.innerHTML = d.success + ? ' '+(d.message||'Enabled.')+'' + : ''+(d.message||'Failed.')+''; + } + loadAutoUpd(); + }).catch(function(){ if(msg) msg.innerHTML='Request failed.'; }) + .finally(function(){ if(btn){ btn.disabled=false; btn.innerHTML=' Enable automatic security updates'; } }); +} + +// Generate a shareable debug report — show it for review, then let the admin download +// it or open a pre-filled GitHub issue (safe summary; log goes in the download). +function genDebugReport(){ + var btn=document.getElementById('diag-report-btn'), msg=document.getElementById('diag-report-msg'); + if(btn){ btn.disabled=true; btn.innerHTML=' Generating…'; } + fetch(MOUNT+'/api/panel/debug-report').then(function(r){return r.json();}).then(function(d){ + if(d.error){ if(msg) msg.innerHTML='Could not generate the report.'; return; } + var ta=document.getElementById('diag-report-text'); + ta.value=d.report; ta.style.display=''; // .value (not innerHTML) — nothing to inject + var dl=document.getElementById('diag-report-dl'); + dl.href=URL.createObjectURL(new Blob([d.report],{type:'text/markdown'})); + dl.download=d.filename; dl.style.display=''; + var gh=document.getElementById('diag-report-gh'); + var body=d.summary+'\n\n---\n**Describe the problem here.** For the full log, attach the downloaded debug file.\n'; + gh.href=d.issues_url+'?labels=debug&title='+encodeURIComponent('Debug report')+'&body='+encodeURIComponent(body.slice(0,6000)); + gh.style.display=''; + if(msg) msg.innerHTML='Report ready — review it below before sharing.'; + }).catch(function(){ if(msg) msg.innerHTML='Request failed.'; }) + .finally(function(){ if(btn){ btn.disabled=false; btn.innerHTML=' Generate debug report'; } }); +} + +// ── Backups (panel host only) ── +function bkFmtBytes(b){ b=b||0; if(b<1024)return b+' B'; if(b<1048576)return (b/1024).toFixed(0)+' KB'; if(b<1073741824)return (b/1048576).toFixed(1)+' MB'; if(b<1099511627776)return (b/1073741824).toFixed(1)+' GB'; return (b/1099511627776).toFixed(2)+' TB'; } +function bkAgo(epoch){ var s=Math.max(0,Math.floor(Date.now()/1000-epoch)); if(s<60)return 'just now'; if(s<3600)return Math.floor(s/60)+'m ago'; if(s<86400)return Math.floor(s/3600)+'h ago'; return Math.floor(s/86400)+'d ago'; } +function bkMsg(t,cls){ var m=document.getElementById('bk-msg'); if(m){ m.textContent=t||''; m.className='small '+(cls||'text-secondary'); } } +function loadBackups(){ + fetch(MOUNT+'/api/panel/backups').then(r=>r.json()).then(function(d){ + var s=d.settings||{}; + var en=document.getElementById('bk-enabled'); if(en) en.checked = s.enabled!==false; + var kp=document.getElementById('bk-keep'); if(kp && s.keep_days) kp.value = String(s.keep_days); + var tb=document.getElementById('bk-tbody'); if(!tb) return; + var rows=''; + (d.backups||[]).forEach(function(b){ + var when=new Date(b.created*1000).toLocaleString(); + var kind = b.kind==='daily'?'daily' + : b.kind==='prerestore'?'pre-restore' + : 'manual'; + rows += '' + + ''+escapeHtml(bkAgo(b.created))+'' + + ''+kind+'' + + ''+bkFmtBytes(b.size)+'' + + '' + + ' ' + + ' ' + + '' + + ''; + }); + if(!(d.backups||[]).length) rows='No backups yet.'; + tb.innerHTML=rows; + document.getElementById('bk-loading').style.display='none'; + document.getElementById('bk-table-wrap').style.display=''; + // ── Full (game server files) backup section ── + var f=d.full||{}; + var autoOn=(f.interval_days||0)>0; + var fen=document.getElementById('fb-auto-enabled'); if(fen) fen.checked=autoOn; + var fi=document.getElementById('fb-interval'); + if(fi){ fi.value=String(autoOn?f.interval_days:7); fi.disabled=!autoOn; } + var fk=document.getElementById('fb-keep'); if(fk) fk.value=String(f.keep||2); + window._bkDisk=d.disk||{free:0,total:0,backup_bytes:0,est_cycle:0}; + window._bkMultiHost=!!d.multi_host; + var fdk=document.getElementById('fb-disk'); + if(fdk){ + var dk=window._bkDisk; + if(window._bkMultiHost){ + // Servers span multiple hosts — a single disk figure would be misleading, so show + // each server's own host disk on its row below instead. + fdk.innerHTML=' Your servers are on more than one host — free disk is shown per server below.'; + } else if(dk.total>0){ + var pct=Math.round((dk.total-dk.free)/dk.total*100); + fdk.innerHTML=' Disk: '+bkFmtBytes(dk.free)+' free of '+bkFmtBytes(dk.total) + +' ('+pct+'% used). Game backups currently use '+bkFmtBytes(dk.backup_bytes||0)+'.'; + } else { fdk.textContent=''; } + } + fbSummary(); + var fnow=document.getElementById('fb-now'); if(fnow) fnow.disabled = !!d.full_running; + var fs=document.getElementById('fb-status'); + if(fs){ fs.textContent = d.full_running ? 'Running now…' : (f.last ? ('Last: '+bkAgo(f.last)+(f.summary?' — '+f.summary:'')) : 'Never run'); } + var fg=document.getElementById('fb-games'); + if(fg){ + var gh=''; + (d.games||[]).forEach(function(g){ + var rows=(g.backups||[]) + // Skip a backup that's still being written — show it only once it's finished. + .filter(function(b){ return !b.in_progress; }) + .map(function(b){ + return '' + + '
'+escapeHtml(b.name)+'
' + + '
'+escapeHtml(bkAgo(b.created))+'
' + + ''+bkFmtBytes(b.size)+'' + + '' + + ' ' + + '' + + ''; + }).join(''); + var table = rows + ? '
' + + '' + + ''+rows+'
Backup fileSizeActions
' + : '
no backups yet
'; + var st=g.status, stHtml=''; + if(st){ + if(st.running){ stHtml=' backing up…'; } + else if(st.busy){ + // Skipped because players were connected — offer a one-click force. + stHtml=' '+escapeHtml(st.msg||'players online — skipped')+'' + +' '; + } + else if(st.ok===true){ stHtml=' ✓ '+escapeHtml(st.msg||'backed up')+''; } + else if(st.ok===false){ stHtml=' ✗ '+escapeHtml(st.msg||'failed')+''; } + } + // Host label disambiguates same-named servers on different machines; per-server disk is + // that server's OWN host, so the numbers/warnings are right even with multiple hosts. + var hostHtml = g.host ? ' · '+escapeHtml(g.host)+'' : ''; + var dkHtml = ''; + if(g.disk && g.disk.total>0){ + dkHtml = '
'+escapeHtml(g.host||'host')+': '+bkFmtBytes(g.disk.free)+' free'; + var eb=g.est_backup||0, keep=(g.schedule&&g.schedule.keep)||2; + if(eb>0){ + var proj=eb*keep; + if(proj>g.disk.free) dkHtml += ' — ~'+bkFmtBytes(proj)+' needed for '+keep+' backups, not enough space!'; + else if(proj>g.disk.free*0.5) dkHtml += ' — ~'+bkFmtBytes(proj)+' for '+keep+' backups (over half free)'; + } + dkHtml += '
'; + } + gh += '
' + + '
' + + ''+escapeHtml(g.name)+''+hostHtml+'' + + '' + + '
' + + gameSchedule(g) + + dkHtml + + (stHtml ? '
'+stHtml+'
' : '') + + table + + '
'; + }); + fg.innerHTML = (d.games||[]).length ? gh : 'No installed game servers.'; + } + }).catch(function(){ var l=document.getElementById('bk-loading'); if(l) l.innerHTML='Could not load backups.'; }); +} +function createBackup(btn){ + if(btn){ btn.disabled=true; } + bkMsg('Creating backup…','text-secondary'); + fetch(MOUNT+'/api/panel/backup',{method:'POST'}).then(r=>r.json()).then(function(d){ + bkMsg((d.success?'✓ ':'✗ ')+(d.message||''), d.success?'text-success':'text-danger'); + if(btn) btn.disabled=false; loadBackups(); + }).catch(function(){ bkMsg('✗ Backup failed','text-danger'); if(btn) btn.disabled=false; }); +} +function onFbAutoToggle(){ + var on=document.getElementById('fb-auto-enabled').checked; + var fi=document.getElementById('fb-interval'); if(fi) fi.disabled=!on; + saveBackupSettings(); +} +function fbSummary(){ + var el=document.getElementById('fb-summary'); if(!el) return; + var on=document.getElementById('fb-auto-enabled').checked; + var keep=parseInt(document.getElementById('fb-keep').value,10)||1; + var dk=window._bkDisk||{free:0,est_cycle:0}; + var cycle=dk.est_cycle||0; // size of one full backup run (all servers) + var free=dk.free||0; + var projected=cycle*keep; // rough space the retained backups will occupy + function fmt(b){ return (typeof bkFmtBytes==='function')?bkFmtBytes(b):(b+' B'); } + var parts=[]; + + if(!on){ + parts.push(' Automatic backups are off — nothing runs on a schedule. ' + +'Use “Back up game servers now” for a one-off; '+(keep===1?'only the latest backup':'the '+keep+' most recent backups') + +' per server '+(keep===1?'is':'are')+' kept.'); + el.innerHTML=parts.join('
'); return; + } + + var days=parseInt(document.getElementById('fb-interval').value,10)||7; + var every=(days===1?'every day':(days===7?'once a week':(days===14?'once every 2 weeks':'once a month'))); + var s=' By default, each server is backed up '+every+'. ' + +'The '+keep+' newest '+(keep===1?'backup is':'backups are')+' kept per server; older ones are deleted automatically. ' + +'(Override per server below.)'; + var multi = !!window._bkMultiHost; + if(cycle>0 && !multi){ s+=' At the current size that\'s up to '+fmt(projected)+' of backups'+(free>0?' ('+fmt(free)+' free now)':'')+'.'; } + parts.push(s); + + if(days===1){ + parts.push(' Daily full backups of game files eat disk fast — keep a low “keep” count unless you have lots of free space.'); + } + if(multi){ + parts.push(' Disk usage is shown per server below (they\'re on different hosts).'); + } + if(cycle>0 && free>0 && !multi){ + if(projected>free){ + parts.push(' This needs more than your free disk ('+fmt(free)+') — backups will fail once it fills up.'); + } else if(projected>free*0.5){ + parts.push(' This would use over half your free disk.'); + } + var recKeep=Math.max(1, Math.floor(free*0.4/cycle)); + if(recKeep Recommended: keep ≤ '+recKeep+' at ~'+fmt(cycle)+' per run (leaves comfortable headroom).'); + } + } else if(cycle===0){ + parts.push('Run a backup once and this will estimate the space each cycle uses and recommend a safe “keep”.'); + } + el.innerHTML=parts.join('
'); +} +function saveBackupSettings(){ + var enabled=document.getElementById('bk-enabled').checked; + var keep=parseInt(document.getElementById('bk-keep').value,10); + // Automatic game backups off → send interval 0 (disabled); on → the chosen interval. + var autoOn=document.getElementById('fb-auto-enabled').checked; + var fi=autoOn ? (parseInt(document.getElementById('fb-interval').value,10)||7) : 0; + var fk=parseInt(document.getElementById('fb-keep').value,10); + fbSummary(); + fetch(MOUNT+'/api/panel/backup/settings',{method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({enabled:enabled,keep_days:keep,full_interval_days:fi,full_keep:fk})}) + .then(r=>r.json()).then(function(){ bkMsg('✓ Saved','text-success'); }).catch(function(){ bkMsg('✗ Could not save','text-danger'); }); +} +function runFullBackup(btn){ + // First check who's online — if any server has players, ask before disconnecting them. + if(btn){ btn.disabled=true; } + bkMsg('Checking for players…','text-secondary'); + fetch(MOUNT+'/api/panel/backup/full/precheck').then(r=>r.json()).then(function(d){ + if(btn) btn.disabled=false; + var busy=(d&&d.busy)||[]; + if(!busy.length){ + bkMsg('',''); + confirmDialog({title:'Back up all servers', icon:'archive', confirmClass:'btn-primary', confirmLabel:'Back up all', + bodyText:'Back up all installed game servers now?\n\nThis runs LinuxGSM\'s backup on each server — any that are running will be briefly STOPPED and restarted for their backup (a short outage each). It can take a while and use disk space.', + onConfirm:function(){ fbStart(''); }}); + return; + } + fbPlayersDialog(busy); + }).catch(function(){ if(btn) btn.disabled=false; bkMsg('✗ Could not check players','text-danger'); }); +} +function fbStart(mode){ + bkMsg('Starting full backup…','text-secondary'); + fetch(MOUNT+'/api/panel/backup/full',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode:mode})}) + .then(r=>r.json()).then(function(d){ + bkMsg((d.success?'✓ ':'✗ ')+(d.message||''), d.success?'text-success':'text-danger'); + [4000,15000,45000,90000,150000].forEach(function(ms){ setTimeout(loadBackups, ms); }); // refresh as it runs + }).catch(function(){ bkMsg('✗ Could not start','text-danger'); }); +} +function fbPlayersDialog(busy){ + var total=busy.reduce(function(s,b){return s+(b.players||0);},0); + var list=busy.map(function(b){return escapeHtml(b.name)+' ('+b.players+')';}).join(', '); + var ov=document.createElement('div'); + ov.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:1080;display:flex;align-items:center;justify-content:center;padding:1rem;'; + ov.innerHTML='
' + +'
Players are online
' + +'
' + +'

'+total+' player'+(total===1?' is':'s are')+' connected to '+busy.length+' server'+(busy.length===1?'':'s')+': '+list+'.

' + +'

A backup briefly stops each server — this is about the busy ones.

' + +'
' + +'' + +'' + +'' + +'
'; + document.body.appendChild(ov); + function close(){ ov.remove(); } + ov.querySelector('#fbd-now').onclick=function(){ close(); fbStart('now'); }; + ov.querySelector('#fbd-wait').onclick=function(){ close(); fbStart('wait'); }; + ov.querySelector('#fbd-cancel').onclick=function(){ close(); bkMsg('',''); }; + ov.addEventListener('click',function(e){ if(e.target===ov){ close(); bkMsg('',''); } }); +} +function backupOneGame(id,btn,force){ + var msg = force + ? 'Back up NOW and disconnect the players who are currently on?\n\nThe server will be briefly STOPPED and restarted for the backup — anyone playing will be kicked.' + : 'Back up this game server now?\n\nLinuxGSM archives its files into ~/lgsm/backup. If players are on, the backup will WAIT (it won\'t kick them). If the server is empty it will be briefly STOPPED and restarted for the backup, and the archive can be large.'; + confirmDialog({title:'Back up game server', icon:'archive', confirmClass: force?'btn-warning':'btn-primary', confirmLabel:'Back up', + bodyText: msg, onConfirm:function(){ _backupOneGame(id, btn, force); }}); +} +function _backupOneGame(id,btn,force){ + // Immediate feedback right where the user clicked: spinner on the button. + var orig = btn ? btn.innerHTML : ''; + if(btn){ btn.disabled=true; btn.innerHTML=' Backing up…'; } + function resetBtn(){ if(btn){ btn.disabled=false; btn.innerHTML=orig || ' Back up now'; } } + if(window.toast) toast('Starting backup…','info'); + bkMsg('Starting backup…','text-secondary'); + fetch(MOUNT+'/api/panel/backup/game/'+id,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({force:!!force})}).then(r=>r.json()).then(function(d){ + bkMsg((d.success?'✓ ':'✗ ')+(d.message||''), d.success?'text-success':'text-danger'); + // Floating toast so the result is visible even when scrolled down to this server's row. + if(window.toast) toast((d.success?'✓ ':'✗ ')+(d.message||(d.success?'Backup started':'Could not start backup')), d.success?'success':'danger'); + if(!d.success){ resetBtn(); return; } + // Refresh soon (so the row's "backing up…" status appears), then keep checking as it runs. + [600,3000,8000,20000,40000,70000,120000].forEach(function(ms){ setTimeout(loadBackups, ms); }); + }).catch(function(){ bkMsg('✗ Could not start','text-danger'); if(window.toast) toast('✗ Could not start backup — connection error','danger'); resetBtn(); }); +} +function schedNote(sc){ + var iv=sc.interval_days, kp=sc.keep; + var base=(sc.interval_set||sc.keep_set) ? 'Custom — ' : 'Using default — '; + if(iv<=0) return base+'no automatic backups.'; + var every=(iv===1?'daily':(iv===7?'weekly':(iv===14?'every 2 weeks':(iv===30?'monthly':'every '+iv+' days')))); + var nextTxt=''; + if(sc.last){ var d=Math.round((sc.last+iv*86400-Date.now()/1000)/86400); nextTxt=' · next '+(d<=0?'due now':'in ~'+d+'d'); } + return base+every+', keep '+kp+nextTxt+'.'; +} +function gameSchedule(g){ + var sc=g.schedule||{interval_days:0,keep:2,interval_set:false,keep_set:false,last:0}; + var ivVal=sc.interval_set?String(sc.interval_days):'default'; + var kpVal=sc.keep_set?String(sc.keep):'default'; + function opt(v,label,cur){ return ''; } + var ivSel=''; + var kpSel=''; + return '
' + +' Schedule:'+ivSel + +'Keep'+kpSel + +''+schedNote(sc)+'
'; +} +function setGameSchedule(id){ + var iv=document.getElementById('gsi-'+id).value, kp=document.getElementById('gsk-'+id).value; + bkMsg('Saving schedule…','text-secondary'); + fetch(MOUNT+'/api/panel/backup/game/'+id+'/schedule',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({interval:iv,keep:kp})}) + .then(r=>r.json()).then(function(d){ + bkMsg(d.success?'✓ Schedule saved':'✗ Save failed', d.success?'text-success':'text-danger'); + if(d.success&&d.schedule){ var n=document.getElementById('gsn-'+id); if(n) n.innerHTML=schedNote(d.schedule); } + }).catch(function(){ bkMsg('✗ Could not save schedule','text-danger'); }); +} +function deleteGameBackup(btn){ + var name=btn.getAttribute('data-name'), gid=btn.getAttribute('data-gid'); + confirmDialog({title:'Delete backup', icon:'trash', confirmClass:'btn-danger', confirmLabel:'Delete', + bodyText:'Delete this game-server backup?\n\n'+name, + onConfirm:function(){ + btn.disabled=true; btn.innerHTML=''; + bkMsg('Deleting…','text-secondary'); + fetch(MOUNT+'/api/panel/backup/game/'+gid+'/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:name})}) + .then(r=>r.json()).then(function(d){ + bkMsg((d.success?'✓ ':'✗ ')+(d.message||''), d.success?'text-success':'text-danger'); + if(window.toast) toast((d.success?'✓ ':'✗ ')+(d.message||(d.success?'Backup deleted':'Delete failed')), d.success?'success':'danger'); + loadBackups(); + }).catch(function(){ bkMsg('✗ Delete failed','text-danger'); if(window.toast) toast('✗ Delete failed — connection error','danger'); btn.disabled=false; btn.innerHTML=''; }); + }}); +} +function deleteBackup(name,btn){ + confirmDialog({title:'Delete backup', icon:'trash', confirmClass:'btn-danger', confirmLabel:'Delete', + bodyText:'Delete this backup?\n\n'+name, + onConfirm:function(){ + if(btn) btn.disabled=true; + fetch(MOUNT+'/api/panel/backup/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:name})}) + .then(r=>r.json()).then(function(d){ bkMsg((d.success?'✓ ':'✗ ')+(d.message||''), d.success?'text-success':'text-danger'); loadBackups(); }) + .catch(function(){ bkMsg('✗ Delete failed','text-danger'); if(btn) btn.disabled=false; }); + }}); +} +function restoreBackup(name,btn){ + confirmDialog({title:'Restore backup', icon:'arrow-counterclockwise', confirmClass:'btn-danger', confirmLabel:'Restore', + bodyText:'Restore this backup?\n\n'+name+'\n\nThis OVERWRITES the panel\'s current database, settings and keys, then restarts the panel. A pre-restore safety backup is taken first.', + onConfirm:function(){ + if(btn) btn.disabled=true; + bkMsg('Restoring…','text-secondary'); + fetch(MOUNT+'/api/panel/backup/restore',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:name})}) + .then(r=>r.json()).then(function(d){ + bkMsg((d.success?'✓ ':'✗ ')+(d.message||''), d.success?'text-success':'text-danger'); + if(d.success){ setTimeout(function(){ location.reload(); }, 8000); } else if(btn){ btn.disabled=false; } + }) + .catch(function(){ bkMsg('The panel is restarting — reconnect in a moment.','text-warning'); }); + }}); +} + +// Uninstall a game server from the list: in-app confirm + type-the-username gate, then submit the +// form (a full POST — this admin page reloads to show the server gone). Delegated so it survives +// any list re-render. +document.addEventListener('click', function(e){ + var b = e.target.closest && e.target.closest('.uninstall-trigger'); + if(!b) return; + var form = b.closest('form'); if(!form) return; + var name = form.getAttribute('data-server-name') || ''; + var short = form.getAttribute('data-server-short') || ''; + confirmDialog({ + title:'Uninstall server', icon:'trash', confirmClass:'btn-danger', confirmLabel:'Uninstall', + body:'Uninstall '+escapeHtml(name)+'? This permanently deletes the server, all its files, AND every backup it has — this cannot be undone.', + requireText: short, + requireLabel:'Type the server’s username ('+short+') to confirm:', + onConfirm:function(){ form.submit(); } + }); +}); + +loadSshStatus(); // public-SSH controls now exist on both local + remote (no-ops if absent) +if(IS_LOCAL){ checkPanelUpdate(false); loadPanelBranches(); loadIntegrity(); loadDbStats(); loadAutoUpd(); loadBackups(); } // panel-only cards +pollLive(); +pollWhenVisible(pollLive, 2000); + +// ── Discover + import existing LinuxGSM servers on this host ────────── +function scanExisting(){ + var btn=document.getElementById('disc-scan-btn'), out=document.getElementById('disc-result'); + if(btn){ btn.disabled=true; btn.innerHTML=' Scanning…'; } + if(out) out.innerHTML=' Scanning every user account on this host…'; + fetch(MOUNT+'/api/remote/'+REMOTE_ID+'/discover').then(function(r){return r.json();}).then(function(d){ + if(!out) return; + if(d.error){ out.innerHTML=''+escapeHtml(d.error)+''; return; } + var s=d.servers||[]; + if(!s.length){ out.innerHTML=' No new LinuxGSM servers found — anything already in the panel is skipped.'; return; } + var rows=s.map(function(g){ + return '' + +'' + +''+escapeHtml(g.user)+(g.autostart?' autostart':'')+'' + +''+escapeHtml(g.game_name||g.game_type)+'' + +''+(g.port||'—')+'' + +''+(g.backups||0)+'' + +''+(g.mods||0)+'' + +''+(g.cron||0)+''; + }).join(''); + out.innerHTML='
' + +'' + +''+rows+'
UserGamePortBackupsModsCron
' + +' '; + }).catch(function(){ if(out) out.innerHTML='Scan failed.'; }) + .finally(function(){ if(btn){ btn.disabled=false; btn.innerHTML=' Scan'; } }); +} +function discToggleAll(cb){ document.querySelectorAll('.disc-chk').forEach(function(c){ c.checked=cb.checked; }); } +function importExisting(btn){ + var picks=[], msg=document.getElementById('disc-msg'); + document.querySelectorAll('.disc-chk:checked').forEach(function(c){ + picks.push({user:c.getAttribute('data-user'), game_type:c.getAttribute('data-game'), + port:parseInt(c.getAttribute('data-port'),10)||0, + autostart:c.getAttribute('data-autostart')==='1'}); + }); + if(!picks.length){ if(msg) msg.innerHTML='Select at least one.'; return; } + btn.disabled=true; btn.innerHTML=' Importing…'; + fetch(MOUNT+'/api/remote/'+REMOTE_ID+'/import',{method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({servers:picks})}) + .then(function(r){return r.json();}).then(function(d){ + var n=(d.added||[]).length; + if(n){ window.toast('Imported '+n+' server'+(n===1?'':'s')+'.', 'success'); + if(msg) msg.innerHTML='Imported '+n+' — see the Game Servers list.'; + btn.disabled=false; btn.innerHTML=' Import selected'; + window.refreshSection('#host-servers-card'); } // show the new rows in place, no reload + else { if(msg) msg.innerHTML=''+escapeHtml(d.message||'Nothing imported.')+''; + btn.disabled=false; btn.innerHTML=' Import selected'; } + }).catch(function(){ if(msg) msg.innerHTML='Import failed.'; + btn.disabled=false; btn.innerHTML=' Import selected'; }); +} diff --git a/static/js/server_detail.js b/static/js/server_detail.js new file mode 100644 index 0000000..f41b3bf --- /dev/null +++ b/static/js/server_detail.js @@ -0,0 +1,887 @@ +// ── Section tabs (Console / Details): show only the selected group's cards. Actions and the +// pending banner are untagged, so they stay visible on both. The chart + console live in the +// default Console tab, so they initialise visible (no hidden-canvas sizing issues). ── +(function(){ + var nav = document.getElementById('sdtab-nav'); if(!nav) return; + var TABS = ['console','history','details']; + function show(tab){ + window._sdTab = tab; + document.querySelectorAll('[data-mtab]').forEach(function(el){ + el.style.display = (el.getAttribute('data-mtab') === tab) ? '' : 'none'; + }); + nav.querySelectorAll('[data-mtab-btn]').forEach(function(b){ + b.classList.toggle('active', b.getAttribute('data-mtab-btn') === tab); + }); + try { history.replaceState(null, '', '#' + tab); } catch(e){} + // The players card is a Console-tab card whose visibility is ALSO driven by the poll — re-apply + // it so a background refresh can't leave it showing on History/Details. + if (window.applyPlayersVisibility) window.applyPlayersVisibility(); + if (tab === 'history' && window.loadHistory) window.loadHistory(); // lazy-load the trend charts + } + nav.addEventListener('click', function(e){ + var b = e.target.closest('[data-mtab-btn]'); if(b) show(b.getAttribute('data-mtab-btn')); + }); + var h = (location.hash || '').replace('#',''); + show(TABS.indexOf(h) >= 0 ? h : 'console'); +})(); + +var consoleEl = document.getElementById('console-output'); +var wsStatus = document.getElementById('ws-status'); + +// ── Players + in-game moderation ────────────────────────────── +function plTime(t){ + var n = Number(t); if(!isFinite(n)) return String(t); + n = Math.floor(n); var h=Math.floor(n/3600), m=Math.floor((n%3600)/60), s=n%60; + return h>0 ? (h+':'+String(m).padStart(2,'0')) : (m+':'+String(s).padStart(2,'0')); +} +// The automatic poll asks gamedig only (never the console). refreshPlayers() (the button, and the +// re-read after a kick/ban) passes console=1 so a single `status` runs on your explicit action. +function loadPlayers(useConsole){ + if(!document.getElementById('players-card')) return; + var url = MOUNT+'/api/server/'+serverId+'/playerlist' + (useConsole ? '?console=1' : ''); + fetch(url).then(function(r){return r.json();}) + .then(renderPlayers).catch(function(){ /* transient: keep what's shown */ }); +} +function refreshPlayers(){ loadPlayers(true); } +// The players card belongs to the Console tab AND is content-driven (moderators always see it; others +// only when the server is queryable). Keep those two concerns from fighting: renderPlayers records +// whether the content WANTS the card, and applyPlayersVisibility() combines that with the active tab. +window.applyPlayersVisibility = function(){ + var card=document.getElementById('players-card'); if(!card) return; + card.style.display = (window._sdTab==='console' && window._playersWanted) ? '' : 'none'; +}; +function renderPlayers(d){ + var card=document.getElementById('players-card'); if(!card||!d) return; + var caps=d.caps||{}, players=d.players||[], queryable=!!d.queryable; + // Moderators see the card whenever there's something to do (a list, or the announce box); + // everyone else only when there's a list or announce to show — but only on the Console tab. + window._playersWanted = (_CAN_MODERATE || queryable); + window.applyPlayersVisibility(); + if(card.style.display==='none') return; + var engine=d.engine||''; + window._plEngine = engine; // the ban dialog uses this to decide if "all servers" can apply + var unsup=document.getElementById('pl-unsupported'), empty=document.getElementById('pl-empty'), + wrap=document.getElementById('pl-table-wrap'), ann=document.getElementById('pl-announce'), + cnt=document.getElementById('pl-count'), netonly=document.getElementById('pl-netonly'); + if(ann) ann.style.display = (_CAN_SAY && caps.say) ? 'flex' : 'none'; + if(netonly) netonly.style.display='none'; + if(!queryable){ if(unsup)unsup.style.display=''; if(empty)empty.style.display='none'; if(wrap)wrap.style.display='none'; if(cnt)cnt.textContent=''; return; } + if(unsup) unsup.style.display='none'; + // gamedig couldn't read the server and the console wasn't run: show the GSLT / load-once hint for + // a console-capable game, rather than a misleading "no players connected". But if a list is already + // on screen (e.g. you just loaded it from the console), keep it — an automatic gamedig miss must + // not blank what you explicitly pulled. + if(d.unknown){ + var hasRows = wrap && wrap.style.display !== 'none'; + if(!hasRows){ + if(netonly && d.console_capable){ netonly.style.display=''; } + else if(unsup){ unsup.style.display=''; } + if(empty)empty.style.display='none'; + } + return; + } + if(cnt) cnt.textContent='('+players.length+')'; + if(!players.length){ if(empty)empty.style.display=''; if(wrap)wrap.style.display='none'; return; } + if(empty) empty.style.display='none'; if(wrap) wrap.style.display=''; + var rows=players.map(function(p){ + var acts=''; + if(_CAN_MODERATE){ + var sid=p.steamid||'', num=(p.num!=null?String(p.num):''); + var da='data-name="'+escapeHtml(p.name)+'" data-steamid="'+escapeHtml(sid)+'" data-num="'+escapeHtml(num)+'"'; + if(_CAN_KICK && caps.kick) acts+=''; + if(_CAN_BAN && caps.ban){ + // gamedig lists carry no SteamID (valve) / slot (idTech3), but the backend resolves it from + // the console when Ban is clicked — so keep the button enabled. A player who genuinely can't + // be resolved (a bot, or someone who just left) comes back as a clear error toast. + acts+=''; + } + } + return ''+escapeHtml(p.name)+'' + +''+(p.score!=null?escapeHtml(String(p.score)):'—')+'' + +''+(p.time!=null?escapeHtml(plTime(p.time)):'—')+'' + +(_CAN_MODERATE?(''+acts+''):'')+''; + }).join(''); + document.getElementById('pl-rows').innerHTML=rows; +} +function moderatePlayer(btn, action){ + var name=btn.getAttribute('data-name')||''; + var steamid=btn.getAttribute('data-steamid')||''; + var num=btn.getAttribute('data-num')||''; + if(action==='ban'){ banScopeDialog(btn, name, steamid, num); return; } // ban asks scope first + _doModerate(btn, action, name, steamid, num, 'this'); +} +function _doModerate(btn, action, name, steamid, num, scope, reason){ + btn.disabled=true; + fetch(MOUNT+'/api/server/'+serverId+'/moderate',{method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({action:action, target:name, steamid:steamid, num:num, scope:scope, reason:reason||''})}) + .then(function(r){return r.json();}).then(function(d){ + window.toast(d.message || (d.success?'Done':'Failed'), d.success?'success':'danger'); + setTimeout(function(){ loadPlayers(true); }, 1200); // you just moderated — re-read (console ok) + }).catch(function(){ window.toast('Moderation failed','danger'); btn.disabled=false; }); +} +// When banning, ASK: this server only, or all my servers. "All servers" is only offered where the +// identifier ports across servers (SteamID on Valve, name on Minecraft); idTech3 slot bans can't. +function banScopeDialog(btn, name, steamid, num){ + if(window._plEngine!=='valve' && window._plEngine!=='minecraft'){ + confirmDialog({ + title: 'Ban player', icon: 'slash-circle', confirmLabel: 'Ban', confirmClass: 'btn-danger', + body: 'Ban ' + _esc(name) + ' from ' + _esc(SERVER_NAME) + '?', + onConfirm: function(){ _doModerate(btn,'ban',name,steamid,num,'this'); } + }); + return; + } + var ov=document.createElement('div'); + ov.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:1080;display:flex;align-items:center;justify-content:center;padding:1rem;'; + ov.innerHTML='
' + +'
Ban '+escapeHtml(name)+'
' + +'

Ban just on this server, or on ' + +'all your servers that can match this player (same SteamID / name)?

' + +'' + +'
' + +'' + +'' + +'' + +'
'; + ov.addEventListener('click', function(ev){ + if(ev.target===ov){ ov.remove(); return; } // click the backdrop = cancel + var b=ev.target.closest && ev.target.closest('button'); + if(!b) return; + var scope=b.getAttribute('data-scope'); + var reason=(ov.querySelector('#ban-reason')||{}).value||''; + ov.remove(); + if(scope==='all'||scope==='this') _doModerate(btn,'ban',name,steamid,num,scope,reason); + }); + document.body.appendChild(ov); +} +function announceSay(){ + var inp=document.getElementById('pl-say'); if(!inp) return; + var msg=(inp.value||'').trim(); if(!msg) return; + fetch(MOUNT+'/api/server/'+serverId+'/moderate',{method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({action:'say', message:msg})}) + .then(function(r){return r.json();}).then(function(d){ + window.toast(d.message || (d.success?'Announced':'Failed'), d.success?'success':'danger'); + if(d.success) inp.value=''; + }).catch(function(){ window.toast('Announce failed','danger'); }); +} + +// ── Custom commands (superadmin-defined, handed to this user's group) ────────── +// Delegated so it also covers the argument-input Enter key. The server re-checks every run +// (group assignment + scope + argument validation) — the button list is only a convenience. +function runCustomCommand(wrap, btn){ + var cmdId = wrap.getAttribute('data-cmd-id'); + var hasArg = wrap.getAttribute('data-has-arg')==='1'; + var value = ''; + if(hasArg){ + var inp = wrap.querySelector('.cc-arg'); + value = inp ? (inp.value||'').trim() : ''; + if(!value){ if(inp) inp.focus(); return; } + } + var orig = btn.innerHTML; btn.disabled = true; + btn.innerHTML = ''; + fetch(MOUNT+'/api/server/'+serverId+'/custom-command/'+cmdId, { + method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({value: value}) + }).then(function(r){return r.json();}).then(function(d){ + window.toast(d.message || (d.success?'Done':'Failed'), d.success?'success':'danger'); + }).catch(function(){ window.toast('Command failed','danger'); }) + .finally(function(){ btn.disabled = false; btn.innerHTML = orig; }); +} +document.addEventListener('click', function(e){ + var btn = e.target.closest && e.target.closest('.cc-run'); if(!btn) return; + var wrap = btn.closest('.custom-cmd'); if(wrap) runCustomCommand(wrap, btn); +}); +document.addEventListener('keydown', function(e){ + if(e.key!=='Enter') return; + var inp = e.target.closest && e.target.closest('.cc-arg'); if(!inp) return; + e.preventDefault(); + var wrap = inp.closest('.custom-cmd'); var btn = wrap && wrap.querySelector('.cc-run'); + if(wrap && btn) runCustomCommand(wrap, btn); +}); +loadPlayers(); +if(window.pollWhenVisible) pollWhenVisible(loadPlayers, 15000); + +// "Follow the tail" only while the user is already at the bottom. If they've +// scrolled up to read history, new output must NOT yank them back down. +function consoleAtBottom() { + return consoleEl.scrollHeight - consoleEl.scrollTop - consoleEl.clientHeight < 48; +} +function stickConsole() { consoleEl.scrollTop = consoleEl.scrollHeight; } + +// Mount prefix (e.g. "/lgsm" when served behind Tailscale Serve). All client-side +// URLs must include it, otherwise requests hit the site root (a different app). + +// WebSocket connection for live console streaming. Use the shared window.socket so the +// base-layout pagehide/pageshow handlers close it for bfcache and reconnect it on return — +// the 'connect' handler below re-joins the console room automatically after a reconnect. +var socket = (window.ensureSocket && window.ensureSocket()) + || io({ path: MOUNT + '/socket.io', transports: ['websocket', 'polling'] }); + +socket.on('connect', function() { + wsStatus.textContent = '(connected)'; + wsStatus.className = 'text-success small ms-2'; + socket.emit('join_console', { server_id: serverId }); +}); + +socket.on('disconnect', function() { + wsStatus.textContent = '(disconnected)'; + wsStatus.className = 'text-danger small ms-2'; +}); + +socket.on('console_output', function(data) { + if (data.server_id === serverId && data.data) { + appendConsole(data.data); + } +}); + +function appendConsole(text) { + var stick = consoleAtBottom(); // capture BEFORE appending + var lines = text.split('\n'); + for (var i = 0; i < lines.length; i++) { + if (lines[i].trim()) { + var div = document.createElement('div'); + div.className = 'console-line'; + div.textContent = lines[i]; + consoleEl.appendChild(div); + } + } + // Keep console from growing too large + while (consoleEl.children.length > 500) { + consoleEl.removeChild(consoleEl.firstChild); + } + if (stick) stickConsole(); // only auto-follow if they were at the bottom +} + +var _consoleSig = null; +function refreshConsole(forceScroll) { + // The periodic backup refresh wipes + rebuilds the console; don't yank the user + // to the bottom unless they were already there (or it's the initial load). + var stick = forceScroll || consoleAtBottom(); + fetch(MOUNT + '/api/console/' + serverId) + .then(r => r.json()) + .then(data => { + var lines = (data.lines || []).filter(function(l) { return l.trim(); }); + // Only rebuild when the log actually changed — otherwise leave the console exactly as + // it is (no 30s wipe-and-rebuild flicker while a server sits idle). + var sig = lines.length + ' ' + (lines[lines.length - 1] || ''); + if (sig === _consoleSig && !forceScroll) return; + _consoleSig = sig; + consoleEl.innerHTML = ''; + lines.forEach(function(line) { + var div = document.createElement('div'); + div.className = 'console-line'; + div.textContent = line; + consoleEl.appendChild(div); + }); + if (stick) stickConsole(); + }) + .catch(() => {}); +} + +function clearConsole() { + consoleEl.innerHTML = ''; +} + +function sendCommand(ev) { + ev.preventDefault(); + var input = document.getElementById('command-input'); + var cmd = input.value.trim(); + if (!cmd) return false; + input.value = ''; + // Echo locally so it feels instant; the real output streams over the websocket. + var echo = document.createElement('div'); + echo.className = 'console-line'; + echo.style.color = '#58a6ff'; + echo.textContent = '> ' + cmd; + consoleEl.appendChild(echo); + consoleEl.scrollTop = consoleEl.scrollHeight; + fetch(MOUNT + '/api/command/' + serverId, { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ command: cmd }) + }) + .then(r => r.json()) + .then(d => { if (d.error) { echo.style.color = '#f85149'; echo.textContent = '> ' + cmd + ' — ' + d.error; } }) + .catch(() => { echo.style.color = '#f85149'; }); + input.focus(); + return false; +} + +// Uses the global window.toast (base.html) — one implementation, consistent independent timing. + +// Delegates — the old fallback returned the raw string, and String(null) rendered "null". +function _esc(s){ return window.escapeHtml(s); } + +// Fire a server action against the JSON endpoint (spinner + toast + banner handling). +function _doServerAction(action, btn, showOutput) { + var orig = btn.innerHTML; + btn.disabled = true; btn.innerHTML = ''; + fetch(MOUNT + '/api/server/' + serverId + '/action', { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ action: action }) + }) + .then(r => r.json()) + .then(d => { + // Maintenance/info actions (details, postdetails, monitor, …) return text you want to READ — + // show it in a dismissible panel, not a toast that disappears before you see it. + if (showOutput) showActionOutput(action, d.message || (d.success ? 'Done — no output' : 'Failed'), d.success); + else toast(d.message || (action + ' done'), d.success ? 'success' : 'danger'); + if (d.success && (action === 'restart' || action === 'start' || action === 'stop')) { + var b = document.getElementById('restart-pending-banner'); + if (b) b.classList.add('d-none'); + } + setTimeout(pollStats, 1200); + }) + .catch(() => toast('Action failed — connection error', 'danger')) + .finally(() => { btn.disabled = false; btn.innerHTML = orig; }); +} +// Dismissible panel showing a command's full output (for maintenance/info actions). Persists until +// you close it (button / backdrop / Esc), so you can read long output like `details`. +function showActionOutput(title, text, ok) { + var ov = document.createElement('div'); + ov.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:1090;display:flex;align-items:center;justify-content:center;padding:1rem;'; + ov.innerHTML = '
' + + '
' + + ' ' + escapeHtml(title) + '' + + '
' + + '
' + + '
' + escapeHtml(text) + '
' + + '
'; + function close(){ ov.remove(); document.removeEventListener('keydown', onEsc); } + function onEsc(e){ if (e.key === 'Escape') close(); } + ov.addEventListener('click', function(ev){ + if (ev.target === ov || (ev.target.closest && ev.target.closest('[data-close]'))) close(); + }); + document.addEventListener('keydown', onEsc); + document.body.appendChild(ov); +} + +function serverAction(action, btn, confirmFirst, showOutput) { + // Restart AND stop get a player check first — warn, and offer "when empty". + if (action === 'restart' || action === 'stop') { return actionWithPlayerCheck(action, btn); } + if (confirmFirst) { + var body = _esc(_cap(action)) + ' ' + _esc(SERVER_NAME) + '?'; + if (action === 'fastdl') { + body = 'Generate FastDL files for ' + _esc(SERVER_NAME) + '? ' + + 'This overwrites the existing FastDL directory and can take a while.'; + } + confirmDialog({ title: _cap(action), body: body, confirmLabel: _cap(action), + onConfirm: function(){ _doServerAction(action, btn, showOutput); } }); + return; + } + _doServerAction(action, btn, showOutput); +} + +function _cap(s){ return s.charAt(0).toUpperCase() + s.slice(1); } + +// confirmDialog() is a shared global defined in base.html (window.confirmDialog). + +// Restart/Stop confirm for an EMPTY server (or when the player count is unknown) — the +// in-app equivalent of the old native confirm, matching the players-online dialog's look. +function confirmActionDialog(action, btn, note){ + confirmDialog({ + title: _cap(action) + ' server', + icon: action === 'stop' ? 'stop-fill' : 'arrow-clockwise', + body: _esc(_cap(action)) + ' ' + _esc(SERVER_NAME) + '?' + + (note ? ' ' + _esc(note) + '' : ''), + confirmLabel: _cap(action), + confirmClass: action === 'stop' ? 'btn-danger' : 'btn-warning', + onConfirm: function(){ _doServerAction(action, btn); } + }); +} + +function actionWithPlayerCheck(action, btn) { + var orig = btn.innerHTML; + btn.disabled = true; btn.innerHTML = ''; + fetch(MOUNT + '/api/server/' + serverId + '/players').then(r => r.json()).then(function(d){ + btn.disabled = false; btn.innerHTML = orig; + var n = (d && d.players) || 0; // null/unknown -> treat as none (show a plain in-app confirm) + if (!n) { confirmActionDialog(action, btn); return; } + actionPlayersDialog(action, n, btn); + }).catch(function(){ + btn.disabled = false; btn.innerHTML = orig; + confirmActionDialog(action, btn, "(couldn't check who's online)"); + }); +} + +function actionPlayersDialog(action, n, btn) { + var verb = _cap(action); // Restart / Stop + var lower = action; // restart / stop + var endpoint = action === 'stop' ? 'stop-when-empty' : 'restart-when-empty'; + var nowIcon = action === 'stop' ? 'stop-fill' : 'arrow-clockwise'; + var ov = document.createElement('div'); + ov.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:1080;display:flex;align-items:center;justify-content:center;padding:1rem;'; + ov.innerHTML = '
' + + '
Players are online
' + + '
' + + '

' + n + ' player' + (n===1?' is':'s are') + ' connected to ' + _esc(SERVER_NAME) + '. ' + verb + 'ping now disconnects ' + (n===1?'them':'everyone') + '.

' + + '

Are you sure you want to ' + lower + ' with players on?

' + + '
' + + '' + + '' + + '' + + '
'; + document.body.appendChild(ov); + function close(){ ov.remove(); } + ov.querySelector('#rd-now').onclick = function(){ close(); _doServerAction(action, btn); }; + ov.querySelector('#rd-wait').onclick = function(){ + close(); + fetch(MOUNT + '/api/server/' + serverId + '/' + endpoint, {method:'POST', headers:{'Content-Type':'application/json'}}) + .then(r => r.json()).then(function(d){ + toast(d.message || ('Queued — will ' + lower + ' once empty.'), d.success ? 'success' : 'danger'); + if (d.success) { showPendingBanner(action); } + }).catch(function(){ toast('Could not queue the ' + lower, 'danger'); }); + }; + ov.querySelector('#rd-cancel').onclick = close; + ov.addEventListener('click', function(e){ if (e.target === ov) close(); }); +} + +// Reflect a queued 'when empty' action (restart|stop) in the banner and show it. +function showPendingBanner(action){ + var b = document.getElementById('restart-pending-banner'); + if (!b) return; + b.dataset.action = action; + var v = document.getElementById('rpb-verb'); if (v) v.textContent = action; + var bt = document.getElementById('rpb-btn'); if (bt) bt.textContent = _cap(action) + ' now'; + b.classList.remove('d-none'); +} + +// The banner's "do it now" button — runs whichever action is queued (restart|stop). +function bannerDoNow(btn){ + var b = document.getElementById('restart-pending-banner'); + serverAction((b && b.dataset.action) || 'restart', btn); +} + +function toggleAutostart(el) { + el.disabled = true; + fetch(MOUNT + '/api/server/' + serverId + '/autostart', { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ enabled: el.checked }) + }) + .then(r => r.json()) + .then(d => { if (!d.success) { el.checked = !el.checked; if(window.toast) toast(d.message || 'Failed to update autostart', 'danger'); } }) + .catch(() => { el.checked = !el.checked; }) + .finally(() => { el.disabled = false; }); +} + +function toggleDailyRestart(el) { + el.disabled = true; + fetch(MOUNT + '/api/server/' + serverId + '/daily-restart', { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ enabled: el.checked }) + }) + .then(r => r.json()) + .then(d => { + if (!d.success) { el.checked = !el.checked; if(window.toast) toast(d.message || 'Failed to update daily restart', 'danger'); } + else if (typeof toast === 'function') { toast(el.checked ? 'Daily restart (when empty) enabled' : 'Daily restart disabled', 'success'); } + }) + .catch(() => { el.checked = !el.checked; }) + .finally(() => { el.disabled = false; }); +} + +function toggleNotifyEmpty(el) { + el.disabled = true; + fetch(MOUNT + '/api/server/' + serverId + '/notify-empty', { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ enabled: el.checked }) + }) + .then(r => r.json()) + .then(d => { + if (!d.success) { el.checked = !el.checked; if(window.toast) toast(d.message || 'Failed to update', 'danger'); } + else if (typeof toast === 'function') { + toast(el.checked ? "You'll be alerted once this server is empty (then it turns itself off)" + : 'Empty alert cancelled', 'success'); + } + }) + .catch(() => { el.checked = !el.checked; }) + .finally(() => { el.disabled = false; }); +} + +// ── Live stats + chart ─────────────────────────────────────── +var connectAddr = ''; +function copyConnect() { + var addr = connectAddr || (document.getElementById('connect-addr') || {}).textContent || ''; + addr = addr.trim(); + if (addr && addr !== 'resolving…' && addr !== 'unknown') window.copyText(addr, 'Copied ' + addr); +} +// Sidebar "Connect" code cell — click anywhere on it to copy ip:port. +document.addEventListener('click', function(ev){ + var el = ev.target.closest('.copy-addr'); if(!el) return; + window.copyText(el.getAttribute('data-copy'), 'Copied ' + el.getAttribute('data-copy')); +}); +function fmtGB(b) { return (b / 1073741824).toFixed(1) + ' GB'; } +function fmtUptime(s) { + var d = Math.floor(s/86400), h = Math.floor(s%86400/3600), m = Math.floor(s%3600/60); + return d ? d+'d '+h+'h' : (h ? h+'h '+m+'m' : m+'m'); +} +function setStatus(status) { + var badge = document.getElementById('status-badge'), text = document.getElementById('status-text'); + if (!text) return; + text.textContent = status.charAt(0).toUpperCase() + status.slice(1); + badge.style.background = status === 'online' ? '#3fb950' : (status === 'offline' ? '#f85149' : '#d29922'); +} + +var statsChart = null; +function initChart() { + if (typeof Chart === 'undefined') return; + // The canvas lives inside the Controls panel, which a user can now HIDE. Without this guard the + // TypeError aborts the rest of this inline script — including the handlers that undo a hide, so + // hiding Controls would leave no way to bring it back. + var canvas = document.getElementById('stats-chart'); + if (!canvas) return; + var ctx = canvas.getContext('2d'); + var mk = function(label, color) { + return { label: label, data: [], borderColor: color, backgroundColor: color+'22', + fill: true, tension: .35, pointRadius: 0, borderWidth: 2 }; + }; + statsChart = new Chart(ctx, { + type: 'line', + data: { labels: [], datasets: [ mk('Game CPU %', '#3fb950'), mk('Server CPU %', '#58a6ff') ] }, + options: { + responsive: true, maintainAspectRatio: false, animation: false, + interaction: { intersect: false, mode: 'index' }, + scales: { + y: { min: 0, max: 100, ticks: { color: '#8b98a5', maxTicksLimit: 5 }, grid: { color: 'rgba(255,255,255,.05)' } }, + x: { ticks: { color: '#8b98a5', maxTicksLimit: 6, maxRotation: 0 }, grid: { display: false } } + }, + plugins: { legend: { labels: { color: '#c9d1d9', boxWidth: 12, boxHeight: 12 } } } + } + }); +} + +// Self-scheduling stats poll. A live server gets a snappy refresh so the CPU/RAM graph moves; +// an offline/unreachable one barely changes, so we poll it lazily instead of SSH-ing every few +// seconds. Paused entirely while the tab is hidden (see the visibilitychange catch-up below). +var _lastStatus = ''; +var _statsTimer = null; +function _scheduleStats() { + if (_statsTimer) { clearTimeout(_statsTimer); } + var delay = (_lastStatus === 'online') ? 8000 : 20000; + _statsTimer = setTimeout(pollStats, delay); +} +function pollStats() { + if (document.hidden) { _scheduleStats(); return; } // don't poll a backgrounded tab; re-check later + fetch(MOUNT + '/api/server/' + serverId + '/stats') + .then(r => r.json()) + .then(d => { + if (d.error) return; + _lastStatus = d.status || ''; + connectAddr = d.connect || ''; + document.getElementById('connect-addr').textContent = connectAddr || 'unknown'; + // One-click join link (steam://connect/…) for games that support it. + var join = document.getElementById('connect-join'); + if (join) { + if (d.connect_url) { join.href = d.connect_url; join.style.display = ''; } + else { join.removeAttribute('href'); join.style.display = 'none'; } + } + setStatus(d.status); + var m = d.metrics || {}; + // Game-specific tiles + document.getElementById('stat-gcpu').textContent = (m.game_cpu_percent!=null? m.game_cpu_percent : '–') + '%'; + document.getElementById('stat-gcpu-sub').textContent = 'of ' + (m.cores||1) + '-core server'; + document.getElementById('stat-gram').textContent = (m.game_ram_mb||0) + ' MB'; + document.getElementById('stat-gram-sub').textContent = (m.game_ram_percent!=null? m.game_ram_percent+'% of RAM' : (m.game_procs||0)+' procs'); + document.getElementById('stat-gup').textContent = m.game_procs ? fmtUptime(m.game_uptime_secs||0) : 'stopped'; + document.getElementById('stat-gup-sub').textContent = (m.game_procs||0) + ' process' + ((m.game_procs===1)?'':'es'); + // Whole-server tile + document.getElementById('stat-scpu').textContent = (m.cpu_percent!=null? m.cpu_percent : '–') + '%'; + document.getElementById('stat-server-sub').textContent = 'RAM ' + (m.ram_percent||0) + '% · disk ' + (m.disk_percent||0) + '%'; + if (statsChart) { + var t = new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit', second:'2-digit'}); + var L = statsChart.data.labels, A = statsChart.data.datasets[0].data, B = statsChart.data.datasets[1].data; + L.push(t); A.push(m.game_cpu_percent||0); B.push(m.cpu_percent||0); + if (L.length > 45) { L.shift(); A.shift(); B.shift(); } + statsChart.update('none'); + } + }) + .catch(() => {}) + .finally(_scheduleStats); +} + +// ── History charts (persisted CPU/RAM/player trends, lazy-loaded when the History tab opens) ── +var _histRange = '24h'; +var _histCharts = {}; +var _histTimes = {}; // chart id -> Date[] (one per data point, by category index); refreshed each load +var _histX = { ticks:{color:'#8b98a5', maxTicksLimit:6, maxRotation:0}, grid:{display:false} }; +// Date-aware x-axis for the 24h view: a 24h window crosses midnight, so time-only ticks are +// ambiguous about which day they belong to. Chart.js generates labels over the FULL tick set before +// auto-skipping, so a per-point "is this a new day?" check lands the date on the midnight point, +// which auto-skip usually then hides. Instead: the tick callback returns a 2-line [time, date] for +// EVERY tick (so fit() reserves height for the date row), then afterFit — which runs on the actual +// post-auto-skip rendered ticks — demotes repeated dates to time-only. Net: the date shows on the +// first tick and wherever the day changes among the *shown* ticks. Reads _histTimes[id] live so the +// 30s in-place refresh tracks the new timestamps. (If a build ignored the afterFit demotion it would +// harmlessly fall back to date-on-every-tick — never clipped, since fit already reserved two lines.) +function _histXScale(id){ + var fmtT = function(t){ return t.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'}); }; + var fmtD = function(t){ return t.toLocaleDateString([], {month:'short', day:'numeric'}); }; + return { grid:{display:false}, + afterFit: function(scale){ + var prev = null; + scale.ticks.forEach(function(tk){ + var t = (_histTimes[id] || [])[tk.value]; if(!t) return; + var d = fmtD(t); + tk.label = (d === prev) ? fmtT(t) : [fmtT(t), d]; + prev = d; + }); + }, + ticks:{ color:'#8b98a5', maxTicksLimit:6, maxRotation:0, autoSkip:true, + callback: function(value){ var t=(_histTimes[id]||[])[value]; return t ? [fmtT(t), fmtD(t)] : ''; } } }; +} +function _histChart(id, labels, datasets, yScales, xScale){ + var el = document.getElementById(id); if(!el || typeof Chart==='undefined') return; + var existing = _histCharts[id]; + if(existing){ + // Live refresh: update the data in place (no destroy/recreate) so the chart doesn't flash and + // keeps the tooltip/hover state. The dataset structure is stable per chart id. + existing.data.labels = labels; + datasets.forEach(function(ds, i){ + if(existing.data.datasets[i]) existing.data.datasets[i].data = ds.data; + else existing.data.datasets[i] = ds; + }); + existing.data.datasets.length = datasets.length; + existing.update('none'); + return; + } + var scales = { x:(xScale||_histX) }; for(var k in yScales){ scales[k]=yScales[k]; } + _histCharts[id] = new Chart(el.getContext('2d'), { + type:'line', data:{ labels:labels, datasets:datasets }, + options:{ responsive:true, maintainAspectRatio:false, animation:false, + interaction:{intersect:false, mode:'index'}, scales:scales, + plugins:{ legend:{ labels:{color:'#c9d1d9', boxWidth:12, boxHeight:12} } } } + }); +} +function _histLbl(iso){ + // Tooltip title (per point). Both ranges carry the date so a hovered point is never ambiguous + // about which day it is; the compact date-at-change lives on the x-axis ticks (_histXScale). + var d = new Date(iso); + return _histRange==='7d' ? d.toLocaleString([], {month:'short', day:'numeric', hour:'2-digit'}) + : d.toLocaleString([], {month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'}); +} +function _ds(label, data, color, fill){ + return { label:label, data:data, borderColor:color, backgroundColor:color+'22', + fill:!!fill, tension:.3, pointRadius:0, borderWidth:2, spanGaps:true }; +} +window.loadHistory = function(){ + fetch(MOUNT + '/api/server/' + serverId + '/history?range=' + encodeURIComponent(_histRange)) + .then(function(r){ return r.json(); }) + .then(function(d){ + var s = d.server || [], h = d.host || []; + var empty = document.getElementById('hist-empty'), charts = document.getElementById('hist-charts'); + if(!s.length && !h.length){ if(empty)empty.style.display=''; if(charts)charts.style.display='none'; return; } + if(empty)empty.style.display='none'; if(charts)charts.style.display=''; + var sL = s.map(function(p){ return _histLbl(p.t); }), hL = h.map(function(p){ return _histLbl(p.t); }); + // Parsed timestamps for the date-aware 24h axis; refreshed every load so in-place updates stay in sync. + _histTimes['hist-players'] = _histTimes['hist-server'] = s.map(function(p){ return new Date(p.t); }); + _histTimes['hist-host'] = h.map(function(p){ return new Date(p.t); }); + var _xs = function(id){ return _histRange==='7d' ? _histX : _histXScale(id); }; + var pctY = { y:{ min:0, max:100, ticks:{color:'#8b98a5', maxTicksLimit:5}, grid:{color:'rgba(255,255,255,.05)'} } }; + _histChart('hist-players', sL, + [ _ds('Players', s.map(function(p){return p.players;}), '#58a6ff', true) ], + { y:{ min:0, ticks:{color:'#8b98a5', maxTicksLimit:5, precision:0}, grid:{color:'rgba(255,255,255,.05)'} } }, _xs('hist-players')); + _histChart('hist-server', sL, [ + Object.assign(_ds('CPU %', s.map(function(p){return p.cpu;}), '#3fb950', true), {yAxisID:'y'}), + Object.assign(_ds('RAM MB', s.map(function(p){return p.ram;}), '#d29922', false), {yAxisID:'y1'}) + ], { y:{ min:0, max:100, position:'left', ticks:{color:'#8b98a5', maxTicksLimit:5}, grid:{color:'rgba(255,255,255,.05)'} }, + y1:{ min:0, position:'right', ticks:{color:'#8b98a5', maxTicksLimit:5}, grid:{display:false} } }, _xs('hist-server')); + _histChart('hist-host', hL, [ + _ds('CPU %', h.map(function(p){return p.cpu;}), '#58a6ff', false), + _ds('RAM %', h.map(function(p){return p.ram;}), '#3fb950', false), + _ds('Disk %', h.map(function(p){return p.disk;}), '#d29922', false) + ], pctY, _xs('hist-host')); + }).catch(function(){}); +}; +document.addEventListener('click', function(e){ + var b = e.target.closest && e.target.closest('[data-hist-range]'); if(!b) return; + var rng = b.getAttribute('data-hist-range'); if(rng === _histRange) return; + _histRange = rng; + if(b.parentNode) b.parentNode.querySelectorAll('[data-hist-range]').forEach(function(x){ x.classList.toggle('active', x===b); }); + // Recreate the charts on range change so the x-axis swaps between the date-aware (24h) and plain + // (7d) formatter — the live-refresh path deliberately reuses the chart and won't re-apply scales. + Object.keys(_histCharts).forEach(function(id){ try{ _histCharts[id].destroy(); }catch(err){} delete _histCharts[id]; }); + window.loadHistory(); +}); +// Keep the History charts live — refresh while its tab is open and the page is visible (a new sample +// lands every minute server-side). Updates in place, so it never flashes or steals focus. +if(window.pollWhenVisible) pollWhenVisible(function(){ if(window._sdTab==='history') window.loadHistory(); }, 30000); + +// ── GMod game content: per-server mount (enable/disable) + host install/uninstall ── +function loadGmodContent(){ + var el = document.getElementById('gmod-content-body'); + if(!el) return; + fetch(MOUNT + '/api/server/' + serverId + '/gmod-content') + .then(function(r){ return r.json(); }) + .then(function(d){ + if(d.error){ el.innerHTML = ''+_esc(d.error)+''; return; } + var running = d.job && d.job.status === 'running'; + // Installable games always show; owned/mount-only games only once their content is on the host + // (or already mounted). Mount-only content that isn't present can't be added by the panel. + var visible = (d.games||[]).filter(function(g){ return g.downloadable || g.present || g.mounted; }); + var rows = visible.map(function(g){ + var status, act = ''; + if (g.present) { + status = 'on host'; + // Any content on the host can be removed to free disk — including owned/mount-only games. + act = ''; + } else if (g.downloadable) { + status = 'installs '+_esc(g.size||'')+''; + } else { + status = 'not on host'; + } + // Flex row: the label flexes+truncates so the status + Uninstall stay inside the card on + // narrow (mobile) screens instead of being pushed off the right edge. + return '
' + + '' + + '' + + ''+status+'' + + (act ? ''+act+'' : '') + + '
'; + }).join(''); + function _gb(b){ return (b/1073741824).toFixed(b >= 10.7e9 ? 0 : 1) + ' GB'; } // bytes -> GB + var disk = (d.disk_free != null && d.disk_total != null) + ? '
Host disk: ' + + ''+_gb(d.disk_free)+' free' + + ' of '+_gb(d.disk_total)+'
' + : ''; + el.innerHTML = + '
' + + 'Tick a game to mount it on this server (enable/disable per server) — a game not ' + + 'yet on the host is installed via LinuxGSM when you Apply, and kept current by a weekly update. ' + + 'Uninstall removes the content from the host, freeing disk for every GMod server ' + + 'here. Restart the server to load mount changes.
' + + disk + + '
' + rows + '
' + + (running + ? '
Working… (install/removal can take a while) '+_esc((d.job&&d.job.msg)||'')+'
' + : '' + + ' Restart the server afterwards to load changes.'); + // Attach handlers programmatically — the strict CSP (no unsafe-inline) blocks inline onclick=. + var _ap = el.querySelector('#gmc-apply'); + if(_ap) _ap.onclick = function(){ applyGmodContent(_ap); }; + el.querySelectorAll('[data-gmc-uninstall]').forEach(function(b){ + b.onclick = function(){ uninstallGmodContent(b.getAttribute('data-gmc-uninstall'), b.getAttribute('data-gmc-label')); }; + }); + if(running) setTimeout(loadGmodContent, 5000); + }) + .catch(function(){ el.innerHTML = 'Could not load content status.'; }); +} +function _gmcPost(bodyObj, okMsg){ + fetch(MOUNT + '/api/server/' + serverId + '/gmod-content', { + method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(bodyObj) + }).then(function(r){ return r.json(); }).then(function(d){ + if(window.toast) toast(d.message || (d.success?okMsg:'Failed'), d.success?'success':'danger'); + setTimeout(loadGmodContent, 1500); + }).catch(function(){ if(window.toast) toast('Request failed','danger'); }); +} +function applyGmodContent(btn){ + var sel = Array.prototype.map.call(document.querySelectorAll('.gmc-box:checked'), function(b){ return b.value; }); + if(btn){ btn.disabled = true; btn.innerHTML = ' Applying…'; } + _gmcPost({action:'mount', games: sel}, 'Applying…'); +} +function uninstallGmodContent(key, label){ + window.confirmDialog({ + title:'Uninstall '+label+' content', icon:'trash', confirmClass:'btn-danger', confirmLabel:'Uninstall', + bodyText:'Remove '+label+' content from this host?\n\nThis frees disk but removes it for EVERY GMod server on the host. You can re-install it later from any GMod server.', + onConfirm:function(){ _gmcPost({action:'uninstall', games:[key]}, 'Removing…'); } + }); +} +loadGmodContent(); + +// Populate console immediately via AJAX (page render no longer waits on SSH), +// then let the websocket stream take over. +refreshConsole(true); // initial load: jump to the latest output +// Layout handlers are defined BEFORE the bootstrap calls below on purpose: initChart/pollStats +// touch elements that a user can now hide, and a throw there would otherwise leave movePanel, +// hidePanel and showDetailPanel undefined — i.e. no way to undo the hide that caused it. +// ── Per-user panel order for this page's Console tab ───────────────────────────────────────────── +// Same shape as the dashboard: the SERVER renders the saved order, and these controls move the node +// for instant feedback and persist the result. Scoped to one region, because the tab switcher drives +// display on every [data-mtab] node and mixing tabs into one order would fight it. +function saveDetailLayout(then){ + var region = document.getElementById('detail-console'); + if (!region) return; + var keys = Array.prototype.slice.call(region.querySelectorAll(':scope > [data-panel]')) + .map(function(el){ return el.getAttribute('data-panel'); }); + var declared = []; + try { declared = JSON.parse(region.getAttribute('data-declared') || '[]'); } catch (e) {} + var bar = document.getElementById('detail-console-hidden'); + var hidden = bar ? Array.prototype.slice.call(bar.querySelectorAll('[data-action="showDetailPanel"]')) + .map(function(b){ return JSON.parse(b.getAttribute('data-args'))[1]; }) : []; + fetch(MOUNT + '/api/account/ui-order', { + method: 'POST', headers: {'Content-Type': 'application/json'}, + // `declared` = the keys THIS page could have sent. The server keeps stored keys outside it, + // so viewing a server without Commands/Game Content cannot erase where you put them elsewhere. + body: JSON.stringify({panels: {detail_console: keys}, hidden: {detail_console: hidden}, + declared: {detail_console: declared}}) + }) + .then(function(r){ if(!r.ok) throw 0; return r.json(); }) + .then(function(d){ if(!d || d.success === false) throw 0; if (then) then(); }) + .catch(function(){ + if (window.toast) toast('Could not save your layout — it will revert on reload.', 'danger'); + }); +} + +// This page reuses the dashboard's action NAMES, so the handlers have to exist here too — they are +// per-page by design (each page knows its own regions and save payload). +window.movePanel = function(dir, btn){ + var panel = btn.closest('[data-panel]'); + var region = panel && panel.closest('[data-region]'); + if (!region) return; + var sibs = Array.prototype.slice.call(region.querySelectorAll(':scope > [data-panel]')); + var at = sibs.indexOf(panel), to = at + (dir < 0 ? -1 : 1); + if (at < 0 || to < 0 || to >= sibs.length) return; + if (dir < 0) region.insertBefore(panel, sibs[to]); + else region.insertBefore(sibs[to], panel); + var keep = panel.querySelector('.panel-tools button:not([disabled])'); + if (keep) keep.focus(); + saveDetailLayout(); +}; + +window.hidePanel = function(btn){ + var panel = btn.closest('[data-panel]'); + if (!panel) return; + var key = panel.getAttribute('data-panel'); + var label = (panel.querySelector('.card-header') || {}).textContent || key; + var bar = document.getElementById('detail-console-hidden'); + if (!bar){ + bar = document.createElement('div'); + bar.id = 'detail-console-hidden'; + bar.className = 'mb-3 d-flex align-items-center gap-2 flex-wrap'; + bar.setAttribute('data-mtab', 'console'); // or it would show on History/Details too + var lead = document.createElement('span'); + lead.className = 'text-secondary small'; + lead.textContent = 'Hidden:'; + bar.appendChild(lead); + var region = document.getElementById('detail-console'); + region.parentNode.insertBefore(bar, region.nextSibling); + } + var chip = document.createElement('button'); + chip.type = 'button'; + chip.className = 'btn btn-sm btn-outline-secondary'; + chip.setAttribute('data-action', 'showDetailPanel'); + chip.setAttribute('data-args', JSON.stringify(['detail_console', key, '@self'])); + chip.textContent = label.trim().split('\n')[0] || key; + bar.appendChild(chip); + panel.remove(); + saveDetailLayout(); +}; + +// A hidden panel is not rendered at all, so restoring needs markup only the server has: save first, +// reload on the acknowledgement. +window.showDetailPanel = function(region, key, btn){ + btn.remove(); + saveDetailLayout(function(){ location.reload(); }); +}; + +if (window.makeSortable) { + makeSortable(document.getElementById('detail-console'), + {itemSelector: '[data-panel]', axis: 'y', onDrop: saveDetailLayout}); +} + +initChart(); +pollStats(); // self-schedules: ~8s while online, ~20s while offline, paused while the tab is hidden +// Refocusing the tab catches up immediately (the poll pauses while hidden). +document.addEventListener('visibilitychange', function(){ if (!document.hidden) pollStats(); }); +// Backup console refresh (websocket is primary) — respects your scroll position +pollWhenVisible(function(){ refreshConsole(); }, 30000); diff --git a/static/js/server_files.js b/static/js/server_files.js new file mode 100644 index 0000000..577b8a1 --- /dev/null +++ b/static/js/server_files.js @@ -0,0 +1,718 @@ +var curDir = ""; // current browse dir (relative to home) +var curFile = null; // path of the file open in the editor + +// Escapes quotes too ('/") — config keys/values are interpolated into +// double-quoted HTML attributes below, so a bare " would otherwise break out (XSS). +// Delegates; the old body used (s+'') so esc(null) rendered the literal "null". +function esc(s){ return window.escapeHtml(s); } +function fmtSize(b){ if(b<1024)return b+' B'; if(b<1048576)return (b/1024).toFixed(1)+' KB'; return (b/1048576).toFixed(1)+' MB'; } +function fileIcon(name){ + var n=(name||'').toLowerCase(); + if(/\.(cfg|conf|ini|cnf)$/.test(n)) return 'bi-sliders text-info'; + if(/\.(log)$/.test(n)) return 'bi-journal-text text-secondary'; + if(/\.(json|yml|yaml|xml|toml)$/.test(n)) return 'bi-file-earmark-code text-info'; + if(/\.(sh|bash)$/.test(n)) return 'bi-terminal text-success'; + if(/\.(txt|md)$/.test(n)) return 'bi-file-earmark-text'; + if(/\.(zip|gz|tar|bz2|xz|7z|rar)$/.test(n)) return 'bi-file-earmark-zip text-warning'; + if(/\.(jpe?g|png|gif|bmp|svg|webp)$/.test(n)) return 'bi-file-earmark-image text-warning'; + return 'bi-file-earmark'; +} + +// ── Config tabs ── +var gameCfgLoaded=false, gameCfgRel=null; +document.getElementById('cfg-tabs').addEventListener('click', function(ev){ + var b=ev.target.closest('[data-tab]'); if(!b) return; + document.querySelectorAll('#cfg-tabs .nav-link').forEach(function(n){ n.classList.remove('active'); }); + b.classList.add('active'); + ['lgsm','game','raw'].forEach(function(t){ document.getElementById('tab-'+t).style.display = (t===b.dataset.tab)?'':'none'; }); + if(b.dataset.tab==='game' && !gameCfgLoaded) loadGameCfg(); +}); + +// ── LinuxGSM settings (grouped) ── +function fieldHtml(s){ + var def = s['default']; + return '
' + + '' + + '' + + (def!==''?'
default: ' + + ''+esc(def)+'
':'') + + '
'; +} +// Click any "default: " to copy that value to the clipboard (delegated, so it keeps working +// after the config list re-renders). Uses the shared copyText() helper for the copy + confirmation. +document.addEventListener('click', function(ev){ + var c = ev.target.closest && ev.target.closest('.cfg-copy-default'); + if(!c) return; + if(window.copyText) window.copyText(c.getAttribute('data-copy') || '', 'Copied default'); +}); +function loadConfig(){ + fetch(MOUNT+'/api/server/'+serverId+'/config').then(r=>r.json()).then(d=>{ + document.getElementById('cfg-loading').style.display='none'; + if(d.error){ document.getElementById('cfg-loading').style.display=''; document.getElementById('cfg-loading').innerHTML=''+esc(d.error)+''; return; } + var g=document.getElementById('cfg-groups'); g.innerHTML=''; + (d.groups||[]).forEach(function(grp, idx){ + var open = idx===0; // first group (Game Server Settings) expanded + var body = '
'+grp.settings.map(fieldHtml).join('')+'
'; + g.insertAdjacentHTML('beforeend', + '
' + + '' + + '
'+body+'
'); + }); + if(!(d.groups||[]).length) g.innerHTML='
No settings detected. Use the raw editor.
'; + document.getElementById('cfg-form').style.display=''; + document.getElementById('cfg-raw').value = d.raw||''; + }).catch(()=>{ document.getElementById('cfg-loading').innerHTML='Failed to load config'; }); +} +// Accordion toggle (delegated). +document.getElementById('cfg-groups').addEventListener('click', function(ev){ + var b=ev.target.closest('[data-acc]'); if(!b) return; + var body=document.querySelector('[data-accbody="'+b.dataset.acc+'"]'); + var ic=b.querySelector('i'); + if(body.style.display==='none'){ body.style.display=''; ic.className='bi bi-caret-down-fill me-1'; } + else { body.style.display='none'; ic.className='bi bi-caret-right-fill me-1'; } +}); +function saveConfig(ev){ + ev.preventDefault(); + var settings={}; + // Only send fields the user actually changed — otherwise every default would be + // written into the instance override file. + document.querySelectorAll('#cfg-groups input[data-key]').forEach(function(i){ + if(i.value !== i.getAttribute('data-orig')) settings[i.getAttribute('data-key')]=i.value; + }); + var msg=document.getElementById('cfg-save-msg'); + if(Object.keys(settings).length===0){ msg.textContent='No changes to save.'; msg.className='small ms-2 text-secondary'; return false; } + msg.textContent='Saving…'; msg.className='small ms-2 text-secondary'; + fetch(MOUNT+'/api/server/'+serverId+'/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({settings:settings})}) + .then(r=>r.json()).then(d=>{ + msg.textContent = d.success?'✓ Saved — restart the server to apply.':'✗ '+(d.message||'Failed'); + msg.className='small ms-2 '+(d.success?'text-success':'text-danger'); + if(d.success){ + // Mark saved values as the new baseline (preserves accordion state), and show the "set" + // badge on the fields we just wrote — they're now overrides in the instance .cfg. + document.querySelectorAll('#cfg-groups input[data-key]').forEach(function(i){ + i.setAttribute('data-orig', i.value); + if(settings.hasOwnProperty(i.getAttribute('data-key'))){ + var b=i.parentElement.querySelector('.cfg-set-badge'); if(b) b.style.display=''; + } + }); + // The settings write changed the instance .cfg — refresh the Raw tab so it isn't stale. + refreshRawConfig(); + } + }).catch(()=>{ msg.textContent='✗ Save failed'; msg.className='small ms-2 text-danger'; }); + return false; +} +// Re-read just the raw instance cfg and update the Raw tab, WITHOUT re-rendering the settings +// form (which would collapse the accordion). Skipped if the user is actively editing the raw box. +function refreshRawConfig(){ + fetch(MOUNT+'/api/server/'+serverId+'/config').then(r=>r.json()).then(function(d){ + var ta=document.getElementById('cfg-raw'); + if(ta && d && d.raw !== undefined && document.activeElement!==ta) ta.value = d.raw || ''; + }).catch(function(){}); +} +// ── Game config file ── +function loadGameCfg(){ + gameCfgLoaded=true; + fetch(MOUNT+'/api/server/'+serverId+'/game-config').then(r=>r.json()).then(d=>{ + document.getElementById('game-loading').style.display='none'; + if(d.error || !d.rel){ + document.getElementById('game-none').style.display=''; + document.getElementById('game-none').innerHTML=' '+esc(d.error||'This game has no single editable config file. Use the file browser below.'); + return; + } + gameCfgRel=d.rel; + document.getElementById('game-path').textContent=d.rel; + document.getElementById('game-cfg').value=d.content||''; + document.getElementById('game-wrap').style.display=''; + }).catch(()=>{ document.getElementById('game-loading').innerHTML='Failed to load game config'; }); +} +function saveGameCfg(){ + if(!gameCfgRel) return; + var msg=document.getElementById('game-msg'); msg.textContent='Saving…'; msg.className='small ms-2 text-secondary'; + fetch(MOUNT+'/api/server/'+serverId+'/file',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:gameCfgRel,content:document.getElementById('game-cfg').value})}) + .then(r=>r.json()).then(d=>{ msg.textContent=d.success?'✓ Saved — restart to apply.':'✗ '+(d.message||'Failed'); msg.className='small ms-2 '+(d.success?'text-success':'text-danger'); }) + .catch(()=>{ msg.textContent='✗ Failed'; msg.className='small ms-2 text-danger'; }); +} +function saveRaw(){ + var msg=document.getElementById('cfg-raw-msg'); msg.textContent='Saving…'; msg.className='small ms-2 text-secondary'; + fetch(MOUNT+'/api/server/'+serverId+'/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({raw:document.getElementById('cfg-raw').value})}) + .then(r=>r.json()).then(d=>{ msg.textContent=d.success?'✓ Saved':'✗ '+(d.message||'Failed'); msg.className='small ms-2 '+(d.success?'text-success':'text-danger'); if(d.success) loadConfig(); }) + .catch(()=>{ msg.textContent='✗ Failed'; msg.className='small ms-2 text-danger'; }); +} + +// ── File browser ── +function renderBreadcrumb(){ + var parts = curDir? curDir.split('/'):[]; + var html=' home'; + var acc=''; + parts.forEach(function(p){ acc = acc?acc+'/'+p:p; html+=' / '+esc(p)+''; }); + document.getElementById('breadcrumb').innerHTML = html; + var dest=document.getElementById('upload-dest'); if(dest) dest.textContent = curDir||'home'; +} +function mkRow(opts){ + // opts: {name, path, type: 'dir'|'file'|'up', size, deletable, protected, icon} + var row=document.createElement('div'); + row.className='list-group-item list-group-item-action d-flex justify-content-between align-items-center'; + row.style.cursor='pointer'; + row.dataset.path=opts.path; row.dataset.type=opts.type; + var left=document.createElement('span'); left.style.flex='1'; left.style.minWidth='0'; left.style.overflow='hidden'; left.style.textOverflow='ellipsis'; left.style.whiteSpace='nowrap'; + left.innerHTML=opts.icon+' '+esc(opts.name)+''; + var right=document.createElement('span'); right.className='d-flex align-items-center gap-2 flex-shrink-0'; + if(opts.size!=null){ var s=document.createElement('span'); s.className='text-secondary'; s.style.fontSize='.68rem'; s.textContent=fmtSize(opts.size); right.appendChild(s); } + if(opts.protected){ var lk=document.createElement('span'); lk.className='text-secondary'; lk.title='Protected — required by LinuxGSM/the game'; lk.innerHTML=''; right.appendChild(lk); } + else if(opts.deletable){ var b=document.createElement('button'); b.type='button'; b.className='btn btn-sm btn-link text-danger p-0'; b.title='Delete'; b.dataset.action='delete'; b.innerHTML=''; right.appendChild(b); } + row.appendChild(left); row.appendChild(right); + return row; +} +function browse(path){ + curDir = path||''; + fetch(MOUNT+'/api/server/'+serverId+'/browse?path='+encodeURIComponent(curDir)).then(r=>r.json()).then(d=>{ + var l=document.getElementById('file-list'); l.innerHTML=''; + if(d.error){ l.innerHTML='
'+esc(d.error)+'
'; return; } + renderBreadcrumb(); + if(curDir){ + l.appendChild(mkRow({name:'..', path:curDir.split('/').slice(0,-1).join('/'), type:'up', icon:''})); + } + (d.entries||[]).forEach(function(e){ + var p = curDir? curDir+'/'+e.name : e.name; + l.appendChild(mkRow({ + name:e.name, path:p, type:e.is_dir?'dir':'file', deletable:true, protected:e.protected, + size: e.is_dir?null:e.size, + icon: e.is_dir?'':'' + })); + }); + if(!(d.entries||[]).length) l.insertAdjacentHTML('beforeend','
(empty folder)
'); + // Re-highlight the open file if it's in this directory. + if(curFile){ var open=l.querySelector('[data-path="'+CSS.escape(curFile)+'"]'); if(open) open.classList.add('active'); } + }).catch(()=>{}); +} +function openFile(path){ + document.getElementById('file-save-msg').textContent=''; + fetch(MOUNT+'/api/server/'+serverId+'/file?path='+encodeURIComponent(path)).then(r=>r.json()).then(d=>{ + if(d.error){ if(window.toast) toast(d.error, 'danger'); return; } + curFile=path; + document.getElementById('editor-empty').style.display='none'; + document.getElementById('editor-wrap').style.display=''; + document.getElementById('editor-path').textContent=path; + document.getElementById('editor').value=d.content||''; + }).catch(()=>{}); +} +function closeEditor(){ + curFile=null; + document.getElementById('editor-wrap').style.display='none'; + document.getElementById('editor-empty').style.display=''; + document.querySelectorAll('#file-list .list-group-item.active').forEach(function(x){ x.classList.remove('active'); }); +} +function saveFile(){ + if(!curFile) return; + var msg=document.getElementById('file-save-msg'); msg.textContent='Saving…'; msg.className='small text-secondary'; + fetch(MOUNT+'/api/server/'+serverId+'/file',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:curFile,content:document.getElementById('editor').value})}) + .then(r=>r.json()).then(d=>{ msg.textContent=d.success?'✓ Saved':'✗ '+(d.message||'Failed'); msg.className='small '+(d.success?'text-success':'text-danger'); }) + .catch(()=>{ msg.textContent='✗ Failed'; msg.className='small text-danger'; }); +} +function deletePath(path, isDir){ + confirmDialog({title:'Delete '+(isDir?'directory':'file'), icon:'trash', confirmClass:'btn-danger', confirmLabel:'Delete', + bodyText:'Delete '+(isDir?'directory (and everything in it)':'file')+':\n'+path+' ?', + onConfirm:function(){ + fetch(MOUNT+'/api/server/'+serverId+'/delete-path',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:path})}) + .then(r=>r.json()).then(d=>{ + if(!d.success){ if(window.toast) toast(d.message||'Delete failed','danger'); return; } + if(curFile===path) closeEditor(); + browse(curDir); + }).catch(()=>{ if(window.toast) toast('Delete failed','danger'); }); + }}); +} +// Upload one or more files to the current dir (used by button + drag-drop). +function uploadFiles(files){ + if(!files || !files.length) return; + var st=document.getElementById('upload-status'); + var arr=Array.prototype.slice.call(files); var failed=0; + st.textContent='Uploading '+arr.length+' file(s)…'; st.className='small mt-2 text-secondary'; + function next(i){ + if(i>=arr.length){ + st.textContent=(failed?'⚠ '+failed+' failed, ':'✓ ')+(arr.length-failed)+' uploaded to '+(curDir||'home'); + st.className='small mt-2 '+(failed?'text-warning':'text-success'); + browse(curDir); setTimeout(function(){ st.textContent=''; },4000); return; + } + var fd=new FormData(); fd.append('file', arr[i]); fd.append('path', curDir); + fetch(MOUNT+'/api/server/'+serverId+'/upload',{method:'POST',body:fd}).then(r=>r.json()).then(d=>{ if(!d.success) failed++; }).catch(()=>{failed++;}).finally(()=>{ next(i+1); }); + } + next(0); +} +function doUpload(ev){ + ev.preventDefault(); + var inp=document.getElementById('upload-input'); + if(inp.files.length){ uploadFiles(inp.files); inp.value=''; } + return false; +} + +// Event delegation for the file list (rows + delete buttons). +document.getElementById('file-list').addEventListener('click', function(ev){ + var del = ev.target.closest('[data-action="delete"]'); + if(del){ ev.stopPropagation(); var r=del.closest('[data-path]'); deletePath(r.dataset.path, r.dataset.type==='dir'); return; } + var row = ev.target.closest('[data-path]'); + if(!row) return; + if(row.dataset.type==='file'){ + document.querySelectorAll('#file-list .list-group-item.active').forEach(function(x){ x.classList.remove('active'); }); + row.classList.add('active'); + openFile(row.dataset.path); + } else browse(row.dataset.path); +}); +// Breadcrumb navigation (delegated). +document.getElementById('breadcrumb').addEventListener('click', function(ev){ + var a=ev.target.closest('[data-nav]'); if(a){ ev.preventDefault(); browse(a.getAttribute('data-nav')); } +}); +// Drag & drop upload onto the browser (overlay shows while dragging). +(function(){ + var dz=document.getElementById('drop-zone'); + ['dragenter','dragover'].forEach(function(e){ dz.addEventListener(e,function(ev){ ev.preventDefault(); ev.stopPropagation(); dz.classList.add('dragging'); }); }); + ['dragleave','drop'].forEach(function(e){ dz.addEventListener(e,function(ev){ ev.preventDefault(); ev.stopPropagation(); if(e==='drop' || !dz.contains(ev.relatedTarget)) dz.classList.remove('dragging'); }); }); + dz.addEventListener('drop', function(ev){ if(ev.dataTransfer && ev.dataTransfer.files) uploadFiles(ev.dataTransfer.files); }); +})(); + +// ── Scheduled tasks (cron) ── +var cronEditRaw = null; // when editing: the exact raw line being replaced +// Attribute-safe escape: cron commands routinely contain " and ', which would +// otherwise break the data-* attributes we round-trip the raw line through. +// escA was a no-op wrapper: esc already escapes both quote characters. Kept as an alias so its +// ~call sites need not change, but it adds nothing. +var escA = esc; +function cronMsg(text, cls){ var m=document.getElementById('cron-msg'); m.textContent=text||''; m.className='small '+(cls||'text-secondary'); } +// "3m ago" / "2h ago" / "5d ago" from an epoch (seconds). +function timeAgo(epoch){ + var s = Math.max(0, Math.floor(Date.now()/1000 - epoch)); + if(s < 60) return s+'s ago'; + if(s < 3600) return Math.floor(s/60)+'m ago'; + if(s < 86400) return Math.floor(s/3600)+'h ago'; + return Math.floor(s/86400)+'d ago'; +} +// Last-run cell. Wrapped jobs report an exit status → OK/Failed badge + the error under a +// failure. A managed or legacy job that has not run since the panel re-wrapped it (which +// upgrade_managed_cron_tracking does on every cron GET) has only a run TIME from cron's log → a +// neutral "ran" badge until its next run records a status. No recorded run → "—". +function cronLastRun(j){ + if(!j.last_run){ return ''; } + var when = new Date(j.last_run*1000).toLocaleString(); + var badge; + if(j.ok === true){ badge = 'OK'; } + else if(j.ok === false){ badge = 'Failed'; } + else { badge = 'ran'; } + var out = badge + ' '+esc(timeAgo(j.last_run))+''; + if(j.ok === false && j.error){ out += '
'+esc(j.error)+'
'; } + return out; +} +function loadCron(){ + fetch(MOUNT+'/api/server/'+serverId+'/cron').then(r=>r.json()).then(d=>{ + var loading=document.getElementById('cron-loading'); + if(d.error){ loading.style.display=''; loading.innerHTML=''+esc(d.error)+''; return; } + var tb=document.getElementById('cron-tbody'), rows=''; + (d.jobs||[]).forEach(function(j){ + var runBtn = ''; + // Every task is editable + deletable now, including panel-installed ones. + var actions = runBtn + + '' + + ''; + // Non-blocking label so you can tell what a panel-installed line is (still fully editable). + var roleTag = j.role + ? ' '+esc(j.role)+'' + : ''; + rows += '' + + ''+esc(j.schedule)+'' + + ''+esc(j.command)+roleTag+'' + + ''+cronLastRun(j)+'' + + ''+actions+''; + }); + if(!(d.jobs||[]).length) rows='No scheduled tasks yet.'; + // Only swap in the new list once it has arrived (keep the old rows until then). + tb.innerHTML=rows; + loading.style.display='none'; + document.getElementById('cron-table-wrap').style.display=''; + }).catch(()=>{ /* keep whatever is shown; transient errors shouldn't blank the list */ }); +} +function saveCron(ev){ + ev.preventDefault(); + var sched=document.getElementById('cron-sched').value.trim(); + var cmd=document.getElementById('cron-cmd').value.trim(); + if(!sched || !cmd){ cronMsg('Schedule and command are both required.','text-danger'); return false; } + var editing = cronEditRaw!==null; + var url = MOUNT+'/api/server/'+serverId+'/cron'+(editing?'/update':''); + var body = editing ? {raw:cronEditRaw, schedule:sched, command:cmd} : {schedule:sched, command:cmd}; + cronMsg('Saving…','text-secondary'); + fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}) + .then(r=>r.json()).then(d=>{ + if(d.success){ cronMsg(editing?'✓ Task updated':'✓ Task added','text-success'); cancelCronEdit(); loadCron(); } + else cronMsg('✗ '+(d.message||'Failed'),'text-danger'); + }).catch(()=>cronMsg('✗ Save failed','text-danger')); + return false; +} +function cancelCronEdit(){ + cronEditRaw=null; + document.getElementById('cron-sched').value=''; + document.getElementById('cron-cmd').value=''; + document.getElementById('cron-submit').innerHTML=' Add task'; + document.getElementById('cron-cancel').style.display='none'; + updateCronExplain(); +} +document.getElementById('cron-tbody').addEventListener('click', function(ev){ + var row=ev.target.closest('tr[data-raw]'); if(!row) return; + if(ev.target.closest('[data-cron-edit]')){ + cronEditRaw=row.getAttribute('data-raw'); + document.getElementById('cron-sched').value=row.getAttribute('data-sched'); + document.getElementById('cron-cmd').value=row.getAttribute('data-cmd'); + updateCronExplain(); + document.getElementById('cron-submit').innerHTML=' Save changes'; + document.getElementById('cron-cancel').style.display=''; + cronMsg('Editing an existing task…','text-secondary'); + document.getElementById('cron-sched').focus(); + } else if(ev.target.closest('[data-cron-del]')){ + confirmDialog({title:'Delete scheduled task', icon:'trash', confirmClass:'btn-danger', confirmLabel:'Delete', + bodyText:'Delete this scheduled task?\n\n'+row.getAttribute('data-sched')+' '+row.getAttribute('data-cmd'), + onConfirm:function(){ + fetch(MOUNT+'/api/server/'+serverId+'/cron/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({raw:row.getAttribute('data-raw')})}) + .then(r=>r.json()).then(d=>{ if(d.success){ cronMsg('✓ Task deleted','text-success'); loadCron(); } else cronMsg('✗ '+(d.message||'Delete failed'),'text-danger'); }) + .catch(()=>cronMsg('✗ Delete failed','text-danger')); + }}); + } else if(ev.target.closest('[data-cron-run]')){ + var btn=ev.target.closest('[data-cron-run]'); btn.disabled=true; + cronMsg('Running '+row.getAttribute('data-cmd')+'…','text-secondary'); + fetch(MOUNT+'/api/server/'+serverId+'/cron/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({raw:row.getAttribute('data-raw')})}) + .then(r=>r.json()).then(d=>{ + cronMsg((d.success?'✓ ':'✗ ')+(d.message||'Done'), d.success?'text-success':'text-danger'); + btn.disabled=false; + // The run is detached; refresh a few times so the OK/Failed badge lands when it finishes. + [3000,8000,15000].forEach(function(ms){ setTimeout(loadCron, ms); }); + }) + .catch(()=>{ cronMsg('✗ Run failed','text-danger'); btn.disabled=false; }); + } +}); + +// ── Live cron explainer (crontab.guru-style: translate + preview next runs) ── +(function(){ + var DOW=['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']; + var MON=['','January','February','March','April','May','June','July','August','September','October','November','December']; + var NM_DOW={sun:0,mon:1,tue:2,wed:3,thu:4,fri:5,sat:6}; + var NM_MON={jan:1,feb:2,mar:3,apr:4,may:5,jun:6,jul:7,aug:8,sep:9,oct:10,nov:11,dec:12}; + var SHORT={'@yearly':'0 0 1 1 *','@annually':'0 0 1 1 *','@monthly':'0 0 1 * *','@weekly':'0 0 * * 0','@daily':'0 0 * * *','@midnight':'0 0 * * *','@hourly':'0 * * * *'}; + // Parse one cron field into a Set of allowed ints (ranges, lists, steps, names). null = invalid. + function parseField(f,lo,hi,names){ + f=(f||'').trim().toLowerCase(); if(f==='') return null; + var out=new Set(), parts=f.split(','); + for(var i=0;i=0){ step=parseInt(p.slice(sl+1),10); range=p.slice(0,sl); if(!(step>=1)) return null; } + var a,b; + if(range==='*'){ a=lo; b=hi; } + else { + var seg=range.split('-'); + var mv=function(x){ x=x.trim(); if(names&&names[x]!=null) return names[x]; if(!/^\d+$/.test(x)) return NaN; return parseInt(x,10); }; + a=mv(seg[0]); b=(seg.length>1)?mv(seg[1]):(sl>=0?hi:a); + if(isNaN(a)||isNaN(b)) return null; + } + if(names===NM_DOW){ if(a===7)a=0; if(b===7)b=0; } + if(a>b || ahi) return null; + for(var v=a; v<=b; v+=step) out.add(v); + } + return out; + } + function pad(n){ return (n<10?'0':'')+n; } + function toArr(s){ return Array.from(s).sort(function(a,b){return a-b;}); } + function joinList(arr,fmt){ arr=arr.map(fmt); if(arr.length<=1) return arr[0]||''; return arr.slice(0,-1).join(', ')+' and '+arr[arr.length-1]; } + function contiguous(a){ for(var i=1;i1; } + function describe(f,sets){ + var mR=f[0],hR=f[1],domR=f[2],monR=f[3],dowR=f[4]; + var mAll=mR==='*', hAll=hR==='*', mOne=/^\d+$/.test(mR), hOne=/^\d+$/.test(hR), stepM=/^\*\/(\d+)$/.exec(mR); + var time; + if(mAll&&hAll) time='Every minute'; + else if(stepM&&hAll) time='Every '+stepM[1]+' minutes'; + else if(mOne&&hOne) time='At '+pad(+hR)+':'+pad(+mR); + else if(mOne&&hAll) time=(+mR===0)?'Every hour, on the hour':'At '+(+mR)+' minutes past every hour'; + else { + var mp=hAll?'past every hour':'past '+(hOne?('hour '+(+hR)):('hours '+joinList(toArr(sets.hour),String))); + time=(mAll?'Every minute':'At '+(mOne?('minute '+(+mR)):('minutes '+joinList(toArr(sets.min),String))))+' '+mp; + } + var q=[]; + if(domR!=='*') q.push('on day-of-month '+joinList(toArr(sets.dom),String)); + if(dowR!=='*'){ var dw=toArr(sets.dow); q.push((domR!=='*'?'and on ':'on ')+(contiguous(dw)?DOW[dw[0]]+'–'+DOW[dw[dw.length-1]]:joinList(dw,function(x){return DOW[x];}))); } + if(monR!=='*') q.push('in '+joinList(toArr(sets.mon),function(x){return MON[x];})); + return time+(q.length?' '+q.join(' '):''); + } + function analyze(expr){ + expr=(expr||'').trim().replace(/\s+/g,' '); + if(expr==='') return {empty:true}; + if(expr.charAt(0)==='@'){ + var k=expr.toLowerCase(); + if(k==='@reboot') return {ok:true,text:'Runs once, at server boot.',reboot:true}; + if(SHORT[k]) expr=SHORT[k]; else return {error:'Unknown shortcut "'+expr+'".'}; + } + var f=expr.split(' '); + if(f.length!==5) return {error:'Needs 5 fields (min hour day month weekday) or an @shortcut.'}; + var sets={min:parseField(f[0],0,59),hour:parseField(f[1],0,23),dom:parseField(f[2],1,31),mon:parseField(f[3],1,12,NM_MON),dow:parseField(f[4],0,6,NM_DOW)}; + for(var kk in sets){ if(!sets[kk]||!sets[kk].size) return {error:'Invalid or out-of-range '+kk+' field.'}; } + sets.domStar=f[2]==='*'; sets.dowStar=f[4]==='*'; + return {ok:true,text:describe(f,sets),sets:sets}; + } + function nextRuns(sets,count){ + var res=[], d=new Date(); d.setSeconds(0,0); d.setMinutes(d.getMinutes()+1); + for(var g=0; g<367*24*60 && res.lengthr.json()).then(function(d){ + var body=document.getElementById('alerts-body'); if(!body) return; + if(d.error){ document.getElementById('alerts-loading').innerHTML=''+esc(d.error)+''; return; } + var vals=d.values||{}, html=''; + (d.providers||[]).forEach(function(p){ + var on=String(vals[p.toggle]||'').toLowerCase()==='on'; + html += '
' + + '
' + + '
' + + '
'; + (p.fields||[]).forEach(function(f){ + html += '
' + + '
'; + }); + html += '
'; + }); + body.innerHTML=html; + document.getElementById('alerts-loading').style.display='none'; + body.style.display=''; + }).catch(function(){ document.getElementById('alerts-loading').innerHTML='Could not load alert settings.'; }); +} +function saveAlerts(btn){ + var values={}; + document.querySelectorAll('[data-alert-toggle]').forEach(function(el){ values[el.getAttribute('data-alert-toggle')] = el.checked ? 'on' : 'off'; }); + document.querySelectorAll('[data-alert-key]').forEach(function(el){ values[el.getAttribute('data-alert-key')] = el.value.trim(); }); + if(btn) btn.disabled=true; alertsMsg('Saving…','text-secondary'); + fetch(MOUNT+'/api/server/'+serverId+'/alerts',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({values:values})}) + .then(r=>r.json()).then(function(d){ alertsMsg((d.success?'✓ ':'✗ ')+(d.message||''), d.success?'text-success':'text-danger'); if(btn) btn.disabled=false; }) + .catch(function(){ alertsMsg('✗ Save failed','text-danger'); if(btn) btn.disabled=false; }); +} +function testAlert(btn){ + if(btn) btn.disabled=true; alertsMsg('Sending a test alert…','text-secondary'); + fetch(MOUNT+'/api/server/'+serverId+'/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'test-alert'})}) + .then(r=>r.json()).then(function(d){ alertsMsg((d.success?'✓ ':'✗ ')+(d.message||'Sent — check your alert channels.'), d.success?'text-success':'text-danger'); if(btn) btn.disabled=false; }) + .catch(function(){ alertsMsg('✗ Could not send','text-danger'); if(btn) btn.disabled=false; }); +} +loadAlerts(); + +// ── Mods & addons ── +function modsMsg(t,cls){ var m=document.getElementById('mods-msg'); if(m){ m.textContent=t||''; m.className='small '+(cls||'text-secondary'); } } +function modLabel(m){ + // "Name (id)" — id is a safe charset ([A-Za-z0-9._-]) so it's fine inline. + return esc(m.name||m.id) + ' ('+esc(m.id)+')'; +} +function modRow(m, installed){ + // One row per mod: installed → green tick + Remove; not installed → Install. Same place for both. + var btn = installed + ? '' + : ''; + var tick = installed ? '' : ''; + return '
' + + ''+tick+modLabel(m)+''+btn+'
'; +} +function loadMods(force){ + var load=document.getElementById('mods-loading'), body=document.getElementById('mods-body'), + uns=document.getElementById('mods-unsupported'); + if(force){ load.style.display=''; body.style.display='none'; uns.style.display='none'; modsMsg(''); } + fetch(MOUNT+'/api/server/'+serverId+'/mods').then(r=>r.json()).then(function(d){ + if(d.error){ load.innerHTML=''+esc(d.error)+''; return; } + // This game has no LinuxGSM mods installer (e.g. Call of Duty) — hide the whole card. + if(d.supported===false){ var card=document.getElementById('mods-card'); if(card) card.style.display='none'; return; } + var avail=d.available||[], inst=d.installed||[]; + load.style.display='none'; + if(!avail.length && !inst.length){ uns.style.display=''; body.style.display='none'; return; } + var installedSet={}; inst.forEach(function(m){ installedSet[m.id]=1; }); + // One merged list: the available catalog is the superset; append any installed mod that isn't in + // the catalog so it stays removable. Each row shows Install or Remove for its current state. + var byId={}, merged=[]; + avail.concat(inst).forEach(function(m){ if(!byId[m.id]){ byId[m.id]=1; merged.push(m); } }); + document.getElementById('mods-list').innerHTML = + merged.length ? merged.map(function(m){ return modRow(m, !!installedSet[m.id]); }).join('') + : 'None available.'; + var n=inst.length; + document.getElementById('mods-count').textContent = + n ? (n+' installed · '+merged.length+' available') : (merged.length+' available'); + body.style.display=''; + }).catch(function(){ load.innerHTML='Could not load mods.'; }); +} +function modAction(which, id, btn){ + var run = function(){ + var orig=btn.innerHTML; btn.disabled=true; btn.innerHTML=''; + modsMsg((which==='install'?'Installing ':'Removing ')+id+'… this can take a moment.','text-secondary'); + fetch(MOUNT+'/api/server/'+serverId+'/mods',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:which,mod:id})}) + .then(r=>r.json()).then(function(d){ + modsMsg((d.success?'✓ ':'✗ ')+(d.message||''), d.success?'text-success':'text-danger'); + // The change only loads after a restart. If the panel deferred it (players online / can't + // confirm empty), offer a one-click force right here. + if(d.success && d.restart_pending){ + var m=document.getElementById('mods-msg'); + if(m){ m.insertAdjacentHTML('beforeend', + ' '); } + } + loadMods(false); + }) + .catch(function(){ modsMsg('✗ Action failed — connection error','text-danger'); btn.disabled=false; btn.innerHTML=orig; }); + }; + if(which==='remove'){ + confirmDialog({title:'Remove mod', icon:'trash', confirmClass:'btn-danger', confirmLabel:'Remove', + bodyText:'Remove '+id+' from this server?', onConfirm:run}); + } else { run(); } +} +function modRestartNow(btn){ + confirmDialog({title:'Restart server', icon:'arrow-clockwise', confirmClass:'btn-warning', confirmLabel:'Restart now', + bodyText:'Restart the server now to load the change?\n\nAnyone currently playing will be disconnected.', + onConfirm:function(){ + btn.disabled=true; btn.innerHTML=' Restarting…'; + fetch(MOUNT+'/api/server/'+serverId+'/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'restart'})}) + .then(r=>r.json()).then(function(d){ + modsMsg((d.success?'✓ Restarted — the change is now loaded.':'✗ '+(d.message||'Restart failed')), d.success?'text-success':'text-danger'); + }) + .catch(function(){ modsMsg('✗ Restart failed — connection error','text-danger'); }); + }}); +} +loadMods(true); + +// ── Backups (this server) — superadmin card; reuses the game-backup endpoints ── +function bkFmt(b){ b=b||0; if(b<1024)return b+' B'; if(b<1048576)return (b/1024).toFixed(0)+' KB'; if(b<1073741824)return (b/1048576).toFixed(1)+' MB'; if(b<1099511627776)return (b/1073741824).toFixed(1)+' GB'; return (b/1099511627776).toFixed(2)+' TB'; } +function bkWhen(sec){ try { return new Date(sec*1000).toLocaleString(); } catch(e){ return ''; } } +var _bkPoll = null, _bkDefault = {interval_days:0, keep:2}; + +function loadBackups(){ + if(!document.getElementById('backup-card')) return; + fetch(MOUNT+'/api/panel/backup/game/'+serverId+'/info') + .then(r=>r.json()).then(renderBackups) + .catch(function(){ /* transient error — keep whatever is already shown */ }); +} + +function renderBackups(d){ + if(!d || d.error) return; + var loading=document.getElementById('bk-loading'), wrap=document.getElementById('bk-wrap'); + if(loading) loading.style.display='none'; + if(wrap) wrap.style.display=''; + _bkDefault = d.default || _bkDefault; + var sc = d.schedule || {interval_days:0, keep:2, interval_set:false, keep_set:false}; + // Don't clobber a select the user is actively changing. + var iv=document.getElementById('bk-interval'), kp=document.getElementById('bk-keep'); + if(iv && document.activeElement!==iv) iv.value = sc.interval_set ? String(sc.interval_days) : 'default'; + if(kp && document.activeElement!==kp) kp.value = sc.keep_set ? String(sc.keep) : 'default'; + // Disk headroom + a rough projection for the retained set. + var keepEff=sc.keep, est=d.est_backup||0, disk=d.disk||{free:0,total:0}, parts=[]; + if(disk.total){ + var usedPct=Math.round((disk.total-disk.free)/disk.total*100); + parts.push(' '+bkFmt(disk.free)+' free of '+bkFmt(disk.total)+' ('+usedPct+'% used)'); + if(est){ + var proj=est*keepEff; + if(proj>disk.free) parts.push('~'+bkFmt(proj)+' needed to keep '+keepEff+', not enough space'); + else if(proj>disk.free*0.5) parts.push('~'+bkFmt(proj)+' to keep '+keepEff+''); + } + } + var defTxt=(_bkDefault.interval_days>0)?('every '+_bkDefault.interval_days+'d, keep '+_bkDefault.keep):'off'; + parts.push('Panel default: '+defTxt+''); + var dk=document.getElementById('bk-disk'); if(dk) dk.innerHTML=parts.join(' · '); + // Live status of any in-flight/last backup. + var st=document.getElementById('bk-status'), s=d.status; + if(st){ + if(s && s.running){ st.className='small text-info'; st.innerHTML=' Backing up…'; } + else if(s && s.busy){ st.className='small text-warning'; st.innerHTML=' '+esc(s.msg||'players online — waiting')+' '; } + else if(s && s.ok===true){ st.className='small text-success'; st.innerHTML=' '+esc(s.msg||'Backed up'); } + else if(s && s.ok===false){ st.className='small text-danger'; st.innerHTML=' '+esc(s.msg||'Backup failed'); } + else st.textContent=''; + } + var nowBtn=document.getElementById('bk-now'); if(nowBtn) nowBtn.disabled=!!(s && s.running); + // Backup rows. + var rows=(d.backups||[]).map(function(b){ + var ip=b.in_progress; + return '' + + ''+esc(b.name)+''+(ip?' in progress':'')+'' + + ''+bkFmt(b.size)+'' + + ''+esc(bkWhen(b.created))+'' + + '' + + (ip?'':(' ' + + '')) + + ''; + }); + var tb=document.getElementById('bk-rows'); + if(tb) tb.innerHTML = rows.length ? rows.join('') : 'No backups yet.'; + // Poll while a backup is running; stop once it finishes. + var running=s && s.running; + if(running && !_bkPoll) _bkPoll=setInterval(loadBackups, 4000); + if(!running && _bkPoll){ clearInterval(_bkPoll); _bkPoll=null; } +} + +function saveBkSchedule(){ + var iv=document.getElementById('bk-interval').value, kp=document.getElementById('bk-keep').value; + fetch(MOUNT+'/api/panel/backup/game/'+serverId+'/schedule',{method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({interval:iv, keep:kp})}) + .then(r=>r.json()).then(function(d){ + if(d.success){ window.toast('Backup schedule saved','success'); loadBackups(); } + else window.toast('Could not save schedule','danger'); + }).catch(function(){ window.toast('Could not save schedule','danger'); }); +} + +function backupNow(force){ + var btn=document.getElementById('bk-now'); if(btn) btn.disabled=true; + fetch(MOUNT+'/api/panel/backup/game/'+serverId,{method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({force:!!force})}) + .then(r=>r.json()).then(function(d){ + window.toast(d.message || (d.success?'Backup started':'Could not start backup'), d.success?'success':'warning'); + setTimeout(loadBackups, 800); + if(!_bkPoll) _bkPoll=setInterval(loadBackups, 4000); // watch it complete + }) + .catch(function(){ window.toast('Could not start backup','danger'); if(btn) btn.disabled=false; }); +} + +function deleteBk(btn){ + var name = btn.getAttribute('data-name') || ''; + confirmDialog({title:'Delete backup', icon:'trash', confirmClass:'btn-danger', confirmLabel:'Delete', + bodyText:'Delete this backup?\n\n'+name, + onConfirm:function(){ + btn.disabled=true; + fetch(MOUNT+'/api/panel/backup/game/'+serverId+'/delete',{method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({name:name})}) + .then(r=>r.json()).then(function(d){ + window.toast(d.message || (d.success?'Deleted':'Delete failed'), d.success?'success':'danger'); + loadBackups(); + }).catch(function(){ window.toast('Delete failed','danger'); btn.disabled=false; }); + }}); +} + +loadBackups(); diff --git a/templates/dashboard.html b/templates/dashboard.html index 5e80965..f1657ca 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -268,515 +268,5 @@
No Game Servers Found
{% endblock %} {% block scripts %} - + {% endblock %} diff --git a/templates/manage_remotes.html b/templates/manage_remotes.html index 638374f..0b59b30 100644 --- a/templates/manage_remotes.html +++ b/templates/manage_remotes.html @@ -248,617 +248,5 @@

{% endfor %} - + {% endblock %} \ No newline at end of file diff --git a/templates/manage_servers.html b/templates/manage_servers.html index e405420..fa16366 100644 --- a/templates/manage_servers.html +++ b/templates/manage_servers.html @@ -255,316 +255,7 @@

Manage Game S {% endblock %} {% block scripts %} - + + {% endblock %} diff --git a/templates/remote_manage.html b/templates/remote_manage.html index 5505ed4..9b2fc11 100644 --- a/templates/remote_manage.html +++ b/templates/remote_manage.html @@ -732,1319 +732,15 @@

{{ server.name }}

{% block scripts %} +{# Server-rendered values for server_detail.js. Kept inline (and BEFORE it, so its top-level code can + read them) while the rest of the page script becomes a cacheable file. #} + {% endblock %} diff --git a/templates/server_files.html b/templates/server_files.html index 879e825..92f53be 100644 --- a/templates/server_files.html +++ b/templates/server_files.html @@ -290,725 +290,10 @@

Files & {% endblock %} {% block scripts %} +{# Server-rendered values for server_files.js. Kept inline (and BEFORE it, so its top-level code can + read them) while the rest of the page script becomes a cacheable file. #} + {% endblock %} diff --git a/tests/smoke_test.py b/tests/smoke_test.py index ac63813..e119271 100644 --- a/tests/smoke_test.py +++ b/tests/smoke_test.py @@ -1262,18 +1262,21 @@ def _detail_panels(html): c.post("/api/account/ui-order", json={"panels": {"detail_console": ["controls", "console", "players"]}, "hidden": {"detail_console": ["controls"]}}) _no_ctrl = c.get("/server/%d" % gs_id).get_data(as_text=True) + # The page's own script is a cacheable file now, so the JS assertions below have to follow the + # reference. The markup check above stays on the HTML alone — that is what it is about. + _no_ctrl_js = page_with_assets(c, "/server/%d" % gs_id) check("detail: the server page offers the same edit-mode toggle", 'data-action="toggleLayoutEdit"' in _det) check("detail: hiding Controls removes the stats canvas", 'id="stats-chart"' not in _no_ctrl) check("detail: initChart is guarded against the missing canvas", - "var canvas = document.getElementById('stats-chart');\n if (!canvas) return;" in _no_ctrl) + "var canvas = document.getElementById('stats-chart');\n if (!canvas) return;" in _no_ctrl_js) # "initChart();" (the CALL) — "function initChart() {" is a different string, so this anchors on # the bootstrap, not the definition. check("detail: the undo-a-hide handlers are defined BEFORE the bootstrap calls", - "initChart();" in _no_ctrl - and _no_ctrl.index("window.showDetailPanel = function") < _no_ctrl.index("initChart();"), + "initChart();" in _no_ctrl_js + and _no_ctrl_js.index("window.showDetailPanel = function") < _no_ctrl_js.index("initChart();"), "showDetailPanel at %s, initChart() call at %s" - % (_no_ctrl.find("window.showDetailPanel = function"), _no_ctrl.find("initChart();"))) + % (_no_ctrl_js.find("window.showDetailPanel = function"), _no_ctrl_js.find("initChart();"))) c.post("/api/account/ui-order", json={"hidden": {"detail_console": []}}) # Each page only knows its OWN regions, so the endpoint must merge rather than replace the map. # Before this was fixed, saving on the dashboard deleted the server page's layout and vice versa.