'use strict'; // ---------- tiny helpers ---------- const $ = (s, el = document) => el.querySelector(s); function add(el, kids) { for (const k of kids.flat(Infinity)) { if (k == null || k === false) continue; el.append(k.nodeType ? k : document.createTextNode(String(k))); } } // h builds an element. Strings become text nodes, never HTML, so names from the game are safe. function h(tag, props, ...kids) { const el = document.createElement(tag); let value; for (const [k, v] of Object.entries(props || {})) { if (v == null || v === false) continue; if (k.startsWith('on')) el.addEventListener(k.slice(2), v); else if (k === 'class') el.className = v; else if (k === 'value') value = v; else if (k === 'checked') el.checked = true; else el.setAttribute(k, v === true ? '' : v); } add(el, kids); if (value !== undefined) el.value = value; return el; } const RANK = { viewer: 1, moderator: 2, admin: 3 }; const can = role => me && RANK[me.role] >= RANK[role]; let me = null; let servers = []; let view = null; // the page on screen: { el, update?, onEvent?, dispose? } let stream = null; async function api(method, path, body) { const opt = { method, headers: { 'X-Megalodon': '1' }, credentials: 'same-origin' }; if (body !== undefined) { opt.headers['Content-Type'] = 'application/json'; opt.body = JSON.stringify(body); } const resp = await fetch(path, opt); const data = await resp.json().catch(() => ({})); if (resp.status === 401 && me) { me = null; showLogin(); throw new Error('Signed out'); } if (!resp.ok) throw new Error(data.error || resp.statusText); return data; } function toast(msg, bad) { const t = h('div', { class: 'toast' + (bad ? ' bad' : '') }, msg); $('#toasts').append(t); setTimeout(() => t.remove(), bad ? 8000 : 4000); } const fail = e => toast(e.message || String(e), true); function fmtDur(sec) { sec = Math.max(0, Math.round(sec)); const d = Math.floor(sec / 86400), hr = Math.floor(sec % 86400 / 3600), m = Math.floor(sec % 3600 / 60); if (d) return `${d}d ${hr}h`; if (hr) return `${hr}h ${m}m`; if (m) return `${m}m`; return `${sec}s`; } const since = iso => iso ? fmtDur((Date.now() - new Date(iso)) / 1000) : ''; const clock = iso => new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); const stamp = iso => new Date(iso).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }); const serverName = id => (servers.find(s => s.id === id) || {}).name || id || ''; const steamLink = id => h('a', { href: 'https://steamcommunity.com/profiles/' + id, target: '_blank', rel: 'noopener noreferrer', class: 'mono' }, id); // ---------- dialogs ---------- // ask shows a form and resolves with its values, or null if cancelled. // Field types: text, password, number, textarea, lines, select, checkbox, multi, note. function ask(title, fields, okLabel = 'Save') { return new Promise(resolve => { const dlg = h('dialog'); const form = h('form'); const inputs = {}; const err = h('p', { class: 'err' }); add(form, [h('h2', null, title)]); for (const f of fields) { if (f.type === 'note') { add(form, [h('p', { class: 'muted' }, f.text)]); continue; } let input; if (f.type === 'select') { input = h('select', { value: f.value }, (f.options || []).map(o => typeof o === 'string' ? h('option', { value: o }, o) : h('option', { value: o.v }, o.l))); input.value = f.value ?? input.options[0]?.value; } else if (f.type === 'textarea' || f.type === 'lines') { input = h('textarea', { placeholder: f.placeholder, rows: 5 }); input.value = f.type === 'lines' ? (f.value || []).join('\n') : (f.value || ''); } else if (f.type === 'checkbox') { input = h('input', { type: 'checkbox', checked: !!f.value }); } else if (f.type === 'multi') { input = h('div', { class: 'multi' }, f.options.map(o => { const v = typeof o === 'string' ? o : o.v, l = typeof o === 'string' ? o : o.l; return h('label', null, h('input', { type: 'checkbox', value: v, checked: (f.value || []).includes(v) }), l); })); } else { input = h('input', { type: f.type || 'text', placeholder: f.placeholder, autocomplete: f.type === 'password' ? 'new-password' : 'off', step: f.type === 'number' ? 'any' : null }); input.value = f.value ?? ''; } inputs[f.name] = { f, input }; if (f.type === 'checkbox') add(form, [h('label', { class: 'check' }, input, f.label)]); else add(form, [h('label', null, f.label), input]); } const close = v => { dlg.close(); dlg.remove(); resolve(v); }; form.addEventListener('submit', e => { e.preventDefault(); const out = {}; for (const [name, { f, input }] of Object.entries(inputs)) { let v; if (f.type === 'checkbox') v = input.checked; else if (f.type === 'multi') v = [...input.querySelectorAll('input:checked')].map(i => i.value); else if (f.type === 'lines') v = input.value.split('\n').map(s => s.trim()).filter(Boolean); else if (f.type === 'number') v = input.value === '' ? null : Number(input.value); else v = input.value.trim(); if (f.required && (v === '' || v == null || (Array.isArray(v) && !v.length))) { err.textContent = `${f.label} is required.`; return; } out[name] = v; } close(out); }); add(form, [err, h('div', { class: 'buttons' }, h('button', { type: 'button', onclick: () => close(null) }, 'Cancel'), h('button', { class: 'primary', type: 'submit' }, okLabel))]); dlg.append(form); dlg.addEventListener('cancel', () => close(null)); document.body.append(dlg); dlg.showModal(); (dlg.querySelector('input:not([type=checkbox]), textarea, select') || dlg).focus(); }); } const confirmDlg = (title, text, ok = 'Yes') => ask(title, [{ type: 'note', text }], ok).then(Boolean); // ---------- shell ---------- const app = $('#app'); function showLogin() { stopAll(); const err = h('p', { class: 'err' }); const user = h('input', { type: 'text', placeholder: 'Username', autocomplete: 'username', required: true }); const pass = h('input', { type: 'password', placeholder: 'Password', autocomplete: 'current-password', required: true }); const form = h('form', { onsubmit: async e => { e.preventDefault(); try { await api('POST', '/api/login', { username: user.value, password: pass.value }); await boot(); } catch (x) { err.textContent = x.message; pass.value = ''; } } }, user, pass, h('button', { class: 'primary', type: 'submit' }, 'Sign in'), err); app.replaceChildren(h('div', { class: 'login panel' }, h('h1', null, 'Megalodon'), h('p', { class: 'muted' }, 'ARK server management'), form)); user.focus(); } let pollTimer = null; function stopAll() { clearInterval(pollTimer); if (stream) stream.close(); stream = null; view = null; } async function boot() { try { me = await api('GET', '/api/me'); } catch { me = null; return showLogin(); } servers = await api('GET', '/api/servers').catch(() => []); startStream(); clearInterval(pollTimer); pollTimer = setInterval(async () => { try { servers = await api('GET', '/api/servers'); view?.update?.(); } catch { /* shown by api() when signed out */ } }, 5000); route(); } function startStream() { if (stream) stream.close(); stream = new EventSource('/api/stream'); stream.onmessage = m => { try { view?.onEvent?.(JSON.parse(m.data)); } catch { /* ignore */ } }; } const NAV = [['#/', 'Overview', 'viewer'], ['#/players', 'Players', 'viewer'], ['#/bans', 'Bans', 'moderator'], ['#/schedules', 'Schedules', 'admin'], ['#/settings', 'Settings', 'admin']]; function shell(active, content) { const nav = h('nav', null, NAV.filter(n => can(n[2])).map(n => h('a', { href: n[0], class: n[0] === active ? 'on' : '' }, n[1]))); const bar = h('header', { class: 'bar' }, h('span', { class: 'brand' }, 'Megalodon'), nav, h('span', { class: 'muted small' }, `${me.name} · ${me.role}`), h('button', { class: 'small', onclick: async () => { await api('POST', '/api/logout').catch(() => { }); me = null; showLogin(); } }, 'Sign out')); app.replaceChildren(bar, h('main', null, content)); } function route() { const parts = (location.hash.slice(2) || '').split('/'); let page; switch (parts[0]) { case 's': page = serverPage(decodeURIComponent(parts[1] || ''), parts[2] || 'players'); shell('#/', page.el); break; case 'players': page = playersPage(); shell('#/players', page.el); break; case 'bans': page = can('moderator') ? bansPage() : overview(); shell('#/bans', page.el); break; case 'schedules': page = can('admin') ? schedulesPage() : overview(); shell('#/schedules', page.el); break; case 'settings': page = can('admin') ? settingsPage() : overview(); shell('#/settings', page.el); break; default: page = overview(); shell('#/', page.el); } view = page; page.update?.(); } window.addEventListener('hashchange', () => me && route()); // ---------- actions ---------- async function act(server, body, quiet) { try { const res = await api('POST', server ? `/api/servers/${encodeURIComponent(server)}/action` : '/api/actions', body); const bad = res.filter(r => !r.ok); if (bad.length) toast(bad.map(r => `${serverName(r.server)}: ${r.error}`).join('\n'), true); else if (!quiet) toast(res.length === 1 && res[0].output ? res[0].output : 'Done'); servers = await api('GET', '/api/servers'); view?.update?.(); return res; } catch (e) { fail(e); return null; } } async function banDialog(steamID, name, server) { const r = await ask(`Ban ${name || steamID}`, [ { name: 'steam_id', label: 'Steam ID', value: steamID, required: true }, { name: 'reason', label: 'Reason', placeholder: 'Shown in the ban list' }, { name: 'minutes', label: 'Length', type: 'select', value: '0', options: [{ v: '60', l: '1 hour' }, { v: '1440', l: '1 day' }, { v: '10080', l: '7 days' }, { v: '43200', l: '30 days' }, { v: '0', l: 'Permanent' }] }, { name: 'all', label: 'Ban on every server (recommended for a cluster)', type: 'checkbox', value: true }, ], 'Ban'); if (!r) return; const only = !r.all && server; act(only ? server : null, { action: 'ban', steam_id: r.steam_id, player: name, reason: r.reason, minutes: Number(r.minutes), servers: only ? [server] : [] }); } // ---------- event rows ---------- function eventRow(e, withServer = true) { const text = e.type === 'chat' ? [h('span', { class: 'who' }, e.actor || 'SERVER'), ': ', e.message] : [e.actor && e.type !== 'audit' ? e.actor + ': ' : '', e.type === 'audit' && e.actor ? e.actor + ' — ' : '', e.message]; return h('div', { class: 'ev' }, h('time', null, clock(e.time)), h('span', { class: 'tag ' + e.type }, e.type), withServer && e.server ? h('span', { class: 'srv muted' }, serverName(e.server)) : null, h('span', { class: 'txt' }, text)); } // ---------- sparkline ---------- function spark(canvas, samples) { const dpr = window.devicePixelRatio || 1; const w = canvas.clientWidth, hgt = canvas.clientHeight; canvas.width = w * dpr; canvas.height = hgt * dpr; const g = canvas.getContext('2d'); g.scale(dpr, dpr); const css = getComputedStyle(document.documentElement); const line = css.getPropertyValue('--accent').trim(), mute = css.getPropertyValue('--line').trim(), bad = css.getPropertyValue('--bad').trim(); g.clearRect(0, 0, w, hgt); if (!samples.length) return; const t1 = Date.now() / 1000, t0 = t1 - 86400; const max = Math.max(4, ...samples.map(s => s.n)); const x = t => (t - t0) / 86400 * w, y = n => hgt - 3 - n / max * (hgt - 6); g.strokeStyle = mute; g.beginPath(); g.moveTo(0, hgt - .5); g.lineTo(w, hgt - .5); g.stroke(); g.fillStyle = bad; samples.forEach(s => { if (!s.up) g.fillRect(x(s.t), hgt - 4, Math.max(2, w / 1440), 4); }); g.strokeStyle = line; g.lineWidth = 1.6; g.beginPath(); let pen = false; samples.forEach(s => { if (!s.up) { pen = false; return; } const px = x(s.t), py = y(s.n); pen ? g.lineTo(px, py) : g.moveTo(px, py); pen = true; }); g.stroke(); } // ---------- overview ---------- function statusPill(s) { if (s.restart) return h('span', { class: 'pill warn' }, s.restart.stop ? 'Shutting down soon' : 'Restarting soon'); if (s.online) return h('span', { class: 'pill ok' }, 'Online'); return h('span', { class: 'pill bad' }, s.held ? 'Stopped' : 'Offline'); } function overview() { const cards = h('div', { class: 'grid' }); const feed = h('div', { class: 'log' }); const tools = h('div', { class: 'row' }); if (can('moderator')) add(tools, [ h('button', { onclick: async () => { const r = await ask('Broadcast to every server', [{ name: 'message', label: 'Message', required: true }], 'Send'); if (r) act(null, { action: 'broadcast', message: r.message }); } }, 'Broadcast to all'), h('button', { onclick: () => act(null, { action: 'save' }) }, 'Save all worlds')]); if (can('admin')) add(tools, [h('button', { onclick: addServerDialog }, 'Add server')]); const samples = {}; async function loadSamples(id) { try { samples[id] = await api('GET', `/api/servers/${encodeURIComponent(id)}/samples`); drawSpark(id); } catch { /* ignore */ } } const canvases = {}; const drawSpark = id => canvases[id] && spark(canvases[id], samples[id] || []); let lastLoad = 0; const page = { el: h('div', { class: 'stack' }, h('div', { class: 'row between' }, h('h1', null, 'Servers'), tools), cards, h('div', { class: 'panel' }, h('h2', null, 'Recent activity'), feed)), update() { cards.replaceChildren(); if (!servers.length) cards.append(h('div', { class: 'empty panel' }, can('admin') ? 'No servers yet. Use "Add server" to connect one over RCON.' : 'No servers are configured.')); for (const s of servers) { const canvas = h('canvas'); canvases[s.id] = canvas; const info = s.info; cards.append(h('div', { class: 'card' }, h('h2', null, h('a', { href: '#/s/' + encodeURIComponent(s.id) }, s.name), statusPill(s)), h('div', { class: 'muted small' }, s.online ? [`${s.players.length}${info ? '/' + info.max_players : ''} players`, info?.map ? ' · ' + info.map : '', info?.version ? ' · v' + info.version : '', s.since ? ' · up ' + since(s.since) : ''] : (s.last_error ? s.last_error : 'Waiting for a response…')), canvas, h('div', { class: 'row small muted' }, s.online ? `RCON ${s.rtt_ms} ms` : '', s.peak ? ` · peak ${s.peak}` : '', h('span', { class: 'spacer' }), h('a', { href: '#/s/' + encodeURIComponent(s.id) }, 'Manage')))); requestAnimationFrame(() => drawSpark(s.id)); } if (Date.now() - lastLoad > 60000) { lastLoad = Date.now(); servers.forEach(s => loadSamples(s.id)); } }, onEvent(e) { if (e.type !== 'chat') { feed.prepend(eventRow(e)); while (feed.children.length > 40) feed.lastChild.remove(); } }, }; api('GET', '/api/events?limit=40&type=join,leave,online,offline,restart,start,ban,unban,kick,filter,watch,schedule,audit,error').then(evs => evs.forEach(e => feed.append(eventRow(e)))).catch(() => { }); return page; } async function addServerDialog(existing) { const e = existing || {}; const r = await ask(existing ? `Edit ${e.name}` : 'Add a server', [ { name: 'name', label: 'Name', value: e.name, required: true, placeholder: 'The Island' }, { name: 'host', label: 'Host or IP', value: e.host, required: true, placeholder: '192.168.1.20' }, { name: 'rcon_port', label: 'RCON port', type: 'number', value: e.rcon_port || 27020, required: true }, { name: 'password', label: existing ? 'RCON password (leave blank to keep)' : 'RCON password', type: 'password', required: !existing }, { name: 'query_port', label: 'Query port (UDP, 0 = off)', type: 'number', value: e.query_port ?? 27015 }, { name: 'start_command', label: 'Start command (optional, runs on this machine)', value: e.start_command, placeholder: 'docker start ark-island' }, { name: 'auto_start', label: 'Start it automatically when it has been down for a while', type: 'checkbox', value: e.auto_start }, ]); if (!r) return; try { const body = { ...r, rcon_port: r.rcon_port || 27020, query_port: r.query_port || 0 }; if (existing) await api('PUT', '/api/servers/' + encodeURIComponent(e.id), body); else await api('POST', '/api/servers', body); servers = await api('GET', '/api/servers'); view?.update?.(); toast('Saved'); } catch (x) { fail(x); } } // ---------- one server ---------- function serverPage(id, tab) { const get = () => servers.find(s => s.id === id); if (!get()) return { el: h('div', { class: 'empty panel' }, 'No such server. ', h('a', { href: '#/' }, 'Back to overview')) }; const head = h('div', { class: 'panel' }); const body = h('div'); const tabs = [['players', 'Players', 'viewer'], ['chat', 'Chat', 'viewer'], ['console', 'Console', 'admin'], ['events', 'Events', 'viewer']].filter(t => can(t[2])); if (!tabs.some(t => t[0] === tab)) tab = 'players'; const tabBar = h('div', { class: 'tabs' }, tabs.map(t => h('a', { href: `#/s/${encodeURIComponent(id)}/${t[0]}`, class: t[0] === tab ? 'on' : '' }, t[1]))); const sub = { players: playersTab, chat: chatTab, console: consoleTab, events: eventsTab }[tab](id, get); body.append(sub.el); function drawHead() { const s = get(); if (!s) return; const info = s.info; const ctl = h('div', { class: 'row' }); if (can('moderator')) add(ctl, [ h('button', { onclick: () => act(id, { action: 'save' }) }, 'Save world'), h('button', { onclick: async () => { const r = await ask('Broadcast', [{ name: 'message', label: 'Message to every player', required: true }], 'Send'); if (r) act(id, { action: 'broadcast', message: r.message }); } }, 'Broadcast')]); if (can('admin')) { add(ctl, [ s.restart ? h('button', { onclick: () => act(id, { action: 'cancel_restart' }) }, 'Cancel ' + (s.restart.stop ? 'shutdown' : 'restart')) : [ h('button', { onclick: () => restartDialog(id, false) }, 'Restart…'), h('button', { class: 'danger', onclick: () => restartDialog(id, true) }, 'Stop…')], s.can_start && !s.online ? h('button', { onclick: () => act(id, { action: 'start' }) }, 'Start') : null, h('button', { onclick: async () => { const r = await ask('Set time of day', [{ name: 'time', label: 'Time (HH:MM)', value: '12:00', required: true }]); if (r) act(id, { action: 'set_time', time: r.time }); } }, 'Set time'), h('button', { onclick: async () => { const r = await ask('Message of the day', [{ name: 'message', label: 'Shown to players when they join', required: true }]); if (r) act(id, { action: 'motd', message: r.message }); } }, 'MOTD'), h('button', { class: 'danger', onclick: async () => { if (await confirmDlg('Destroy wild creatures?', 'All wild creatures on this server will be removed and respawn.', 'Destroy')) act(id, { action: 'destroy_wild_dinos' }); } }, 'Wipe wild dinos'), h('button', { onclick: () => addServerDialog(s) }, 'Edit'), h('button', { class: 'danger', onclick: async () => { if (await confirmDlg('Remove ' + s.name + '?', 'Megalodon stops managing this server. The server itself is not touched.', 'Remove')) { await api('DELETE', '/api/servers/' + encodeURIComponent(id)).catch(fail); servers = await api('GET', '/api/servers'); location.hash = '#/'; } } }, 'Remove')]); } head.replaceChildren(...[ h('div', { class: 'row between' }, h('h1', null, s.name), statusPill(s)), h('div', { class: 'muted small' }, `${s.host}:${s.rcon_port}`, s.online ? ` · ${s.players.length}${info ? '/' + info.max_players : ''} players` : '', info?.map ? ' · ' + info.map : '', info?.version ? ' · v' + info.version : '', s.since ? ' · up ' + since(s.since) : '', s.online ? ` · RCON ${s.rtt_ms} ms` : ''), !s.online ? h('div', { class: 'banner bad' }, s.held ? 'Stopped from Megalodon.' : 'Not responding.', s.last_error ? ' ' + s.last_error : '', s.offline_since ? ' Down for ' + since(s.offline_since) + '.' : '') : null, s.restart ? h('div', { class: 'banner' }, `${s.restart.stop ? 'Shutdown' : 'Restart'} at ${clock(s.restart.at)} (in ${fmtDur((new Date(s.restart.at) - Date.now()) / 1000)}) — started by ${s.restart.by}`, s.restart.reason ? ' · ' + s.restart.reason : '') : null, ctl].filter(Boolean)); } return { el: h('div', null, h('p', { class: 'small' }, h('a', { href: '#/' }, '← All servers')), head, tabBar, body), update() { drawHead(); sub.update?.(); }, onEvent(e) { if (e.server === id) sub.onEvent?.(e); }, }; } async function restartDialog(id, stop) { const r = await ask(stop ? 'Stop the server' : 'Restart the server', [ { name: 'minutes', label: 'Countdown in minutes (players get warnings; 0 = now)', type: 'number', value: 5, required: true }, { name: 'reason', label: 'Reason (shown to players)', placeholder: 'Server update' }, ], stop ? 'Stop' : 'Restart'); if (r) act(id, { action: stop ? 'stop' : 'restart', minutes: r.minutes, reason: r.reason }); } function playersTab(id, get) { const table = h('div', { class: 'tablewrap panel' }); return { el: table, update() { const s = get(); if (!s?.players.length) { table.replaceChildren(h('div', { class: 'empty' }, s?.online ? 'Nobody is online.' : 'The server is offline.')); return; } table.replaceChildren(h('table', null, h('thead', null, h('tr', null, ['Player', 'Steam ID', 'Online for', ''].map(t => h('th', null, t)))), h('tbody', null, s.players.map(p => h('tr', null, h('td', null, p.name, p.banned ? h('span', { class: 'pill bad' }, ' banned') : null, p.watch ? h('span', { class: 'pill warn' }, ' watched') : null, p.notes ? h('div', { class: 'muted small' }, p.notes) : null), h('td', null, steamLink(p.steam_id)), h('td', null, since(p.joined_at) || 'just now'), h('td', { class: 'actions' }, can('moderator') ? [ h('button', { class: 'small', onclick: async () => { const r = await ask('Message to ' + p.name, [{ name: 'message', label: 'Private message', required: true }], 'Send'); if (r) act(id, { action: 'whisper', steam_id: p.steam_id, message: r.message }); } }, 'Message'), ' ', h('button', { class: 'small', onclick: async () => { if (await confirmDlg('Kick ' + p.name + '?', 'They can rejoin straight away.', 'Kick')) act(id, { action: 'kick', steam_id: p.steam_id }); } }, 'Kick'), ' ', h('button', { class: 'small danger', onclick: () => banDialog(p.steam_id, p.name, id) }, 'Ban'), ' ', h('button', { class: 'small', onclick: () => noteDialog(p.steam_id, p.name, p.notes, p.watch) }, 'Note')] : null)))))); }, }; } async function noteDialog(steamID, name, notes, watch) { const r = await ask('Notes on ' + name, [ { name: 'notes', label: 'Notes (visible to moderators)', type: 'textarea', value: notes }, { name: 'watch', label: 'Alert me when this player joins', type: 'checkbox', value: watch }]); if (r) api('PUT', '/api/players/' + steamID, r).then(() => toast('Saved')).catch(fail); } function chatTab(id) { const log = h('div', { class: 'log chat' }); const input = h('input', { type: 'text', placeholder: 'Message to everyone', maxlength: 300, class: 'grow' }); const line = e => { const near = log.scrollHeight - log.scrollTop - log.clientHeight < 60; log.append(h('div', { class: 'ev' }, h('time', null, clock(e.time)), h('span', { class: e.actor === 'SERVER' ? 'who srv' : 'who' }, e.actor || 'SERVER'), h('span', { class: 'txt' }, e.message, e.detail ? h('span', { class: 'muted small' }, ` (${e.detail})`) : null))); if (near) log.scrollTop = log.scrollHeight; }; api('GET', `/api/events?server=${encodeURIComponent(id)}&type=chat&limit=200`).then(evs => { evs.reverse().forEach(line); log.scrollTop = log.scrollHeight; }).catch(fail); const send = (action) => { const m = input.value.trim(); if (!m) return; act(id, { action, message: m }, true).then(() => { input.value = ''; }); }; return { el: h('div', { class: 'panel' }, log, can('moderator') ? h('form', { class: 'row mt', onsubmit: e => { e.preventDefault(); send('say'); } }, input, h('button', { class: 'primary', type: 'submit' }, 'Say as SERVER'), h('button', { type: 'button', onclick: () => send('broadcast') }, 'Broadcast')) : null), onEvent(e) { if (e.type === 'chat') line(e); }, }; } function consoleTab(id) { const out = h('pre', { class: 'out' }); const input = h('input', { type: 'text', list: 'cmds', placeholder: 'ListPlayers', class: 'grow', autocomplete: 'off', spellcheck: 'false' }); const list = h('datalist', { id: 'cmds' }, (me.reference || []).map(c => h('option', { value: c }))); let hist = []; try { hist = JSON.parse(localStorage.getItem('megalodon.history') || '[]'); } catch { /* none */ } let pos = hist.length; const print = (cls, text) => { out.append(h('div', { class: cls }, text)); out.scrollTop = out.scrollHeight; }; async function run(cmd) { print('cmd', '> ' + cmd); try { const res = await api('POST', `/api/servers/${encodeURIComponent(id)}/action`, { action: 'exec', command: cmd }); res.forEach(r => print(r.ok ? '' : 'bad', r.ok ? (r.output || '(no reply)') : r.error)); } catch (e) { print('bad', e.message); } } const macros = h('select', null, h('option', { value: '' }, 'Macro…'), (me.macros || []).map(m => h('option', { value: m.name }, m.name))); macros.addEventListener('change', async () => { const name = macros.value; macros.value = ''; if (!name) return; print('cmd', '> macro ' + name); try { (await api('POST', `/api/servers/${encodeURIComponent(id)}/macro`, { name })).forEach(r => print(r.ok ? '' : 'bad', r.ok ? (r.output || '(no reply)') : r.error)); } catch (e) { print('bad', e.message); } }); input.addEventListener('keydown', e => { if (e.key === 'ArrowUp' && pos > 0) { input.value = hist[--pos]; e.preventDefault(); } if (e.key === 'ArrowDown' && pos < hist.length) { input.value = hist[++pos] || ''; e.preventDefault(); } }); return { el: h('div', { class: 'panel' }, out, list, h('form', { class: 'row', onsubmit: e => { e.preventDefault(); const c = input.value.trim(); if (!c) return; if (hist[hist.length - 1] !== c) hist.push(c); hist = hist.slice(-100); pos = hist.length; try { localStorage.setItem('megalodon.history', JSON.stringify(hist)); } catch { /* private mode */ } input.value = ''; run(c); } }, input, h('button', { class: 'primary', type: 'submit' }, 'Run'), (me.macros || []).length ? macros : null), h('p', { class: 'muted small' }, 'Commands go straight to the server\'s console and are recorded in the event log.')), }; } function eventsTab(id) { const log = h('div', { class: 'log' }); const types = ['', 'join', 'leave', 'chat', 'online', 'offline', 'restart', 'ban', 'kick', 'filter', 'watch', 'schedule', 'audit', 'error'].filter(t => !['audit', 'error'].includes(t) || can('moderator')); const sel = h('select', null, types.map(t => h('option', { value: t }, t || 'All events'))); const q = h('input', { type: 'text', placeholder: 'Search' }); const more = h('button', { class: 'small' }, 'Older'); let oldest = 0; async function load(reset) { if (reset) { log.replaceChildren(); oldest = 0; } const qs = new URLSearchParams({ server: id, limit: 100 }); if (sel.value) qs.set('type', sel.value); if (q.value) qs.set('q', q.value); if (oldest) qs.set('before', oldest); try { const evs = await api('GET', '/api/events?' + qs); evs.forEach(e => { log.append(eventRow(e, false)); oldest = e.id; }); more.hidden = evs.length < 100; } catch (e) { fail(e); } } sel.addEventListener('change', () => load(true)); q.addEventListener('change', () => load(true)); more.addEventListener('click', () => load(false)); load(true); return { el: h('div', { class: 'panel' }, h('div', { class: 'row' }, sel, q), log, h('div', { class: 'row end' }, more)), onEvent(e) { if ((!sel.value || sel.value === e.type) && !q.value) log.prepend(eventRow(e, false)); }, }; } // ---------- players database ---------- function playersPage() { const body = h('div', { class: 'tablewrap' }); const q = h('input', { type: 'text', placeholder: 'Search by name or Steam ID' }); let all = []; const draw = () => { const f = q.value.toLowerCase(); const rows = all.filter(p => !f || p.name.toLowerCase().includes(f) || p.steam_id.includes(f)); body.replaceChildren(rows.length ? h('table', null, h('thead', null, h('tr', null, ['Player', 'Steam ID', 'Last seen', 'Play time', 'Joins', ''].map(t => h('th', null, t)))), h('tbody', null, rows.slice(0, 500).map(p => h('tr', null, h('td', null, p.name, p.watch ? h('span', { class: 'pill warn' }, ' watched') : null, p.notes ? h('div', { class: 'muted small' }, p.notes) : null), h('td', null, steamLink(p.steam_id)), h('td', null, stamp(p.last_seen), p.last_server ? h('span', { class: 'muted small' }, ' · ' + serverName(p.last_server)) : null), h('td', null, fmtDur(p.seconds)), h('td', null, p.joins), h('td', { class: 'actions' }, can('moderator') ? [h('button', { class: 'small', onclick: async () => { await noteDialog(p.steam_id, p.name, p.notes, p.watch); setTimeout(load, 300); } }, 'Note'), ' ', h('button', { class: 'small danger', onclick: () => banDialog(p.steam_id, p.name) }, 'Ban')] : null))))) : h('div', { class: 'empty' }, 'No players recorded yet.')); }; const load = () => api('GET', '/api/players').then(l => { all = l; draw(); }).catch(fail); q.addEventListener('input', draw); load(); return { el: h('div', { class: 'stack' }, h('div', { class: 'row between' }, h('h1', null, 'Players'), q), h('div', { class: 'panel' }, body)) }; } // ---------- bans ---------- function bansPage() { const body = h('div', { class: 'tablewrap' }); const load = () => api('GET', '/api/bans').then(list => { body.replaceChildren(list.length ? h('table', null, h('thead', null, h('tr', null, ['Player', 'Steam ID', 'Reason', 'By', 'Until', ''].map(t => h('th', null, t)))), h('tbody', null, list.map(b => h('tr', null, h('td', null, b.name || '—'), h('td', null, steamLink(b.steam_id)), h('td', null, b.reason || ''), h('td', null, b.by, h('div', { class: 'muted small' }, stamp(b.at))), h('td', null, b.expires ? stamp(b.expires) : 'Never', b.servers?.length ? h('div', { class: 'muted small' }, b.servers.map(serverName).join(', ')) : null), h('td', { class: 'actions' }, h('button', { class: 'small', onclick: async () => { if (await confirmDlg('Unban ' + (b.name || b.steam_id) + '?', 'They will be able to join again.', 'Unban')) { await act(null, { action: 'unban', steam_id: b.steam_id }); load(); } } }, 'Unban')))))) : h('div', { class: 'empty' }, 'No bans.')); }).catch(fail); load(); return { el: h('div', { class: 'stack' }, h('div', { class: 'row between' }, h('h1', null, 'Bans'), h('button', { onclick: async () => { await banDialog('', ''); setTimeout(load, 500); } }, 'Ban by Steam ID')), h('p', { class: 'muted small' }, 'Bans are kept by Megalodon and sent to every server. A banned player who shows up anywhere is removed again, even if that server was offline when the ban was made.'), h('div', { class: 'panel' }, body)) }; } // ---------- schedules ---------- const ACTIONS = [['save', 'Save the world'], ['broadcast', 'Broadcast a message'], ['restart', 'Restart with warnings'], ['command', 'Run a console command'], ['destroy_wild_dinos', 'Wipe wild dinos'], ['motd', 'Set the message of the day']]; const DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']; const serverOptions = () => servers.map(s => ({ v: s.id, l: s.name })); function describeJob(j) { const when = j.every ? 'every ' + j.every : 'at ' + j.at.join(', ') + (j.days?.length ? ' on ' + j.days.join(', ') : ' daily'); return `${(ACTIONS.find(a => a[0] === j.action) || [, j.action])[1]} · ${when} · ${j.servers?.length ? j.servers.map(serverName).join(', ') : 'all servers'}`; } function schedulesPage() { const body = h('div'); let st; const save = async () => { try { st = await api('PUT', '/api/settings', st); draw(); } catch (e) { fail(e); await load(); } }; async function edit(j) { const e = j || { enabled: true, action: 'save', every: '1h', at: [], days: [], servers: [] }; const r = await ask(j ? 'Edit job' : 'New job', [ { name: 'name', label: 'Name', value: e.name, required: true }, { name: 'action', label: 'Action', type: 'select', value: e.action, options: ACTIONS.map(a => ({ v: a[0], l: a[1] })) }, { name: 'servers', label: 'Servers (none ticked = all)', type: 'multi', options: serverOptions(), value: e.servers }, { name: 'every', label: 'Repeat every (e.g. 30m, 6h) — leave empty to use times of day', value: e.every }, { name: 'at', label: 'Times of day (HH:MM, comma separated)', value: (e.at || []).join(', ') }, { name: 'days', label: 'Only on these days (times of day only)', type: 'multi', options: DAYS, value: e.days }, { name: 'message', label: 'Message (broadcast / MOTD)', value: e.message }, { name: 'command', label: 'Console command', value: e.command }, { name: 'countdown', label: 'Restart countdown (e.g. 10m)', value: e.countdown }, { name: 'reason', label: 'Restart reason shown to players', value: e.reason }, { name: 'enabled', label: 'Enabled', type: 'checkbox', value: e.enabled }, ]); if (!r) return; r.at = r.at.split(',').map(s => s.trim()).filter(Boolean); const nj = { ...e, ...r }; st.jobs = j ? st.jobs.map(x => x.id === j.id ? nj : x) : [...st.jobs, nj]; save(); } function draw() { body.replaceChildren(st.jobs.length ? h('table', null, h('thead', null, h('tr', null, ['', 'Job', 'What', ''].map(t => h('th', null, t)))), h('tbody', null, st.jobs.map(j => h('tr', null, h('td', null, h('input', { type: 'checkbox', checked: j.enabled, title: 'Enabled', onchange: ev => { j.enabled = ev.target.checked; save(); } })), h('td', null, j.name), h('td', { class: 'muted small' }, describeJob(j)), h('td', { class: 'actions' }, h('button', { class: 'small', onclick: async () => { const r = await api('POST', `/api/jobs/${j.id}/run`).catch(fail); if (r) toast('Ran on ' + r.length + ' server(s)'); } }, 'Run now'), ' ', h('button', { class: 'small', onclick: () => edit(j) }, 'Edit'), ' ', h('button', { class: 'small danger', onclick: async () => { if (await confirmDlg('Delete ' + j.name + '?', '', 'Delete')) { st.jobs = st.jobs.filter(x => x.id !== j.id); save(); } } }, 'Delete')))))) : h('div', { class: 'empty' }, 'No scheduled jobs. Try a save every 30 minutes, or a daily restart at 04:00.')); } const load = () => api('GET', '/api/settings').then(s => { st = s; st.jobs ||= []; draw(); }).catch(fail); load(); return { el: h('div', { class: 'stack' }, h('div', { class: 'row between' }, h('h1', null, 'Schedules'), h('button', { class: 'primary', onclick: () => st && edit() }, 'New job')), h('p', { class: 'muted small' }, 'Times use the clock of the machine Megalodon runs on. Restarts warn players at 30, 15, 10, 5, 2 and 1 minute, then 30 and 10 seconds, and save the world first.'), h('div', { class: 'panel' }, body)) }; } // ---------- settings ---------- function settingsPage() { const root = h('div', { class: 'stack' }); let st; const save = async () => { try { st = await api('PUT', '/api/settings', st); toast('Saved'); draw(); } catch (e) { fail(e); await load(); } }; function section(title, hint, actions, content) { return h('div', { class: 'panel' }, h('div', { class: 'row between' }, h('h2', null, title), h('div', { class: 'row' }, actions)), hint ? h('p', { class: 'muted small' }, hint) : null, content); } const table = (cols, rows) => rows.length ? h('div', { class: 'tablewrap' }, h('table', null, h('thead', null, h('tr', null, cols.map(c => h('th', null, c)))), h('tbody', null, rows))) : h('div', { class: 'empty' }, 'Nothing yet.'); const EVENTS = ['join', 'leave', 'online', 'offline', 'restart', 'start', 'ban', 'unban', 'kick', 'filter', 'watch', 'schedule', 'error', 'audit', 'chat']; async function editNotify(n) { const e = n || { kind: 'discord', events: [], servers: [] }; const r = await ask(n ? 'Edit notification' : 'Add a notification', [ { name: 'name', label: 'Name', value: e.name, required: true }, { name: 'kind', label: 'Kind', type: 'select', value: e.kind, options: [{ v: 'discord', l: 'Discord webhook' }, { v: 'ntfy', l: 'ntfy topic URL' }, { v: 'json', l: 'Generic webhook (JSON)' }] }, { name: 'url', label: 'URL', value: e.url, required: true }, { name: 'events', label: 'Events (none ticked = joins, leaves, up/down, restarts, bans)', type: 'multi', options: EVENTS, value: e.events }, { name: 'servers', label: 'Servers (none ticked = all)', type: 'multi', options: serverOptions(), value: e.servers }, ]); if (!r) return; st.notify = n ? st.notify.map(x => x === n ? r : x) : [...st.notify, r]; save(); } async function editMacro(m) { const e = m || { role: 'admin', commands: [] }; const r = await ask(m ? 'Edit macro' : 'Add a macro', [ { name: 'name', label: 'Name', value: e.name, required: true }, { name: 'commands', label: 'Console commands, one per line', type: 'lines', value: e.commands, required: true }, { name: 'role', label: 'Who may run it', type: 'select', value: e.role, options: ['moderator', 'admin'] }, ]); if (!r) return; st.macros = m ? st.macros.map(x => x === m ? r : x) : [...st.macros, r]; save(); } async function editUser(u) { const r = await ask(u ? 'Edit ' + u.name : 'Add a user', [ u ? { type: 'note', text: 'Leave the password empty to keep it.' } : { name: 'name', label: 'Username', required: true }, { name: 'role', label: 'Role', type: 'select', value: u?.role || 'viewer', options: ['viewer', 'moderator', 'admin'] }, { name: 'password', label: 'Password (8+ characters)', type: 'password', required: !u }, ].filter(Boolean)); if (!r) return; try { await api('POST', '/api/users', { name: u?.name || r.name, role: r.role, password: r.password }); toast('Saved'); load(); } catch (e) { fail(e); } } async function newToken() { const r = await ask('Create an API token', [ { name: 'name', label: 'Name', required: true, placeholder: 'prometheus' }, { name: 'role', label: 'Role', type: 'select', value: 'viewer', options: ['viewer', 'moderator', 'admin'] }, ], 'Create'); if (!r) return; try { const { token } = await api('POST', '/api/tokens', r); await ask('Copy your token now', [{ type: 'note', text: 'It is shown only once. Send it as "Authorization: Bearer ".' }, { name: 't', label: 'Token', value: token }], 'Done'); load(); } catch (e) { fail(e); } } async function draw() { const [users, tokens] = await Promise.all([api('GET', '/api/users'), api('GET', '/api/tokens')]); const w = st.welcome, f = st.chat_filter; root.replaceChildren(h('h1', null, 'Settings'), section('Notifications', 'Send events to Discord, ntfy or any webhook.', h('button', { onclick: () => editNotify() }, 'Add'), table(['Name', 'Kind', 'Events', ''], st.notify.map(n => h('tr', null, h('td', null, n.name), h('td', null, n.kind), h('td', { class: 'muted small' }, n.events?.length ? n.events.join(', ') : 'default'), h('td', { class: 'actions' }, h('button', { class: 'small', onclick: () => api('POST', '/api/notify/test', { name: n.name }).then(() => toast('Sent')).catch(fail) }, 'Test'), ' ', h('button', { class: 'small', onclick: () => editNotify(n) }, 'Edit'), ' ', h('button', { class: 'small danger', onclick: () => { st.notify = st.notify.filter(x => x !== n); save(); } }, 'Delete')))))), section('Welcome message', 'Whispered to each player a few seconds after they join. {name}, {server} and {players} are filled in.', h('button', { onclick: async () => { const r = await ask('Welcome message', [{ name: 'enabled', label: 'Enabled', type: 'checkbox', value: w.enabled }, { name: 'message', label: 'Message', value: w.message }]); if (r) { st.welcome = r; save(); } } }, 'Edit'), h('p', null, w.enabled ? '“' + w.message + '”' : h('span', { class: 'muted' }, 'Off'))), section('Chat filter', 'Watches player chat for words you list. Players are warned, and after enough strikes kicked or banned.', h('button', { onclick: async () => { const r = await ask('Chat filter', [{ name: 'enabled', label: 'Enabled', type: 'checkbox', value: f.enabled }, { name: 'words', label: 'Words, one per line', type: 'lines', value: f.words }, { name: 'action', label: 'After the strikes', type: 'select', value: f.action, options: [{ v: 'warn', l: 'Only warn' }, { v: 'kick', l: 'Kick' }, { v: 'ban', l: 'Temporary ban' }] }, { name: 'strikes', label: 'Strikes before the action', type: 'number', value: f.strikes }, { name: 'ban_minutes', label: 'Ban length in minutes', type: 'number', value: f.ban_minutes }]); if (r) { st.chat_filter = r; save(); } } }, 'Edit'), h('p', null, f.enabled ? `${f.words.length} word(s) · ${f.action} after ${f.strikes} strikes` : h('span', { class: 'muted' }, 'Off'))), section('Macros', 'Named lists of console commands you can run from a server\'s Console tab.', h('button', { onclick: () => editMacro() }, 'Add'), table(['Name', 'Commands', 'Who', ''], st.macros.map(m => h('tr', null, h('td', null, m.name), h('td', { class: 'mono' }, m.commands.join(' ; ')), h('td', null, m.role), h('td', { class: 'actions' }, h('button', { class: 'small', onclick: () => editMacro(m) }, 'Edit'), ' ', h('button', { class: 'small danger', onclick: () => { st.macros = st.macros.filter(x => x !== m); save(); } }, 'Delete')))))), section('Users', 'People who can sign in. Viewers look, moderators handle players, admins run everything.', h('button', { onclick: () => editUser() }, 'Add'), table(['User', 'Role', ''], users.map(u => h('tr', null, h('td', null, u.name), h('td', null, u.role), h('td', { class: 'actions' }, h('button', { class: 'small', onclick: () => editUser(u) }, 'Edit'), ' ', h('button', { class: 'small danger', onclick: async () => { if (await confirmDlg('Delete ' + u.name + '?', '', 'Delete')) api('DELETE', '/api/users/' + encodeURIComponent(u.name)).then(load).catch(fail); } }, 'Delete')))))), section('API tokens', 'For scripts and monitoring. Prometheus can scrape /metrics with a viewer token.', h('button', { onclick: newToken }, 'Create'), table(['Name', 'Role', ''], tokens.map(t => h('tr', null, h('td', null, t.name), h('td', null, t.role), h('td', { class: 'actions' }, h('button', { class: 'small danger', onclick: async () => { if (await confirmDlg('Delete token ' + t.name + '?', 'Anything using it stops working.', 'Delete')) api('DELETE', '/api/tokens/' + encodeURIComponent(t.name)).then(load).catch(fail); } }, 'Delete'))))))); } const load = () => api('GET', '/api/settings').then(s => { st = s; st.notify ||= []; st.macros ||= []; st.jobs ||= []; st.chat_filter.words ||= []; return draw(); }).catch(fail); load(); return { el: root }; } boot();