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>
774 lines
58 KiB
JavaScript
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();
|