Megalodon: manage ARK servers over RCON from a web dashboard
CI / test (push) Successful in 2m26s
Release / release (push) Successful in 2m36s

Watches any number of ARK:SE servers (RCON for players and chat, the query
port for map, version and player cap), records joins, leaves, chat and an
audit trail, and keeps a player database across servers.

Control: save, broadcast, chat, whisper, kick, time of day, MOTD, wild dino
wipe, a raw console with history and macros, restarts with timed warnings and
a world save, optional start command and auto-start. Bans are kept by
Megalodon: timed, cluster-wide and re-enforced when the player returns.

Automation: schedules (every N or at times of day), Discord/ntfy/webhook
notifications, welcome message, chat word filter.

Access: viewer, moderator and admin roles, API tokens, Prometheus metrics.
cmd/fakeark simulates a server for trying it out.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-30 23:36:13 -06:00
commit 0d2cb31abf
32 files changed
+6280

No files matched your search

+656
View File
@@ -0,0 +1,656 @@
'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 <token>".' }, { 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();