');
+ });
+}
+
+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 += '
');
+ });
+}
+
+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('
';
+ 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 = '
'; 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='
'
+ +'
When
Event
User
Detail
IP
'+rows+'
';
+ }).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 = '
';
+}
+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
+ ? '
'
+ + '
Backup file
Size
Actions
'
+ + ''+rows+'
'
+ : '
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 += '
';
+ });
+ 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 '
'
+ +' ';
+ }).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 '
'
+ + '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 = '
Installing OS updates
{% endblock %}
{% block scripts %}
+{# Server-rendered values for remote_manage.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_detail.html b/templates/server_detail.html
index 6015a94..4c8cec3 100644
--- a/templates/server_detail.html
+++ b/templates/server_detail.html
@@ -449,900 +449,16 @@
{{ 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.