Files
Megalodon/internal/web/static/app.js
T
VeillaxandClaude Sonnet 5.5 88487d9744
CI / test (push) Successful in 2m15s
Release / release (push) Successful in 2m51s
Restyle the dashboard to the Lightwell design language
Same tokens, sidebar shell, components and voice as Hammerhead: teal accent,
warm neutrals, status badges with text, dialogs with focus trapping that name
what they affect, empty states, skeleton loading, outline icons, 44px touch
targets and a slide-in drawer on phones. The strict CSP stays: no inline
styles or scripts, so icons and sizes use classes.

Adds docs/design.md and scripts/lint-design.mjs (run in CI), capitalizes
server error messages in one place, and rewrites interface copy to the voice
rules (sentence case, buttons that name the result, no dashes).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 00:30:30 -06:00

774 lines
58 KiB
JavaScript

'use strict';
// ---------- tiny toolkit ----------
// 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);
}
for (const k of kids.flat(Infinity)) if (k != null && k !== false) el.append(k.nodeType ? k : document.createTextNode(String(k)));
if (value !== undefined) el.value = value;
return el;
}
const $app = document.getElementById('app');
// theme: follows the system unless the person picked one
try { const t = localStorage.getItem('theme'); if (t) document.documentElement.dataset.theme = t; } catch (e) { /* storage is off */ }
function toggleTheme() {
const dark = document.documentElement.dataset.theme ? document.documentElement.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
const next = dark ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
try { localStorage.setItem('theme', next); } catch (e) { /* storage is off */ }
}
// short confirmations at the bottom right; errors stay longer and are announced as alerts
let toastBox;
function toast(msg, bad) {
if (!toastBox) { toastBox = h('div', { class: 'toasts', 'aria-live': 'polite' }); document.body.append(toastBox); }
const t = h('div', { class: 'toast' + (bad ? ' toast-error' : ''), role: bad ? 'alert' : 'status' }, msg);
toastBox.append(t);
setTimeout(() => t.remove(), bad ? 7000 : 3000);
}
const cap = s => s ? s[0].toUpperCase() + s.slice(1) : s;
const SIGNED_OUT = 'Your session ended. Sign in again to continue.';
async function api(method, url, 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 r = await fetch(url, opt);
const j = await r.json().catch(() => ({}));
if (r.status === 401 && url !== '/api/login' && url !== '/api/me') {
if (S.me) { S.me = null; boot(); }
throw new Error(SIGNED_OUT);
}
if (!r.ok) throw new Error(cap(j.error || r.statusText));
return j;
}
// run an action; show errors as a toast instead of throwing into the void
const act = fn => async (...a) => { try { await fn(...a); } catch (e) { if (e.message !== SIGNED_OUT) toast(e.message, true); } };
const pad = n => String(n).padStart(2, '0');
const clock = d => { const t = new Date(d); return `${pad(t.getHours())}:${pad(t.getMinutes())}:${pad(t.getSeconds())}`; };
const fmtTime = d => new Date(d).toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
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} min`;
if (m) return `${m} min`;
return `${sec} s`;
}
const since = d => d ? fmtDur((Date.now() - new Date(d)) / 1000) : '';
const field = (label, el, hint) => h('label', {}, label, el, hint ? h('span', { class: 'sub' }, hint) : null);
const chk = (label, checked) => { const i = h('input', { type: 'checkbox', checked: !!checked }); return Object.assign(h('label', { class: 'chk' }, i, label), { box: i }); };
// status pill: color always travels with a text label
const badge = (text, tone) => h('span', { class: 'badge' + (tone ? ' badge-' + tone : '') }, text);
const emptyState = (name, title, text, action) => h('div', { class: 'empty' }, icon(name, 22), h('h3', {}, title), h('p', {}, text), action || null);
// placeholder rows while a page loads, so the layout does not jump
const skeleton = () => h('div', { class: 'card', role: 'status', 'aria-busy': 'true' }, h('span', { class: 'sr-only' }, 'Loading'),
['w60', 'w100', 'w90', 'w100', 'w75'].map(w => h('div', { class: 'skeleton ' + w })));
const put = (el, kids) => el.append(...kids.flat(Infinity).filter(k => k != null && k !== false));
const card = (title, ...kids) => h('div', { class: 'card' }, title ? h('h2', {}, title) : null, ...kids);
const steamLink = id => h('a', { href: 'https://steamcommunity.com/profiles/' + id, target: '_blank', rel: 'noopener noreferrer', class: 'mono' }, id);
// ---------- icons (lucide-style, static strings; see docs/design.md for the approved names) ----------
const ICONS = {
fin: '<path d="M3 20c4-1 7-5 8-14 3 5 8 7 10 14z"/>',
servers: '<rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><path d="M6 6h.01M6 18h.01"/>',
players: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
bans: '<circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/>',
schedules: '<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>',
settings: '<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/>',
activity: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
out: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>',
menu: '<path d="M4 6h16M4 12h16M4 18h16"/>',
close: '<path d="M18 6 6 18M6 6l12 12"/>',
add: '<path d="M5 12h14M12 5v14"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
light: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>',
dark: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
alert: '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4M12 17h.01"/>',
};
function icon(name, size = 18) {
const s = h('span', { class: 'inline-icon', 'aria-hidden': 'true' });
s.innerHTML = `<svg class="icon icon-${size}" xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">${ICONS[name] || ''}</svg>`;
return s;
}
const brand = () => h('div', { class: 'brand' }, icon('fin', 22), 'Megalodon');
// ---------- dialogs ----------
// A dialog: focus moves in, Tab stays inside, Escape closes, and focus goes back to what opened it.
let dialogSeq = 0;
function modal(title, body, opts = {}) {
const opener = document.activeElement, id = 'dialog-title-' + (++dialogSeq);
const shade = h('div', { class: 'shade' });
const box = h('div', { class: 'modal' + (opts.wide ? ' wide' : ''), role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': id, tabindex: '-1' }, h('h2', { id }, title), body);
let closed = false;
const close = () => {
if (closed) return; closed = true;
document.removeEventListener('keydown', onKey, true); shade.remove();
if (opener && opener.isConnected && opener.focus) opener.focus();
if (opts.onClose) opts.onClose();
};
const focusables = () => [...box.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')].filter(e => e.offsetParent !== null);
function onKey(e) {
if ([...document.querySelectorAll('.shade')].pop() !== shade) return;
if (e.key === 'Escape') { e.stopPropagation(); close(); }
else if (e.key === 'Tab') {
const f = focusables(); if (!f.length) { e.preventDefault(); return; }
const first = f[0], last = f[f.length - 1];
if (e.shiftKey && (document.activeElement === first || document.activeElement === box)) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
}
shade.addEventListener('mousedown', e => { if (e.target === shade && !opts.sticky) close(); });
shade.append(box);
document.body.append(shade);
document.addEventListener('keydown', onKey, true);
(box.querySelector('input,select,textarea') || focusables()[0] || box).focus();
return close;
}
const modalOpen = () => !!document.querySelector('.shade');
// A confirmation that names what it affects and says what is lost. Resolves true only when confirmed.
function confirmDialog({ title, body, confirm = 'Confirm', cancel = 'Cancel', danger = true }) {
return new Promise(resolve => {
let close;
const done = v => { resolve(v); close(); };
close = modal(title, h('div', { class: 'stack' }, h('p', { class: 'dim' }, body),
h('div', { class: 'actions form-actions' }, h('button', { class: 'ghost', onclick: () => done(false) }, cancel),
h('button', { class: danger ? 'ghost danger' : 'btn', onclick: () => done(true) }, confirm))), { onClose: () => resolve(false) });
});
}
// formDialog shows a form and resolves with its values, or null if it was cancelled.
// Field types: text, password, number, textarea, lines (one per row), select, checkbox, multi, note.
function formDialog({ title, intro, fields, submit = 'Save', wide }) {
return new Promise(resolve => {
const parts = {};
const err = h('p', { class: 'err', role: 'alert' });
const rows = fields.map(f => {
if (f.type === 'note') return h('p', { class: 'dim' }, f.text);
if (f.type === 'checkbox') { const c = chk(f.label, f.value); parts[f.name] = { f, get: () => c.box.checked, el: c.box }; return f.hint ? h('div', {}, c, h('span', { class: 'sub' }, f.hint)) : c; }
let el, get;
if (f.type === 'select') {
el = h('select', {}, (f.options || []).map(o => typeof o === 'string' ? h('option', { value: o }, o) : h('option', { value: o.v }, o.l)));
el.value = f.value ?? el.options[0]?.value; get = () => el.value;
} else if (f.type === 'textarea' || f.type === 'lines') {
el = h('textarea', { placeholder: f.placeholder, rows: 5 }); el.value = f.type === 'lines' ? (f.value || []).join('\n') : (f.value || '');
get = () => f.type === 'lines' ? el.value.split('\n').map(s => s.trim()).filter(Boolean) : el.value.trim();
} else if (f.type === 'multi') {
const boxes = f.options.map(o => { const v = typeof o === 'string' ? o : o.v; return Object.assign(chk(typeof o === 'string' ? o : o.l, (f.value || []).includes(v)), { v }); });
el = h('div', { class: 'pillrow' }, boxes); get = () => boxes.filter(b => b.box.checked).map(b => b.v);
parts[f.name] = { f, get, el: boxes[0]?.box };
return h('fieldset', {}, h('legend', {}, f.label), el, f.hint ? h('p', { class: 'sub' }, f.hint) : null);
} else {
el = h('input', { type: f.type === 'number' ? 'number' : f.type || 'text', placeholder: f.placeholder, autocomplete: f.type === 'password' ? 'new-password' : 'off', step: f.type === 'number' ? 'any' : null });
el.value = f.value ?? ''; get = () => f.type === 'number' ? (el.value === '' ? null : Number(el.value)) : el.value.trim();
}
parts[f.name] = { f, get, el };
return field(f.label, el, f.hint);
});
let close;
const send = () => {
const out = {};
for (const [name, p] of Object.entries(parts)) {
const v = p.get();
const empty = v === '' || v == null || (Array.isArray(v) && !v.length);
if (p.f.required && empty) { err.textContent = `${p.f.label} is required.`; if (p.el) { p.el.setAttribute('aria-invalid', 'true'); p.el.focus(); } return; }
out[name] = v;
}
done(out);
};
const done = v => { resolve(v); close(); };
const form = h('form', { class: 'stack', onsubmit: e => { e.preventDefault(); send(); } }, intro ? h('p', { class: 'dim' }, intro) : null, rows, err,
h('div', { class: 'actions form-actions' }, h('button', { type: 'button', class: 'ghost', onclick: () => done(null) }, 'Cancel'), h('button', { type: 'submit', class: 'btn' }, submit)));
close = modal(title, form, { wide, sticky: true, onClose: () => resolve(null) });
});
}
// ---------- state ----------
const S = { me: null, servers: [], stream: null, timer: null, view: null, route: '', navState: {} };
const RANK = { viewer: 1, moderator: 2, admin: 3 };
const can = role => S.me && RANK[S.me.role] >= RANK[role];
const serverName = id => (S.servers.find(s => s.id === id) || {}).name || id || '';
const serverOptions = () => S.servers.map(s => ({ v: s.id, l: s.name }));
async function reloadServers() { S.servers = await api('GET', '/api/servers'); updateNav(); }
async function boot() {
clearInterval(S.timer);
if (S.stream) S.stream.close();
S.stream = null; S.view = null;
try { S.me = await api('GET', '/api/me'); } catch { S.me = null; return $app.replaceChildren(loginView()); }
S.servers = await api('GET', '/api/servers').catch(() => []);
S.stream = new EventSource('/api/stream');
S.stream.onmessage = m => { try { S.view?.onEvent?.(JSON.parse(m.data)); } catch { /* ignore a bad message */ } };
S.timer = setInterval(async () => {
if (!S.me || document.hidden) return;
try { await reloadServers(); if (!modalOpen()) S.view?.update?.(); } catch { /* sign-out is handled by api() */ }
}, 4000);
render();
}
function loginView() {
const user = h('input', { type: 'text', autocomplete: 'username' }), pw = h('input', { type: 'password', autocomplete: 'current-password' });
const go = act(async () => {
await api('POST', '/api/login', { username: user.value, password: pw.value });
if (toastBox) toastBox.replaceChildren(); // an earlier failed attempt no longer applies
boot();
});
[user, pw].forEach(i => i.addEventListener('keydown', e => e.key === 'Enter' && go()));
setTimeout(() => user.focus());
return h('div', { class: 'center' }, card('', brand(), h('h2', {}, 'Sign in'), h('div', { class: 'stack' }, field('Username', user), field('Password', pw), h('button', { class: 'btn', onclick: go }, 'Sign in'))));
}
// ---------- shell ----------
const ROUTES = { players: ['Players', 'Everyone who has joined any of your servers.'], bans: ['Bans', 'Bans are kept by Megalodon and sent to every server. A banned player who shows up anywhere is removed again.'],
schedules: ['Schedules', 'Jobs that run on a timer, such as saving the world or restarting at a quiet hour.'], settings: ['Settings', 'Notifications, welcome message, chat filter, macros, users and API tokens.'] };
function setDrawer(open) {
const sb = document.getElementById('sidebar'), bd = document.getElementById('backdrop'), mb = document.getElementById('menu-btn');
if (!sb) return;
sb.classList.toggle('open', open); bd.classList.toggle('open', open); mb.setAttribute('aria-expanded', open);
if (open) sb.querySelector('a,button')?.focus(); else mb.focus();
}
document.addEventListener('keydown', e => { if (e.key === 'Escape' && !modalOpen()) document.getElementById('sidebar')?.classList.contains('open') && setDrawer(false); });
function stateLabel(s) { return s.restart ? 'Restarting' : s.online ? 'Online' : 'Offline'; }
function updateNav() {
for (const s of S.servers) {
const el = S.navState[s.id];
if (el) { el.textContent = stateLabel(s); el.className = 'state ' + (s.online && !s.restart ? 'up' : 'down'); }
}
}
function render() {
const cur = location.hash.slice(2).split('/')[0] || 'overview';
const nav = (href, key, label, ic, extra) => h('a', { class: 'navitem' + (S.route === key ? ' on' : ''), href, 'aria-current': S.route === key ? 'page' : null, onclick: () => setDrawer(false) }, icon(ic), h('span', { class: 'label' }, label), extra);
S.route = cur === 's' ? 's/' + decodeURIComponent(location.hash.slice(2).split('/')[1] || '') : cur;
const side = [h('div', { class: 'sidebar-head' }, brand(), h('button', { class: 'ghost quiet btn-icon sidebar-close', 'aria-label': 'Close menu', onclick: () => setDrawer(false) }, icon('close'))),
h('div', { class: 'navsec' }, 'Servers'), nav('#/', 'overview', 'Overview', 'activity')];
S.navState = {};
for (const s of S.servers) {
const st = h('span', {}); S.navState[s.id] = st;
side.push(nav('#/s/' + encodeURIComponent(s.id), 's/' + s.id, s.name, 'servers', st));
}
side.push(h('div', { class: 'navsec' }, 'Manage'), nav('#/players', 'players', 'Players', 'players'));
if (can('moderator')) side.push(nav('#/bans', 'bans', 'Bans', 'bans'));
if (can('admin')) side.push(nav('#/schedules', 'schedules', 'Schedules', 'schedules'), nav('#/settings', 'settings', 'Settings', 'settings'));
side.push(h('div', { class: 'grow' }), h('div', { class: 'userbox' }, h('div', { class: 'who' }, `${S.me.name} (${S.me.role})`),
h('button', { class: 'navitem', onclick: toggleTheme }, icon('dark'), 'Switch theme'),
h('button', { class: 'navitem', onclick: act(async () => { await api('POST', '/api/logout'); S.me = null; boot(); }) }, icon('out'), 'Sign out')));
const main = h('main', { id: 'view', tabindex: '-1' });
$app.replaceChildren(h('a', { class: 'skip', href: '#view', onclick: e => { e.preventDefault(); main.focus(); } }, 'Skip to content'), h('div', { class: 'shell' },
h('div', { class: 'topbar' }, brand(), h('button', { class: 'ghost quiet btn-icon', id: 'menu-btn', 'aria-label': 'Open menu', 'aria-controls': 'sidebar', 'aria-expanded': 'false', onclick: () => setDrawer(true) }, icon('menu'))),
h('div', { class: 'backdrop', id: 'backdrop', onclick: () => setDrawer(false) }),
h('aside', { class: 'sidebar', id: 'sidebar', 'aria-label': 'Main' }, side), h('div', { class: 'content' }, main)));
updateNav();
show();
}
window.addEventListener('hashchange', () => { if (S.me) render(); });
function pageHeader(title, desc, primary) {
return h('div', { class: 'page-h' }, h('div', {}, h('h1', {}, title), desc ? h('p', {}, desc) : null),
primary ? h('button', { class: 'btn', onclick: primary[1] }, icon(primary[2] || 'add', 16), primary[0]) : null);
}
function show() {
const parts = location.hash.slice(2).split('/');
const main = document.getElementById('view');
let page;
switch (parts[0]) {
case 's': page = serverPage(decodeURIComponent(parts[1] || ''), parts[2] || 'players'); break;
case 'players': page = playersPage(); break;
case 'bans': page = can('moderator') ? bansPage() : overview(); break;
case 'schedules': page = can('admin') ? schedulesPage() : overview(); break;
case 'settings': page = can('admin') ? settingsPage() : overview(); break;
default: page = overview();
}
S.view = page;
main.replaceChildren(page.el);
page.update?.();
}
// ---------- actions ----------
const DONE = { save: 'World saved', broadcast: 'Broadcast sent', say: 'Message sent', whisper: 'Message sent', kick: 'Player kicked', ban: 'Player banned', unban: 'Player unbanned',
allow: 'Player allowed to join', disallow: 'Player no longer allowed to join', destroy_wild_dinos: 'Wild creatures removed', set_time: 'Time of day set', motd: 'Message of the day set',
restart: 'Restart scheduled', stop: 'Shutdown scheduled', cancel_restart: 'Countdown cancelled', start: 'Start command run' };
// run an action on one server (id) or on all of them (null) and report how it went
async function run(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)}: ${cap(r.error)}`).join('\n'), true);
else if (!quiet) toast(res.length === 1 && res[0].output && body.action === 'exec' ? res[0].output : (DONE[body.action] || 'Done') + (res.length > 1 ? ` on ${res.length} servers` : ''));
await reloadServers().catch(() => { });
S.view?.update?.();
return res;
} catch (e) { if (e.message !== SIGNED_OUT) toast(e.message, true); return null; }
}
async function banDialog(steamID, name, server) {
const r = await formDialog({
title: name ? `Ban ${name}` : 'Ban a player', submit: 'Ban player', fields: [
{ name: 'steam_id', label: 'Steam ID', value: steamID, required: true, hint: 'The 17-digit number from the player\'s Steam profile.' },
{ name: 'reason', label: 'Reason', hint: 'Shown in the ban list. Optional.' },
{ 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', type: 'checkbox', value: true, hint: 'Recommended for a cluster, so they cannot just switch maps.' }],
});
if (!r) return false;
const only = !r.all && server;
await run(only ? server : null, { action: 'ban', steam_id: r.steam_id, player: name, reason: r.reason, minutes: Number(r.minutes), servers: only ? [server] : [] });
return true;
}
async function noteDialog(steamID, name, notes, watch) {
const r = await formDialog({
title: 'Notes on ' + name, fields: [
{ name: 'notes', label: 'Notes', type: 'textarea', value: notes, hint: 'Only moderators and admins can see these.' },
{ name: 'watch', label: 'Alert me when this player joins', type: 'checkbox', value: watch }],
});
if (!r) return false;
await act(async () => { await api('PUT', '/api/players/' + steamID, r); toast('Notes saved'); })();
return true;
}
// ---------- event rows ----------
const TONE = { join: 'success', online: 'success', start: 'success', offline: 'danger', ban: 'danger', kick: 'danger', error: 'danger', restart: 'warning', filter: 'warning', watch: 'warning', schedule: 'warning', unban: 'accent', audit: '', chat: 'accent', leave: '' };
function eventRow(e, withServer = true) {
const text = e.type === 'chat' ? [h('strong', {}, e.actor || 'SERVER'), ': ', e.message]
: [e.actor && e.type === 'audit' ? e.actor + ': ' : e.actor ? e.actor + ': ' : '', e.message];
return h('div', { class: 'ev' }, h('time', { datetime: e.time, title: fmtTime(e.time) }, clock(e.time)), badge(e.type, TONE[e.type]),
h('span', { class: 'txt' }, withServer && e.server ? [h('span', { class: 'srv' }, serverName(e.server)), ' · '] : null, text));
}
// ---------- sparkline ----------
function spark(canvas, samples) {
const dpr = window.devicePixelRatio || 1, w = canvas.clientWidth, hgt = canvas.clientHeight;
if (!w) return;
canvas.width = w * dpr; canvas.height = hgt * dpr;
const g = canvas.getContext('2d');
g.scale(dpr, dpr);
const css = getComputedStyle(document.documentElement), tok = n => css.getPropertyValue(n).trim();
g.clearRect(0, 0, w, hgt);
const t0 = Date.now() / 1000 - 86400, 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 = tok('--border'); g.beginPath(); g.moveTo(0, hgt - .5); g.lineTo(w, hgt - .5); g.stroke();
g.fillStyle = tok('--danger');
samples.forEach(s => { if (!s.up) g.fillRect(x(s.t), hgt - 4, Math.max(2, w / 1440), 4); });
g.strokeStyle = tok('--accent-text'); 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 statusBadge(s) {
if (s.restart) return badge(s.restart.stop ? 'Shutting down soon' : 'Restarting soon', 'warning');
if (s.online) return badge('Online', 'success');
return badge(s.held ? 'Stopped' : 'Offline', 'danger');
}
const meta = s => {
const i = s.info, bits = [];
if (!s.online) return s.last_error ? cap(s.last_error) : 'Waiting for a response.';
bits.push(`${s.players.length}${i ? ' of ' + i.max_players : ''} players`);
if (i?.map) bits.push(i.map);
if (i?.version) bits.push('version ' + i.version);
if (s.since) bits.push('up ' + since(s.since));
return bits.join(' · ');
};
function overview() {
const cards = h('div', { class: 'cards' }), feed = h('div', { class: 'feed' });
const samples = {}, canvases = {};
let lastLoad = 0;
const draw = id => canvases[id] && spark(canvases[id], samples[id] || []);
const tools = h('div', { class: 'toolbar' });
if (can('moderator')) tools.append(
h('button', { class: 'ghost', onclick: async () => { const r = await formDialog({ title: 'Broadcast to all servers', submit: 'Send broadcast', fields: [{ name: 'message', label: 'Message', required: true, hint: 'Shown in the middle of every player\'s screen.' }] }); if (r) run(null, { action: 'broadcast', message: r.message }); } }, 'Broadcast to all servers'),
h('button', { class: 'ghost', onclick: () => run(null, { action: 'save' }) }, 'Save all worlds'));
const page = {
el: h('div', {}, pageHeader('Servers', 'Every server Megalodon watches, and what is happening on it.', can('admin') ? ['Add server', () => serverDialog()] : null), tools, cards,
card('', h('div', { class: 'card-h' }, h('h2', {}, 'Recent activity')), feed)),
update() {
cards.replaceChildren();
if (!S.servers.length) { cards.replaceWith(h('div', { class: 'card' }, emptyState('servers', 'No servers yet', 'Add a server with its RCON address and password to start watching it.', can('admin') ? h('button', { class: 'btn', onclick: () => serverDialog() }, icon('add', 16), 'Add server') : null))); return; }
for (const s of S.servers) {
const canvas = h('canvas', { class: 'spark', role: 'img', 'aria-label': `Players on ${s.name} over the last 24 hours` });
canvases[s.id] = canvas;
cards.append(h('div', { class: 'card' }, h('div', { class: 'card-h' }, h('h3', {}, h('a', { href: '#/s/' + encodeURIComponent(s.id) }, s.name)), statusBadge(s)),
h('div', { class: 'meta' }, meta(s)), canvas,
h('div', { class: 'meta' }, s.online ? `RCON ${s.rtt_ms} ms` : '', s.peak ? ` · peak ${s.peak} players` : '')));
requestAnimationFrame(() => draw(s.id));
}
if (Date.now() - lastLoad > 60000) { lastLoad = Date.now(); S.servers.forEach(s => api('GET', `/api/servers/${encodeURIComponent(s.id)}/samples`).then(l => { samples[s.id] = l; draw(s.id); }).catch(() => { })); }
},
onEvent(e) { if (e.type !== 'chat') { feed.querySelector('.empty')?.remove(); 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 => {
if (!evs.length) feed.append(emptyState('activity', 'No activity yet', 'Joins, leaves, restarts and actions appear here as they happen.'));
evs.forEach(e => feed.append(eventRow(e)));
}).catch(() => { });
return page;
}
async function serverDialog(existing) {
const e = existing || {};
const r = await formDialog({
title: existing ? 'Edit ' + e.name : 'Add a server', submit: existing ? 'Save server' : 'Add server', wide: true, fields: [
{ name: 'name', label: 'Name', value: e.name, required: true, placeholder: 'The Island' },
{ name: 'host', label: 'Host or IP address', value: e.host, required: true, placeholder: '192.168.1.20', hint: 'Where Megalodon can reach the server. Keep RCON off the public internet.' },
{ name: 'rcon_port', label: 'RCON port', type: 'number', value: e.rcon_port || 27020, required: true },
{ name: 'password', label: 'RCON password', type: 'password', required: !existing, hint: existing ? 'Leave empty to keep the current password.' : 'The ServerAdminPassword from the server\'s settings.' },
{ name: 'query_port', label: 'Query port', type: 'number', value: e.query_port ?? 27015, hint: 'UDP, usually 27015. Gives the map, version and player limit. Use 0 to turn it off.' },
{ name: 'start_command', label: 'Start command', value: e.start_command, placeholder: 'docker start ark-island', hint: 'Optional. Runs on this machine to start the server after a restart or crash.' },
{ name: 'auto_start', label: 'Start it automatically when it has been down for a while', type: 'checkbox', value: e.auto_start }],
});
if (!r) return;
await act(async () => {
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);
toast(existing ? 'Server saved' : 'Server added');
await reloadServers(); render();
})();
}
// ---------- one server ----------
function serverPage(id, tab) {
const get = () => S.servers.find(s => s.id === id);
if (!get()) return { el: h('div', {}, pageHeader('Server not found', ''), h('div', { class: 'card' }, emptyState('servers', 'No such server', 'It may have been removed.', h('a', { href: '#/' }, h('button', { class: 'ghost' }, 'Back to servers'))))) };
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 head = h('div'), tabBar = h('div', { class: 'tabs2', role: 'navigation', 'aria-label': 'Server sections' }, tabs.map(t => h('a', { href: `#/s/${encodeURIComponent(id)}/${t[0]}`, class: t[0] === tab ? 'on' : '', 'aria-current': t[0] === tab ? 'page' : null }, t[1])));
const sub = { players: playersTab, chat: chatTab, console: consoleTab, events: eventsTab }[tab](id, get);
function drawHead() {
const s = get(); if (!s) return;
const ctl = h('div', { class: 'toolbar' });
if (can('moderator')) ctl.append(h('button', { class: 'ghost', onclick: () => run(id, { action: 'save' }) }, 'Save world'));
if (can('admin')) {
put(ctl, [s.restart ? h('button', { class: 'ghost', onclick: () => run(id, { action: 'cancel_restart' }) }, 'Cancel ' + (s.restart.stop ? 'shutdown' : 'restart')) : [
h('button', { class: 'ghost', onclick: () => restartDialog(id, false) }, 'Restart'), h('button', { class: 'ghost', onclick: () => restartDialog(id, true) }, 'Stop')],
s.can_start && !s.online ? h('button', { class: 'ghost', onclick: () => run(id, { action: 'start' }) }, 'Start') : null,
h('button', { class: 'ghost', onclick: async () => { const r = await formDialog({ title: 'Set time of day', submit: 'Set time', fields: [{ name: 'time', label: 'Time', value: '12:00', required: true, hint: 'Hours and minutes, such as 06:30.' }] }); if (r) run(id, { action: 'set_time', time: r.time }); } }, 'Set time'),
h('button', { class: 'ghost', onclick: async () => { const r = await formDialog({ title: 'Message of the day', submit: 'Set message', fields: [{ name: 'message', label: 'Message', required: true, hint: 'Players see it when they join.' }] }); if (r) run(id, { action: 'motd', message: r.message }); } }, 'Message of the day'),
h('button', { class: 'ghost danger', onclick: async () => { if (await confirmDialog({ title: 'Remove wild creatures on ' + s.name + '?', body: 'Every wild creature on the map is removed and they respawn over time. Tamed creatures are not affected. This cannot be undone.', confirm: 'Remove wild creatures' })) run(id, { action: 'destroy_wild_dinos' }); } }, 'Remove wild creatures'),
h('button', { class: 'ghost', onclick: () => serverDialog(s) }, 'Edit server'),
h('button', { class: 'ghost danger', onclick: async () => {
if (await confirmDialog({ title: 'Remove ' + s.name + '?', body: 'Megalodon stops watching and managing this server, and turns off schedules that only target it. The server itself is not touched, and you can add it again.', confirm: 'Remove server' }))
act(async () => { await api('DELETE', '/api/servers/' + encodeURIComponent(id)); toast('Server removed'); await reloadServers(); location.hash = '#/'; })();
} }, 'Remove server')]);
}
const banner = [];
if (!s.online) banner.push(h('div', { class: 'notice notice-danger', role: 'status' }, icon('alert', 16), s.held ? 'Stopped from Megalodon.' : 'Not responding.', s.last_error ? ' ' + cap(s.last_error) + '.' : '', s.offline_since ? ` Down for ${since(s.offline_since)}.` : ''));
if (s.restart) banner.push(h('div', { class: 'notice notice-warning', role: 'status' }, icon('schedules', 16),
`${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 ? ' Reason: ' + s.restart.reason : ''));
head.replaceChildren(h('a', { href: '#/', class: 'back' }, 'All servers'),
h('div', { class: 'page-h' }, h('div', {}, h('h1', {}, s.name, statusBadge(s)), h('p', {}, `${s.host}:${s.rcon_port}`, s.online ? ' · ' + meta(s) : '')),
can('moderator') ? h('button', { class: 'btn', onclick: async () => { const r = await formDialog({ title: 'Broadcast to ' + s.name, submit: 'Send broadcast', fields: [{ name: 'message', label: 'Message', required: true, hint: 'Shown in the middle of every player\'s screen.' }] }); if (r) run(id, { action: 'broadcast', message: r.message }); } }, 'Broadcast') : null),
banner, ctl);
}
return {
el: h('div', {}, head, tabBar, sub.el),
update() { drawHead(); sub.update?.(); },
onEvent(e) { if (e.server === id) sub.onEvent?.(e); },
};
}
async function restartDialog(id, stop) {
const r = await formDialog({
title: stop ? 'Stop the server' : 'Restart the server', submit: stop ? 'Start countdown' : 'Start countdown', fields: [
{ name: 'minutes', label: 'Countdown in minutes', type: 'number', value: 5, required: true, hint: 'Players get warnings along the way, and the world is saved first. Use 0 to go now.' },
{ name: 'reason', label: 'Reason', hint: 'Shown to players. Optional.' }],
});
if (r) run(id, { action: stop ? 'stop' : 'restart', minutes: r.minutes, reason: r.reason });
}
function playersTab(id, get) {
const box = h('div', { class: 'card flush' });
return {
el: box,
update() {
const s = get();
if (!s?.players.length) { box.replaceChildren(emptyState('players', s?.online ? 'Nobody is online' : 'The server is offline', s?.online ? 'Players appear here as they join.' : 'Players appear here once it is back.')); return; }
box.replaceChildren(h('div', { class: 'scroll' }, h('table', {}, h('thead', {}, h('tr', {}, ['Player', 'Steam ID', 'Online for', ''].map(t => h('th', {}, t)))),
h('tbody', {}, s.players.map(p => h('tr', {},
h('td', {}, p.name, ' ', p.banned ? badge('Banned', 'danger') : null, p.watch ? badge('Watched', 'warning') : null, p.notes ? h('div', { class: 'meta' }, p.notes) : null),
h('td', {}, steamLink(p.steam_id)), h('td', { class: 'num' }, since(p.joined_at) || 'just now'),
h('td', { class: 'actions-cell' }, can('moderator') ? [
h('button', { class: 'ghost', onclick: async () => { const r = await formDialog({ title: 'Message ' + p.name, submit: 'Send message', fields: [{ name: 'message', label: 'Message', required: true, hint: 'Only this player sees it.' }] }); if (r) run(id, { action: 'whisper', steam_id: p.steam_id, message: r.message }); } }, 'Message'),
h('button', { class: 'ghost', onclick: async () => { if (await confirmDialog({ title: 'Kick ' + p.name + '?', body: 'They are removed from the server now and can rejoin straight away.', confirm: 'Kick player' })) run(id, { action: 'kick', steam_id: p.steam_id }); } }, 'Kick'),
h('button', { class: 'ghost danger', onclick: () => banDialog(p.steam_id, p.name, id) }, 'Ban'),
h('button', { class: 'ghost', onclick: () => noteDialog(p.steam_id, p.name, p.notes, p.watch) }, 'Notes')] : null)))))));
},
};
}
function chatTab(id) {
const log = h('div', { class: 'feed' });
const input = h('input', { type: 'text', placeholder: 'Message to everyone', maxlength: 300, 'aria-label': 'Message to everyone' });
const line = e => {
const near = log.scrollHeight - log.scrollTop - log.clientHeight < 60;
log.querySelector('.empty')?.remove();
log.append(h('div', { class: 'ev chat' }, h('time', {}, clock(e.time)), h('span', { class: 'txt' }, h('strong', {}, e.actor || 'SERVER'), e.detail ? h('span', { class: 'meta' }, ` (${e.detail})`) : null, ': ', e.message)));
if (near) log.scrollTop = log.scrollHeight;
};
api('GET', `/api/events?server=${encodeURIComponent(id)}&type=chat&limit=200`).then(evs => {
if (!evs.length) log.append(emptyState('players', 'No chat yet', 'Player chat appears here while the server is online.'));
evs.reverse().forEach(line); log.scrollTop = log.scrollHeight;
}).catch(e => toast(e.message, true));
const send = async action => { const m = input.value.trim(); if (!m) return; const r = await run(id, { action, message: m }, true); if (r && r.every(x => x.ok)) { input.value = ''; toast(DONE[action]); } };
return {
el: h('div', { class: 'card' }, log, can('moderator') ? h('form', { class: 'sendrow', onsubmit: e => { e.preventDefault(); send('say'); } }, input,
h('button', { class: 'btn', type: 'submit' }, 'Send as SERVER'), h('button', { class: 'ghost', type: 'button', onclick: () => send('broadcast') }, 'Broadcast')) : null),
onEvent(e) { if (e.type === 'chat') line(e); },
};
}
function consoleTab(id) {
const out = h('pre', { class: 'logbox', 'aria-live': 'polite' });
const input = h('input', { type: 'text', list: 'cmds', placeholder: 'ListPlayers', autocomplete: 'off', spellcheck: 'false', 'aria-label': 'Console command' });
const list = h('datalist', { id: 'cmds' }, (S.me.reference || []).map(c => h('option', { value: c })));
let hist = []; try { hist = JSON.parse(localStorage.getItem('megalodon.history') || '[]'); } catch (e) { /* none yet */ }
let pos = hist.length;
const print = (cls, text) => { out.append(h('div', { class: cls }, text)); out.scrollTop = out.scrollHeight; };
const show = res => res.forEach(r => print(r.ok ? '' : 'bad', r.ok ? (r.output || '(no reply)') : cap(r.error)));
const exec = async cmd => { print('cmd', '> ' + cmd); try { show(await api('POST', `/api/servers/${encodeURIComponent(id)}/action`, { action: 'exec', command: cmd })); } catch (e) { print('bad', e.message); } };
const macros = h('select', { 'aria-label': 'Run a macro' }, h('option', { value: '' }, 'Run a macro'), (S.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 { show(await api('POST', `/api/servers/${encodeURIComponent(id)}/macro`, { name })); } 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: 'card' }, out, list,
h('form', { class: 'sendrow', 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 (x) { /* storage is off */ }
input.value = ''; exec(c);
} }, input, h('button', { class: 'btn', type: 'submit' }, 'Run command'), (S.me.macros || []).length ? macros : null),
h('p', { class: 'meta' }, 'Commands go straight to the server\'s console and are recorded in the event log.')),
};
}
function eventsTab(id) {
const log = h('div', { class: 'feed' });
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', { 'aria-label': 'Event type' }, types.map(t => h('option', { value: t }, t || 'All events')));
const q = h('input', { type: 'search', placeholder: 'Search events', 'aria-label': 'Search events' });
const more = h('button', { class: 'ghost', hidden: true }, 'Show older events');
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; });
if (reset && !evs.length) log.append(emptyState('activity', 'No events', sel.value || q.value ? 'Nothing matches these filters.' : 'Nothing has happened on this server yet.'));
more.hidden = evs.length < 100;
} catch (e) { toast(e.message, true); }
}
sel.addEventListener('change', () => load(true)); q.addEventListener('change', () => load(true)); more.addEventListener('click', () => load(false));
load(true);
return {
el: h('div', { class: 'card' }, h('div', { class: 'toolbar' }, sel, q), log, h('div', { class: 'toolbar' }, more)),
onEvent(e) { if ((!sel.value || sel.value === e.type) && !q.value) { log.querySelector('.empty')?.remove(); log.prepend(eventRow(e, false)); } },
};
}
// ---------- players database ----------
function playersPage() {
const body = h('div', { class: 'card flush' }, skeleton());
const q = h('input', { type: 'search', placeholder: 'Search by name or Steam ID', 'aria-label': 'Search players' });
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('div', { class: 'scroll' }, h('table', {}, h('thead', {}, h('tr', {}, ['Player', 'Steam ID', 'Last seen', 'Play time', 'Joins', ''].map(t => h('th', {}, t)))),
h('tbody', {}, rows.slice(0, 500).map(p => h('tr', {},
h('td', {}, p.name, ' ', p.watch ? badge('Watched', 'warning') : null, p.notes ? h('div', { class: 'meta' }, p.notes) : null),
h('td', {}, steamLink(p.steam_id)),
h('td', {}, fmtTime(p.last_seen), p.last_server ? h('div', { class: 'meta' }, serverName(p.last_server)) : null),
h('td', { class: 'num' }, fmtDur(p.seconds)), h('td', { class: 'num' }, p.joins),
h('td', { class: 'actions-cell' }, can('moderator') ? [h('button', { class: 'ghost', onclick: async () => { if (await noteDialog(p.steam_id, p.name, p.notes, p.watch)) setTimeout(load, 300); } }, 'Notes'), h('button', { class: 'ghost danger', onclick: () => banDialog(p.steam_id, p.name) }, 'Ban')] : null))))))
: emptyState('players', all.length ? 'No matching players' : 'No players yet', all.length ? 'Try a different name or Steam ID.' : 'Players are recorded the first time they join a server.'));
};
const load = () => api('GET', '/api/players').then(l => { all = l; draw(); }).catch(e => toast(e.message, true));
q.addEventListener('input', draw);
load();
return { el: h('div', {}, pageHeader(...ROUTES.players), h('div', { class: 'toolbar' }, q), body) };
}
// ---------- bans ----------
function bansPage() {
const body = h('div', { class: 'card flush' }, skeleton());
const load = () => api('GET', '/api/bans').then(list => {
body.replaceChildren(list.length ? h('div', { class: 'scroll' }, h('table', {}, h('thead', {}, h('tr', {}, ['Player', 'Steam ID', 'Reason', 'Banned by', 'Until', ''].map(t => h('th', {}, t)))),
h('tbody', {}, list.map(b => h('tr', {}, h('td', {}, b.name || 'Unknown'), h('td', {}, steamLink(b.steam_id)), h('td', { class: 'wrap' }, b.reason || ''),
h('td', {}, b.by, h('div', { class: 'meta' }, fmtTime(b.at))),
h('td', {}, b.expires ? fmtTime(b.expires) : badge('Permanent', 'danger'), b.servers?.length ? h('div', { class: 'meta' }, b.servers.map(serverName).join(', ')) : null),
h('td', { class: 'actions-cell' }, h('button', { class: 'ghost', onclick: async () => { if (await confirmDialog({ title: 'Unban ' + (b.name || b.steam_id) + '?', body: 'They can join every server again. You can ban them again later.', confirm: 'Unban player', danger: false })) { await run(null, { action: 'unban', steam_id: b.steam_id }); load(); } } }, 'Unban')))))))
: emptyState('bans', 'No bans', 'Banned players are listed here, with the reason and when the ban ends.'));
}).catch(e => toast(e.message, true));
load();
return { el: h('div', {}, pageHeader(ROUTES.bans[0], ROUTES.bans[1], ['Ban a player', async () => { if (await banDialog('', '')) setTimeout(load, 500); }, 'bans']), body) };
}
// ---------- schedules ----------
const ACTIONS = [['save', 'Save the world'], ['broadcast', 'Broadcast a message'], ['restart', 'Restart with warnings'], ['command', 'Run a console command'], ['destroy_wild_dinos', 'Remove wild creatures'], ['motd', 'Set the message of the day']];
const DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
function describeJob(j) {
const when = j.every ? 'every ' + j.every : 'at ' + (j.at || []).join(', ') + (j.days?.length ? ' on ' + j.days.join(', ') : ' every day');
return `${(ACTIONS.find(a => a[0] === j.action) || [0, j.action])[1]}, ${when}, ${j.servers?.length ? j.servers.map(serverName).join(', ') : 'all servers'}`;
}
function schedulesPage() {
const body = h('div', { class: 'card flush' }, skeleton());
let st;
const save = act(async () => { try { st = await api('PUT', '/api/settings', st); draw(); } catch (e) { await load(); throw e; } });
async function edit(j) {
const e = j || { enabled: true, action: 'save', every: '1h', at: [], days: [], servers: [] };
const r = await formDialog({
title: j ? 'Edit ' + j.name : 'New job', submit: j ? 'Save job' : 'Create job', wide: true, fields: [
{ 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', type: 'multi', options: S.servers.map(s => ({ v: s.id, l: s.name })), value: e.servers, hint: 'Leave all unticked to run on every server.' },
{ name: 'every', label: 'Repeat every', value: e.every, hint: 'For example 30m or 6h. Leave empty to use times of day instead.' },
{ name: 'at', label: 'Times of day', value: (e.at || []).join(', '), hint: 'Hours and minutes separated by commas, such as 04:30, 16:30. Uses the clock of the machine running Megalodon.' },
{ name: 'days', label: 'Only on these days', type: 'multi', options: DAYS, value: e.days, hint: 'Applies to times of day. Leave all unticked for every day.' },
{ name: 'message', label: 'Message', value: e.message, hint: 'For broadcasts and the message of the day.' },
{ name: 'command', label: 'Console command', value: e.command, hint: 'For the console command action.' },
{ name: 'countdown', label: 'Restart countdown', value: e.countdown, hint: 'For restarts, such as 10m. Players are warned along the way.' },
{ name: 'reason', label: 'Restart reason', value: e.reason, hint: 'Shown to players with the restart warnings.' },
{ name: 'enabled', label: 'Enabled', type: 'checkbox', value: e.enabled }],
});
if (!r) return;
r.at = 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('div', { class: 'scroll' }, h('table', {}, h('thead', {}, h('tr', {}, ['On', 'Job', 'What and when', ''].map(t => h('th', {}, t)))),
h('tbody', {}, st.jobs.map(j => h('tr', {},
h('td', {}, h('input', { type: 'checkbox', checked: j.enabled, 'aria-label': 'Enabled: ' + j.name, onchange: ev => { j.enabled = ev.target.checked; save(); } })),
h('td', {}, j.name), h('td', { class: 'wrap meta' }, describeJob(j)),
h('td', { class: 'actions-cell' },
h('button', { class: 'ghost', onclick: act(async () => { const r = await api('POST', `/api/jobs/${j.id}/run`); toast(`Ran on ${r.length} server${r.length === 1 ? '' : 's'}`); }) }, 'Run now'),
h('button', { class: 'ghost', onclick: () => edit(j) }, 'Edit'),
h('button', { class: 'ghost danger', onclick: async () => { if (await confirmDialog({ title: 'Delete ' + j.name + '?', body: 'The job is removed and will not run again. This cannot be undone.', confirm: 'Delete job' })) { st.jobs = st.jobs.filter(x => x.id !== j.id); save(); } } }, 'Delete')))))))
: emptyState('schedules', 'No jobs yet', 'Try a world save every 30 minutes, or a daily restart at a quiet hour.', h('button', { class: 'btn', onclick: () => edit() }, icon('add', 16), 'New job')));
}
const load = () => api('GET', '/api/settings').then(s => { st = s; st.jobs ||= []; draw(); }).catch(e => toast(e.message, true));
load();
return { el: h('div', {}, pageHeader(ROUTES.schedules[0], ROUTES.schedules[1] + ' Restarts warn players at 30, 15, 10, 5, 2 and 1 minute, then 30 and 10 seconds, and save the world first.', ['New job', () => st && edit(), 'add']), body) };
}
// ---------- settings ----------
const EVENT_TYPES = ['join', 'leave', 'online', 'offline', 'restart', 'start', 'ban', 'unban', 'kick', 'filter', 'watch', 'schedule', 'error', 'audit', 'chat'];
function settingsPage() {
const root = h('div', {}, skeleton());
let st;
const save = act(async () => { try { st = await api('PUT', '/api/settings', st); toast('Settings saved'); await draw(); } catch (e) { await load(); throw e; } });
const section = (title, hint, action, content) => h('div', { class: 'card' }, h('div', { class: 'card-h' }, h('h2', {}, title), action), hint ? h('p', { class: 'dim' }, hint) : null, content);
const table = (cols, rows, empty) => rows.length ? h('div', { class: 'scroll' }, h('table', {}, h('thead', {}, h('tr', {}, cols.map(c => h('th', {}, c)))), h('tbody', {}, rows))) : h('p', { class: 'meta' }, empty);
const del = (what, name, body, fn) => h('button', { class: 'ghost danger', onclick: async () => { if (await confirmDialog({ title: `Delete ${what} ${name}?`, body, confirm: 'Delete ' + what })) await act(fn)(); } }, 'Delete');
async function editNotify(n) {
const e = n || { kind: 'discord', events: [], servers: [] };
const r = await formDialog({
title: n ? 'Edit ' + n.name : 'Add a notification', submit: n ? 'Save notification' : 'Add notification', wide: true, fields: [
{ 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, hint: 'Keep it private: anyone with a Discord webhook URL can post to that channel.' },
{ name: 'events', label: 'Events', type: 'multi', options: EVENT_TYPES, value: e.events, hint: 'Leave all unticked for joins, leaves, up and down, restarts, starts, bans and errors.' },
{ name: 'servers', label: 'Servers', type: 'multi', options: serverOptions(), value: e.servers, hint: 'Leave all unticked for every server.' }],
});
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 formDialog({
title: m ? 'Edit ' + m.name : 'Add a macro', submit: m ? 'Save macro' : 'Add macro', fields: [
{ name: 'name', label: 'Name', value: e.name, required: true },
{ name: 'commands', label: 'Console commands', type: 'lines', value: e.commands, required: true, hint: 'One command per line, sent in order.' },
{ 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 formDialog({
title: u ? 'Edit ' + u.name : 'Add a user', submit: u ? 'Save user' : 'Add user', fields: [
u ? null : { name: 'name', label: 'Username', required: true },
{ name: 'role', label: 'Role', type: 'select', value: u?.role || 'viewer', options: ['viewer', 'moderator', 'admin'], hint: 'Viewers look, moderators handle players, admins run everything.' },
{ name: 'password', label: 'Password', type: 'password', required: !u, hint: u ? 'Leave empty to keep the current password. At least 8 characters.' : 'At least 8 characters.' }].filter(Boolean),
});
if (r) await act(async () => { await api('POST', '/api/users', { name: u?.name || r.name, role: r.role, password: r.password }); toast('User saved'); await draw(); })();
}
async function newToken() {
const r = await formDialog({ title: 'Create an API token', submit: 'Create token', fields: [
{ name: 'name', label: 'Name', required: true, hint: 'What will use it, such as prometheus.' },
{ name: 'role', label: 'Role', type: 'select', value: 'viewer', options: ['viewer', 'moderator', 'admin'] }] });
if (!r) return;
await act(async () => {
const { token } = await api('POST', '/api/tokens', r);
await formDialog({ title: 'Copy your token now', submit: 'Done', fields: [{ type: 'note', text: 'It is shown only once. Send it as "Authorization: Bearer" followed by the token.' }, { name: 'token', label: 'Token', value: token }] });
await draw();
})();
}
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(pageHeader(...ROUTES.settings),
section('Notifications', 'Send events to Discord, ntfy or any webhook.', h('button', { class: 'ghost', onclick: () => editNotify() }, 'Add notification'),
table(['Name', 'Kind', 'Events', ''], st.notify.map(n => h('tr', {}, h('td', {}, n.name), h('td', {}, n.kind), h('td', { class: 'meta wrap' }, n.events?.length ? n.events.join(', ') : 'Default events'),
h('td', { class: 'actions-cell' }, h('button', { class: 'ghost', onclick: act(async () => { await api('POST', '/api/notify/test', { name: n.name }); toast('Test message sent'); }) }, 'Send test'),
h('button', { class: 'ghost', onclick: () => editNotify(n) }, 'Edit'),
del('notification', n.name, 'No more events are sent there. This cannot be undone.', async () => { st.notify = st.notify.filter(x => x !== n); await save(); })))), 'No notifications yet.')),
section('Welcome message', 'Whispered to each player a few seconds after they join. {name}, {server} and {players} are filled in.',
h('button', { class: 'ghost', onclick: async () => { const r = await formDialog({ title: 'Welcome message', fields: [{ name: 'enabled', label: 'Send a welcome message', type: 'checkbox', value: w.enabled }, { name: 'message', label: 'Message', value: w.message }] }); if (r) { st.welcome = r; save(); } } }, 'Edit message'),
h('p', {}, w.enabled ? w.message : h('span', { class: 'dim' }, 'Off'))),
section('Chat filter', 'Watches player chat for the words you list. Players are warned, and after enough strikes kicked or banned.',
h('button', { class: 'ghost', onclick: async () => { const r = await formDialog({ title: 'Chat filter', fields: [{ name: 'enabled', label: 'Filter chat', type: 'checkbox', value: f.enabled }, { name: 'words', label: 'Words', type: 'lines', value: f.words, hint: 'One per line. Matches inside longer words too.' }, { 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 filter'),
h('p', {}, f.enabled ? `${f.words.length} word${f.words.length === 1 ? '' : 's'}, ${f.action} after ${f.strikes} strikes` : h('span', { class: 'dim' }, 'Off'))),
section('Macros', 'Named lists of console commands you can run from a server\'s Console tab.', h('button', { class: 'ghost', onclick: () => editMacro() }, 'Add macro'),
table(['Name', 'Commands', 'Who may run it', ''], st.macros.map(m => h('tr', {}, h('td', {}, m.name), h('td', { class: 'mono wrap' }, m.commands.join('; ')), h('td', {}, m.role),
h('td', { class: 'actions-cell' }, h('button', { class: 'ghost', onclick: () => editMacro(m) }, 'Edit'), del('macro', m.name, 'The macro is removed. This cannot be undone.', async () => { st.macros = st.macros.filter(x => x !== m); await save(); })))), 'No macros yet.')),
section('Users', 'People who can sign in to this dashboard.', h('button', { class: 'ghost', onclick: () => editUser() }, 'Add user'),
table(['User', 'Role', ''], users.map(u => h('tr', {}, h('td', {}, u.name), h('td', {}, u.role), h('td', { class: 'actions-cell' }, h('button', { class: 'ghost', onclick: () => editUser(u) }, 'Edit'),
del('user', u.name, 'They can no longer sign in. What they did stays in the event log.', async () => { await api('DELETE', '/api/users/' + encodeURIComponent(u.name)); toast('User deleted'); await draw(); })))), 'No users.')),
section('API tokens', 'For scripts and monitoring. Prometheus can scrape /metrics with a viewer token.', h('button', { class: 'ghost', onclick: newToken }, 'Create token'),
table(['Name', 'Role', ''], tokens.map(t => h('tr', {}, h('td', {}, t.name), h('td', {}, t.role), h('td', { class: 'actions-cell' },
del('token', t.name, 'Anything using it stops working. This cannot be undone.', async () => { await api('DELETE', '/api/tokens/' + encodeURIComponent(t.name)); toast('Token deleted'); await draw(); })))), 'No tokens yet.')));
}
const load = () => api('GET', '/api/settings').then(s => { st = s; st.notify ||= []; st.macros ||= []; st.jobs ||= []; st.chat_filter.words ||= []; return draw(); }).catch(e => toast(e.message, true));
load();
return { el: root };
}
boot();