const $ = id => document.getElementById(id); let state = null, settingsSpec = null, rotation = [], knownMaps = [], pollTimer = null; let activeTab = "chat"; // The console sits in an iframe on the game page and keeps running when the // modal is hidden. Polling and re-rendering there steals main-thread time from // the game, so both are suspended while it is not on screen. let consoleVisible = true; function show(el, text, kind) { el.textContent = text; el.className = text ? "msg show " + kind : "msg"; } function esc(s) { return String(s).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } function mb(bytes) { return (bytes / 1048576).toFixed(0) + " MB"; } async function api(path, body) { const res = await fetch(path, body ? { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), } : {}); let data; try { data = await res.json(); } catch { throw new Error("HTTP " + res.status); } if (!res.ok || data.error) throw new Error(data.error || "HTTP " + res.status); return data; } // --- confirmation modal ---------------------------------------------------- // Replaces window.confirm so destructive actions never raise a browser dialog. let confirmResolve = null; function askConfirm(text, label) { $("confirmtext").textContent = text; $("confirmyes").textContent = label || "Confirm"; $("confirm").hidden = false; $("confirmyes").focus(); return new Promise(resolve => { confirmResolve = resolve; }); } function settleConfirm(answer) { if (!confirmResolve) return; const resolve = confirmResolve; confirmResolve = null; $("confirm").hidden = true; resolve(answer); } function confirmOpen() { return confirmResolve !== null; } $("confirmyes").onclick = () => settleConfirm(true); $("confirmno").onclick = () => settleConfirm(false); $("confirm").addEventListener("mousedown", e => { if (e.target === $("confirm")) settleConfirm(false); // click outside the box }); // --- views ----------------------------------------------------------------- function view(name) { $("signin").hidden = name !== "signin"; $("pwpanel").hidden = name !== "password"; $("main").hidden = name === "signin" || name === "password"; $("signout").hidden = !isAdmin; $("changepw").hidden = !isAdmin; $("adminlogin").hidden = isAdmin || name === "signin"; document.querySelectorAll("[data-admin]").forEach(b => (b.hidden = !isAdmin)); applyPolling(name === "main"); } function applyPolling(active) { const want = active && consoleVisible; if (want && isAdmin && !pollTimer) pollTimer = setInterval(refresh, 15000); if ((!want || !isAdmin) && pollTimer) { clearInterval(pollTimer); pollTimer = null; } if (want && !publicTimer) publicTimer = setInterval(refreshPublic, 10000); if (!want && publicTimer) { clearInterval(publicTimer); publicTimer = null; } } window.addEventListener("message", e => { if (e.origin !== window.location.origin) return; if (e.data && e.data.type === "qadmin-visible") { consoleVisible = !!e.data.visible; applyPolling(!$("main").hidden); if (consoleVisible) { refreshPublic(); if (isAdmin) refresh(); } } }); function tab(name) { if (!isAdmin && name !== "chat" && name !== "stats") name = "chat"; activeTab = name; document.querySelectorAll(".tabs button").forEach(b => b.setAttribute("aria-selected", String(b.dataset.tab === name))); ["chat", "stats", "server", "match", "players", "maps", "log"].forEach(t => { $("tab-" + t).hidden = t !== name; }); if (name === "chat") { refreshPublic(); selectThread(activeThread); } if (name === "server" && state) fillMapPicker(); if (name === "server") loadBackups(); if (name === "players" && state) { renderPlayers(state.players); fillBots(state.bots); fillBotRoom(state.bot_room); } if (name === "maps" && state) { renderRotation(); renderMaps(); } if (name === "match" && !settingsSpec) loadSettings(); if (name === "match") loadPresets(); if (name === "players") loadBans(); if (name === "log") { loadLog(); loadAudit(); loadCrashes(); } if (name === "stats") loadStats(); } document.querySelectorAll(".tabs button").forEach(b => (b.onclick = () => tab(b.dataset.tab))); // --- auth ------------------------------------------------------------------ async function start() { $("myname").value = savedName(); try { $("lowdetail").checked = localStorage.getItem(DETAIL_KEY) === "1"; } catch {} let session = {}; try { session = await api("api/session"); isAdmin = !!session.authenticated; } catch { isAdmin = false; } view("main"); tab("chat"); await refreshPublic(); if (isAdmin) await refresh(); } $("adminlogin").onclick = () => { view("signin"); $("pass").focus(); }; $("signincancel").onclick = () => { view("main"); tab("chat"); }; $("signinform").onsubmit = async e => { e.preventDefault(); $("signinbtn").disabled = true; try { await api("api/login", { user: $("user").value, password: $("pass").value }); $("pass").value = ""; show($("signinmsg"), "", "ok"); isAdmin = true; view("main"); tab("server"); await refresh(); } catch (err) { show($("signinmsg"), err.message, "err"); } finally { $("signinbtn").disabled = false; } }; $("signout").onclick = async () => { try { await api("api/logout", {}); } catch {} isAdmin = false; view("main"); tab("chat"); }; $("changepw").onclick = () => { ["pwcur", "pwnew", "pwconf"].forEach(id => ($(id).value = "")); show($("pwmsg"), "", "ok"); view("password"); $("pwcur").focus(); }; $("pwcancel").onclick = () => { view("main"); tab("server"); }; $("pwform").onsubmit = async e => { e.preventDefault(); if ($("pwnew").value !== $("pwconf").value) return show($("pwmsg"), "New passwords do not match.", "err"); if ($("pwnew").value.length < 8) return show($("pwmsg"), "New password must be at least 8 characters.", "err"); try { await api("api/password", { current: $("pwcur").value, new: $("pwnew").value }); isAdmin = false; view("signin"); show($("signinmsg"), "Password changed. Sign in again.", "ok"); $("pass").focus(); } catch (err) { show($("pwmsg"), err.message, "err"); } }; // --- server tab ------------------------------------------------------------ function setShot(img, fallback, name) { if (!name) { img.hidden = true; if (fallback) fallback.hidden = false; return; } img.onload = () => { img.hidden = false; if (fallback) fallback.hidden = true; }; img.onerror = () => { img.hidden = true; if (fallback) fallback.hidden = false; }; img.src = "api/levelshot/" + encodeURIComponent(name); } function mapsForCurrentType() { if (!state) return []; const type = (state.gametypeKey || "").toLowerCase(); if (!type) return state.maps; const fits = state.maps.filter(m => { const types = (state.arenas[m] || {}).types || []; return types.length === 0 || types.includes(type); }); return fits.length ? fits : state.maps; } function fillMapPicker() { const list = mapsForCurrentType(); if (JSON.stringify(list) === JSON.stringify(knownMaps)) return; knownMaps = list; const current = $("mapsel").value; $("mapsel").innerHTML = list.map(m => { const long = (state.arenas[m] || {}).longname; return '"; }).join(""); if (list.includes(current)) $("mapsel").value = current; else if (state.map && list.includes(state.map)) $("mapsel").value = state.map; setShot($("pickshot"), null, $("mapsel").value); } $("mapsel").onchange = () => setShot($("pickshot"), null, $("mapsel").value); $("mapgo").onclick = async () => { const map = $("mapsel").value; show($("mapmsg"), "Loading " + map + "...", "work"); try { await api("api/map", { map }); setTimeout(refresh, 2500); show($("mapmsg"), "Switched to " + map + ".", "ok"); } catch (e) { show($("mapmsg"), e.message, "err"); } }; $("restart").onclick = async () => { if (!await askConfirm("Restart the game server? Connected players will be dropped.", "Restart")) return; show($("mapmsg"), "Restarting...", "work"); try { await api("api/restart", {}); setTimeout(refresh, 9000); show($("mapmsg"), "Restart sent; the server comes back in a few seconds.", "ok"); } catch (e) { show($("mapmsg"), e.message, "err"); } }; $("saygo").onclick = async () => { const message = $("saytext").value.trim(); if (!message) return; try { await api("api/say", { message }); $("saytext").value = ""; show($("saymsg"), "Sent.", "ok"); } catch (e) { show($("saymsg"), e.message, "err"); } }; $("joinbtn").onclick = () => { // The client reads "+set" arguments from the query string, so a reserved-slot // password can be handed straight to the game. const pw = (settingsSpec && settingsSpec.sv_privatePassword || {}).value; window.open(location.origin + launchArgs(pw ? ["set password " + pw] : []), "_blank"); }; // --- players tab ----------------------------------------------------------- function renderPlayers(players) { const host = $("players"); if (!players.length) { host.innerHTML = '
Nobody connected.
'; return; } // How many connected players share each address. A ban matches the address, // so banning a shared one takes every player behind it - which is what used // to happen to everybody at once, before the server learned to read // X-Forwarded-For and see past the proxy. const sharing = {}; players.forEach(p => { if (p.address) sharing[p.address] = (sharing[p.address] || 0) + 1; }); host.innerHTML = "| Name | Score | Ping | Address | |
|---|---|---|---|---|
| " + esc(p.name) + " " + (p.bot ? 'bot' : 'human') + " | " + p.score + " | " + (p.bot ? "-" : p.ping) + " | " + (p.address ? esc(p.address) + (shared ? ' shared x' + sharing[p.address] + '' : "") : "-") + " | " + ' ' + '' + (p.address ? ' ' : "") + " |
| " + esc(b.ip) + " | " + esc(b.reason || "") + ' | ' + esc(remaining(b.remaining)) + (b.enforced ? "" : ' not enforced by this game module') + ' |
Nobody is banned.
'; $("banlist").querySelectorAll("[data-unban]").forEach(btn => { btn.onclick = async () => { try { await api("api/unban", { ip: btn.dataset.unban }); await loadBans(); } catch (e) { show($("banmsg"), e.message, "err"); } }; }); } catch (e) { /* the state poll surfaces auth failures */ } } $("bango").onclick = async () => { try { const r = await api("api/ban", banRequest($("banip").value.trim())); $("banip").value = ""; $("banreason").value = ""; show($("banmsg"), "Banned " + r.ip + " " + remaining(r.expires ? r.expires - Date.now() / 1000 : null) + ".", "ok"); await loadBans(); } catch (e) { show($("banmsg"), e.message, "err"); } }; // --- messenger ------------------------------------------------------------- // Threads are assembled from two sources: messages this console sent (recorded // server-side as they go out) and player chat tailed from the server log. A // console user is not a connected client, so a player cannot address a reply // back to them - replies arrive in the Everyone thread instead. const NAME_KEY = "qadmin.name"; const DETAIL_KEY = "qadmin.lowdetail"; // The server copes with a full game easily; the browser is the limit, because it // renders Quake in JS. These trim the client's per-frame work and cap its frame // rate so the render loop stops starving the network pump, which is what shows // up in game as "connection interrupted". const LOW_DETAIL = [ // Firing is the worst case: every rocket, plasma ball and lightning bolt casts // a dynamic light, and each impact leaves a mark and ejects brass. Those are // the first things to drop when the client cannot keep up. "set r_dynamiclight 0", "set cg_marks 0", "set cg_brassTime 0", "set cg_gibs 0", "set r_flares 0", "set cg_railTrailTime 0", // General render cost. "set r_vertexlight 1", "set r_picmip 3", "set r_fastsky 1", "set cg_shadows 0", // Cap the render loop so it stops starving the network pump. "set com_maxfps 60", ]; function launchArgs(extra) { const args = extra.slice(); if ($("lowdetail").checked) args.push.apply(args, LOW_DETAIL); return "/play.html?" + args.map(encodeURIComponent).join("&"); } const EVERYONE = " everyone"; let isAdmin = false; let publicTimer = null; let messages = []; let lastSeq = 0; let activeThread = EVERYONE; let roster = []; let unread = {}; function savedName() { try { return localStorage.getItem(NAME_KEY) || ""; } catch { return ""; } } function rememberName(name) { try { localStorage.setItem(NAME_KEY, name); } catch { /* private browsing */ } } function myName() { return ($("myname").value || "").trim() || "guest"; } function threadFor(m) { if (m.kind === "tell") return m.from; if (m.kind === "sent-pm") return m.to; return EVERYONE; } function isMine(m) { return m.kind === "sent" || m.kind === "sent-pm" || (m.from || "").toLowerCase() === myName().toLowerCase(); } function renderContacts() { // Humans only. A bot cannot read a private message, so listing them as // contacts is just noise. const isBot = name => (roster.find(p => p.name === name) || {}).bot === true; const names = roster.filter(p => !p.bot).map(p => p.name); messages.forEach(m => { const t = threadFor(m); if (t !== EVERYONE && !isBot(t) && names.indexOf(t) < 0) names.push(t); }); const rows = [{ id: EVERYONE, label: "Everyone", note: "public" }].concat( names.filter((n, i) => n && names.indexOf(n) === i) .map(n => ({ id: n, label: n, note: roster.some(p => p.name === n) ? "" : "away" }))); $("contactlist").innerHTML = rows.map(r => 'No messages yet.
'; if (pinned) host.scrollTop = host.scrollHeight; } // Only one poll in flight at a time. lastSeq only moves once a response has // arrived, so two overlapping polls would both ask from the same sequence // number and both append the same messages. let pollingMessages = false; async function pollMessages() { if (pollingMessages) return; pollingMessages = true; try { const d = await api("api/messages?since=" + lastSeq); // Belt and braces: never append anything already counted. const fresh = (d.messages || []).filter(m => m.seq > lastSeq); if (!fresh.length) return; fresh.forEach(m => { lastSeq = Math.max(lastSeq, m.seq); messages.push(m); const t = threadFor(m); const fromBot = (roster.find(p => p.name === t) || {}).bot === true; if (t !== activeThread && !isMine(m) && !fromBot) unread[t] = (unread[t] || 0) + 1; }); if (messages.length > 500) messages = messages.slice(-500); renderContacts(); renderThread(); } catch (e) { /* the public poll surfaces connectivity problems */ } finally { pollingMessages = false; } } async function refreshPublic() { try { const d = await api("api/public"); roster = d.players || []; renderContacts(); if (!isAdmin) { $("status").innerHTML = "" + esc(d.map || "?") + " · " + roster.length + " connected" + (d.hostname ? " · " + esc(d.hostname) : ""); } } catch (e) { /* keep the last known roster */ } await pollMessages(); } async function sendMessage() { const text = $("composetext").value.trim(); if (!text) return; const name = myName(); rememberName(name); try { if (activeThread === EVERYONE) { await api("api/chat", { name, message: text }); } else { const target = roster.find(p => p.name === activeThread); if (!target) return show($("chatmsg"), activeThread + " is not connected right now.", "err"); await api("api/pm", { name, to: target.num, message: text }); } $("composetext").value = ""; show($("chatmsg"), "", "ok"); await pollMessages(); } catch (e) { show($("chatmsg"), e.message, "err"); } } $("composesend").onclick = sendMessage; $("composetext").onkeydown = e => { if (e.key === "Enter") { e.preventDefault(); sendMessage(); } }; // Saving the name is playing under it. The client only takes a name at // launch, so over the game this reloads it - after asking, since it rejoins. // As a page of its own the console just remembers the name; the next launch // of the game applies it, from here or from the front page. $("namesave").onclick = async () => { const name = $("myname").value.trim(); if (!name) return show($("namemsg"), "Pick a name first.", "err"); rememberName(name); if (window.parent === window) return show($("namemsg"), "Saved - the game will use it next time you join.", "ok"); if (!await askConfirm("Reload the game as " + name + "? You will rejoin the server.", "Reload")) return show($("namemsg"), "Saved - the game will use it next time you join.", "ok"); window.parent.location = launchArgs(["set name " + name]); }; $("lowdetail").onchange = () => { try { localStorage.setItem(DETAIL_KEY, $("lowdetail").checked ? "1" : "0"); } catch {} show($("namemsg"), $("lowdetail").checked ? "Low detail will apply next time you load the game." : "Full detail will apply next time you load the game.", "ok"); }; // --- bots ------------------------------------------------------------------ function fillBots(bots) { if ($("botsel").options.length) return; $("botsel").innerHTML = bots.map(b => '").join(""); } // How many more fit right now: free client slots, or the game module's // memory ceiling, whichever is lower. The server refuses more either way; // this keeps the field honest. function fillBotRoom(room) { if (!room) return; const input = $("botcount"); input.max = Math.max(1, room.room); if (Number(input.value) > room.room) input.value = Math.max(1, room.room); $("botroom").textContent = room.room > 0 ? "up to " + room.room : "none fit"; $("botroom").title = room.slots + " of " + room.maxclients + " client slots free; the game module holds " + room.ceiling + " bots at most"; } $("botgo").onclick = async () => { try { const count = Number($("botcount").value) || 1; const r = await api("api/bot", { name: $("botsel").value, skill: Number($("botskill").value), count: count, }); show($("botmsg"), "Added " + r.count + " " + r.bot + (r.count === 1 ? "" : "s") + " at skill " + r.skill + ".", "ok"); setTimeout(refresh, 1500 + 400 * r.count); } catch (e) { show($("botmsg"), e.message, "err"); } }; $("botclear").onclick = async () => { const bots = (state ? state.players : []).filter(p => p.bot); if (!bots.length) return show($("botmsg"), "No bots connected.", "work"); if (!await askConfirm("Remove " + bots.length + " bot" + (bots.length === 1 ? "" : "s") + " from the server?", "Remove")) return; show($("botmsg"), "Removing " + bots.length + " bot(s)...", "work"); try { // Kick highest slot first: removing a client renumbers the ones above it. for (const b of bots.slice().sort((x, y) => y.num - x.num)) { await api("api/kick", { num: b.num }); } show($("botmsg"), "Removed. If bots reappear, lower Min players on the Match tab.", "ok"); setTimeout(refresh, 1800); } catch (e) { show($("botmsg"), e.message, "err"); } }; // --- match tab ------------------------------------------------------------- let presets = []; async function loadPresets() { if (presets.length) return; try { presets = (await api("api/presets")).presets; $("preset").innerHTML = presets.map(p => '").join(""); $("preset").onchange = () => { const p = presets.find(x => x.key === $("preset").value); $("presethint").textContent = p ? p.blurb : ""; }; $("preset").onchange(); } catch (e) { /* the state poll surfaces auth failures */ } } $("presetgo").onclick = async () => { const p = presets.find(x => x.key === $("preset").value); if (!p) return; show($("presetmsg"), "Applying " + p.label + "...", "work"); try { const r = await api("api/settings", { settings: p.values, restart: false }); // The game type is latched: it reads back old until the map restart the // change triggers has finished - seconds, more with a big pak - so wait // for it to land before showing the form again. if (r.reloaded) { const want = String(p.values.g_gametype); for (let i = 0; i < 15; i++) { await new Promise(done => setTimeout(done, 2000)); try { if ((await api("api/settings")).settings.g_gametype.value === want) break; } catch (e) { /* keep waiting */ } } } settingsSpec = null; await loadSettings(); show($("presetmsg"), p.label + " applied" + (r.reloaded ? "; the map restarted for the new game type" : "") + ". Fill the rotation on the Maps tab to match.", "ok"); } catch (e) { show($("presetmsg"), e.message, "err"); } }; const GROUPS = { rules: ["g_gametype", "timelimit", "fraglimit", "capturelimit"], network: ["sv_fps"], slots: ["sv_maxclients", "bot_minplayers", "sv_privateClients", "sv_privatePassword"], mutators: ["g_gravity", "g_speed", "g_quadfactor", "g_weaponrespawn", "g_friendlyfire", "g_inactivity"], identity: ["sv_hostname", "g_motd"], }; function field(name, spec) { const id = "set_" + name; const val = spec.value == null ? "" : spec.value; let control; if (name === "g_gametype") { control = '"; } else if (spec.kind === "int" && spec.min === 0 && spec.max === 1) { control = '"; } else { const type = spec.secret ? "password" : (spec.kind === "int" ? "number" : "text"); const bounds = spec.kind === "int" ? ' min="' + spec.min + '" max="' + spec.max + '"' : ""; // A secret never comes back from the server. Blank means keep it; the // checkbox is the only way to clear it, and sends an explicit null. const hint = spec.secret ? ' placeholder="' + (spec.present ? '(set - leave blank to keep)' : '(not set)') + '"' : ''; control = '' + (spec.secret && spec.present ? '' : ''); } return 'Nothing recorded yet.
'; return; } $(host).innerHTML = "| # | Name | Kills | Deaths | " + "K/D | Best | Matches |
|---|---|---|---|---|---|---|
| " + (i + 1) + " | " + esc(r.name) + " | " + r.kills + " | " + r.deaths + " | " + r.ratio + " | " + r.best + " | " + r.matches + " |
' + esc(e.message) + "
"; } } // --- log ------------------------------------------------------------------- async function loadAudit() { try { const entries = (await api("api/audit?limit=200")).entries.slice().reverse(); $("auditcount").textContent = entries.length ? entries.length + " entries" : ""; $("audit").innerHTML = entries.length ? '| ' + esc(new Date(e.at * 1000).toLocaleString()) + ' | ' + esc(e.actor) + ' | ' + esc(e.action.replace(/^\/api\//, "")) + " | " + esc(Object.keys(e.detail || {}).length ? JSON.stringify(e.detail) : "") + " |
Nothing yet.
'; } catch (e) { /* the state poll surfaces auth failures */ } } async function loadCrashes() { try { const { crashes } = await api("api/crashes"); $("crashcount").textContent = crashes.length ? crashes.length + " kept" : ""; $("crashes").innerHTML = crashes.length ? crashes.map(c => "" +
esc(c.tail.join("\n")) + "None recorded.
'; } catch (e) { /* the state poll surfaces auth failures */ } } async function loadLog() { try { const el = $("log"); // Follow new output unless the reader has scrolled up to look at something. const pinned = el.scrollHeight - el.scrollTop - el.clientHeight < 40; el.textContent = (await api("api/log")).log || "(empty)"; if (pinned) el.scrollTop = el.scrollHeight; } catch (e) { /* the state poll surfaces auth failures */ } } // --- backup ---------------------------------------------------------------- async function loadBackups() { try { const { backups, keep, every_hours } = await api("api/backups"); $("backuplist").innerHTML = (backups.length ? "Automatic: " + backups.map(b => '' + esc(b.name) + "").join(", ") : "No automatic backup yet") + " - written every " + every_hours + " h, " + keep + " kept in the state volume."; } catch (e) { /* the state poll surfaces auth failures */ } } $("backupnow").onclick = async () => { try { const r = await api("api/backups", {}); show($("backupmsg"), "Wrote " + r.name + ".", "ok"); await loadBackups(); } catch (e) { show($("backupmsg"), e.message, "err"); } }; $("exportbtn").onclick = () => { location.href = "api/export"; }; $("importbtn").onclick = () => $("importfile").click(); $("importfile").onchange = async () => { const file = $("importfile").files[0]; if (!file) return; show($("backupmsg"), "Importing " + file.name + "...", "work"); try { const r = await fetch("api/import", { method: "POST", body: await file.text(), credentials: "same-origin", headers: { "Content-Type": "application/json" } }); const data = await r.json(); if (!r.ok) throw new Error(data.error || r.statusText); const a = data.applied; show($("backupmsg"), "Imported " + (a.settings || 0) + " settings, " + (a.rotation || 0) + " rotation maps, " + (a.players || 0) + " players" + (a.credentials ? " and the password" : "") + ". " + data.note + ".", "ok"); rotation = []; // refilled from the next state poll if (data.reauth) { isAdmin = false; view("signin"); show($("signinmsg"), "The password came from the backup. Sign in with it.", "ok"); } } catch (e) { show($("backupmsg"), e.message, "err"); } $("importfile").value = ""; }; // --- shared refresh -------------------------------------------------------- const TYPE_KEYS = { "0": "ffa", "1": "tourney", "2": "single", "3": "team", "4": "ctf" }; async function refresh() { try { state = await api("api/state"); const gt = settingsSpec && settingsSpec.g_gametype ? settingsSpec.g_gametype.value : null; state.gametypeKey = TYPE_KEYS[gt] || ""; $("curmap").textContent = state.map || "unknown"; const meta = state.arenas[state.map] || {}; $("curmeta").textContent = [meta.longname, (meta.types || []).join(" ")] .filter(Boolean).join(" - "); setShot($("curshot"), $("curnoshot"), state.map); $("status").innerHTML = "" + esc(state.map || "?") + " · " + state.players.length + " connected · " + state.maps.length + " maps · " + mb(state.usage.bytes); if (activeTab === "server") fillMapPicker(); if (activeTab === "players") { renderPlayers(state.players); fillBots(state.bots); fillBotRoom(state.bot_room); } if (!rotation.length) rotation = state.rotation.slice(); if (activeTab === "maps") { renderRotation(); renderMaps(); } } catch (e) { if (/authentication required/i.test(e.message)) { isAdmin = false; view("main"); tab("chat"); return; } $("curmap").textContent = "unreachable"; show($("mapmsg"), e.message, "err"); } if (!$("tab-log").hidden) loadLog(); } // --- close the surrounding modal ------------------------------------------- // Keystrokes inside an iframe never reach the parent window, so the console has // to forward the close key itself or the overlay could only be closed by // clicking away from it. window.addEventListener("keydown", e => { const editable = /^(INPUT|TEXTAREA|SELECT)$/.test((e.target || {}).tagName || ""); const plain = e.code === "Backquote" && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey; // ` types a backtick, so leave it alone while a field has focus; Esc always closes. if (e.key === "Escape" && confirmOpen()) { e.preventDefault(); e.stopImmediatePropagation(); settleConfirm(false); return; } if (confirmOpen()) return; // keep the console open behind the confirmation if (e.key === "Escape" || (plain && !editable)) { e.preventDefault(); if (window.parent !== window) { window.parent.postMessage({ type: "qadmin-close" }, window.location.origin); } } }, true); start();