diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 0ec2ea0..8396102 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -24,6 +24,9 @@ jobs: exit 1 fi + - name: Design rules + run: node scripts/lint-design.mjs + - name: Vet run: go vet ./... diff --git a/README.md b/README.md index f7ca9c7..022f5d1 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,10 @@ Prometheus: `megalodon_server_up`, `megalodon_players`, `megalodon_players_max`, report that ID. - ARK:SE only (it is untested on ASA, which uses the same protocol but may differ in detail). +## Design + +The dashboard follows the Lightwell design language, the same one Hammerhead uses: teal accent, warm neutrals, a sidebar, status badges with text, and dialogs that name what they affect. See [docs/design.md](docs/design.md). `node scripts/lint-design.mjs` checks the rules a machine can check. + ## Development ```sh diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..ae19c85 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,63 @@ +# Megalodon design language + +The dashboard follows the Lightwell design language, the same one Hammerhead uses. The reference is `docs/design.md` in the Lightwell repository, and Hammerhead's own `docs/design.md` records how it is applied there. When this page and Lightwell disagree, Lightwell wins. + +## What is shared with Lightwell and Hammerhead + +* **Tokens.** Colors, spacing (4px scale), radius (6px and 10px), type sizes (12 to 28px), the single shadow and the dark theme are the Lightwell values, copied from Hammerhead. The accent is Lightwell's teal, `#1f5c5c`, with white text on it. Everything derived from the accent uses `color-mix`. +* **Principles.** Quiet neutrals with one accent, plain and specific wording, one way to do each thing, calm density, and WCAG 2.1 AA. +* **Voice.** No emoji, no em or en dashes, no exclamation marks, sentence case, buttons that name the result ("Ban player", not "OK"), and errors that say what happened and what to do next. Errors that come from the server are capitalized in one place (`fail` in `internal/web/server.go`). +* **Status carries text.** A status is a badge with a label ("Online", "Offline", "Restarting soon"), never a colored dot on its own. + +## Where it lives + +| File | What it holds | +| --- | --- | +| `internal/web/static/style.css` | All styles. The token block sits between `tokens:begin` and `tokens:end`; raw colors are allowed only there. | +| `internal/web/static/app.js` | The dashboard: the toolkit at the top, then the pages. | +| `internal/web/static/index.html` | An empty shell that loads the two files above. | + +Unlike Hammerhead's single page, Megalodon keeps its script and styles in separate files because its Content-Security-Policy forbids inline script and inline `style` attributes. Nothing in the UI sets a style attribute; widths and sizes are classes. + +## Components + +Built from the helpers at the top of `app.js`. Feature code uses these and does not style one-off. + +| Helper | Use | +| --- | --- | +| `card`, `pageHeader` | Surfaces and the page title with its one primary action | +| `field`, `chk` | Label above the control, hint below | +| `badge(text, tone)` | Status pill. Tones: success, warning, danger, accent, or none for neutral | +| `emptyState(icon, title, text, action)` | Dashed outline, icon, a title that says "No X yet", one sentence | +| `modal`, `formDialog` | Dialogs. Focus moves in, Tab stays inside, Escape closes, focus returns to the opener | +| `confirmDialog` | Every destructive action. The title names the object, the body says what is lost and whether it can be undone | +| `toast` | Confirmations at the bottom right. Errors stay longer and are announced as alerts | +| `skeleton` | Placeholder rows while a page loads, not a spinner | + +## Icons + +Lucide style, outline only, stroke 1.75, round caps, `currentColor`. Sizes are 16, 18 and 22 pixels. They are inline in the `ICONS` table. An icon goes with a text label, and icon-only buttons carry an `aria-label`. + +Approved names: `fin`, `servers`, `players`, `bans`, `schedules`, `settings`, `activity`, `out`, `menu`, `close`, `add`, `check`, `light`, `dark`, `alert`. Add a name here and in `ICONS` together. + +## Patterns + +* **One primary action per view.** It sits in the page header: Add server on the overview, Broadcast on a server, Ban a player on the bans page, New job on schedules. Other buttons use the default (ghost) style. +* **List pages** show the page header, then the table in a card with no second title. +* **Dialogs and forms** end with Cancel and then the primary action, at the bottom right. Labels sit above fields, and rules such as "At least 8 characters" are hints below them, not part of the label. +* **Dates** have the month written out ("Nov 2, 2026"), with `fmtTime`. Event rows show the time of day, with the full date in a tooltip. +* **Empty states** say why the list is empty: nothing yet, or nothing matching the filters. + +## Layout + +* Content is capped at 1120px. Below 860px the sidebar becomes a slide-in drawer with a top bar, and grids collapse. Phones keep a 16px side gutter. +* On touch screens controls are at least 44px high and inputs are 16px so the page does not zoom. +* Motion is minimal and turns off under `prefers-reduced-motion`. The theme follows the system and can be switched from the sidebar. + +## Checking a change + +``` +node scripts/lint-design.mjs +``` + +It fails on em and en dashes, emoji, and raw colors outside the token block, in the dashboard files, the docs and the Go source. Then check the page in light and dark, at phone and desktop width, using only the keyboard. diff --git a/internal/web/server.go b/internal/web/server.go index abed830..c8bd66d 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -285,7 +285,11 @@ func writeJSON(w http.ResponseWriter, v any) { json.NewEncoder(w).Encode(v) } +// fail answers with an error. Messages are capitalized here, once, so they read as sentences. func fail(w http.ResponseWriter, code int, msg string) { + if r := []rune(msg); len(r) > 0 { + msg = strings.ToUpper(string(r[:1])) + string(r[1:]) + } w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) json.NewEncoder(w).Encode(map[string]string{"error": msg}) diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 0ef9b52..3eba72a 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -1,14 +1,6 @@ 'use strict'; -// ---------- tiny helpers ---------- -const $ = (s, el = document) => el.querySelector(s); - -function add(el, kids) { - for (const k of kids.flat(Infinity)) { - if (k == null || k === false) continue; - el.append(k.nodeType ? k : document.createTextNode(String(k))); - } -} +// ---------- 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); @@ -21,446 +13,565 @@ function h(tag, props, ...kids) { else if (k === 'checked') el.checked = true; else el.setAttribute(k, v === true ? '' : v); } - add(el, kids); + 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'); -const RANK = { viewer: 1, moderator: 2, admin: 3 }; -const can = role => me && RANK[me.role] >= RANK[role]; +// 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 */ } +} -let me = null; -let servers = []; -let view = null; // the page on screen: { el, update?, onEvent?, dispose? } -let stream = null; +// 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, path, body) { +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 resp = await fetch(path, opt); - const data = await resp.json().catch(() => ({})); - if (resp.status === 401 && me) { me = null; showLogin(); throw new Error('Signed out'); } - if (!resp.ok) throw new Error(data.error || resp.statusText); - return data; + 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); } }; -function toast(msg, bad) { - const t = h('div', { class: 'toast' + (bad ? ' bad' : '') }, msg); - $('#toasts').append(t); - setTimeout(() => t.remove(), bad ? 8000 : 4000); -} -const fail = e => toast(e.message || String(e), true); - +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}m`; - if (m) return `${m}m`; - return `${sec}s`; + if (d) return `${d} d ${hr} h`; + if (hr) return `${hr} h ${m} min`; + if (m) return `${m} min`; + return `${sec} s`; } -const since = iso => iso ? fmtDur((Date.now() - new Date(iso)) / 1000) : ''; -const clock = iso => new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); -const stamp = iso => new Date(iso).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }); -const serverName = id => (servers.find(s => s.id === id) || {}).name || id || ''; +const 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: '', + servers: '', + players: '', + bans: '', + schedules: '', + settings: '', + activity: '', + out: '', + menu: '', + close: '', + add: '', + check: '', + light: '', + dark: '', + alert: '', +}; +function icon(name, size = 18) { + const s = h('span', { class: 'inline-icon', 'aria-hidden': 'true' }); + s.innerHTML = `${ICONS[name] || ''}`; + return s; +} +const brand = () => h('div', { class: 'brand' }, icon('fin', 22), 'Megalodon'); + // ---------- dialogs ---------- -// ask shows a form and resolves with its values, or null if cancelled. -// Field types: text, password, number, textarea, lines, select, checkbox, multi, note. -function ask(title, fields, okLabel = 'Save') { - return new Promise(resolve => { - const dlg = h('dialog'); - const form = h('form'); - const inputs = {}; - const err = h('p', { class: 'err' }); - add(form, [h('h2', null, title)]); - for (const f of fields) { - if (f.type === 'note') { add(form, [h('p', { class: 'muted' }, f.text)]); continue; } - let input; - if (f.type === 'select') { - input = h('select', { value: f.value }, (f.options || []).map(o => typeof o === 'string' ? h('option', { value: o }, o) : h('option', { value: o.v }, o.l))); - input.value = f.value ?? input.options[0]?.value; - } else if (f.type === 'textarea' || f.type === 'lines') { - input = h('textarea', { placeholder: f.placeholder, rows: 5 }); - input.value = f.type === 'lines' ? (f.value || []).join('\n') : (f.value || ''); - } else if (f.type === 'checkbox') { - input = h('input', { type: 'checkbox', checked: !!f.value }); - } else if (f.type === 'multi') { - input = h('div', { class: 'multi' }, f.options.map(o => { - const v = typeof o === 'string' ? o : o.v, l = typeof o === 'string' ? o : o.l; - return h('label', null, h('input', { type: 'checkbox', value: v, checked: (f.value || []).includes(v) }), l); - })); - } else { - input = h('input', { type: f.type || 'text', placeholder: f.placeholder, autocomplete: f.type === 'password' ? 'new-password' : 'off', step: f.type === 'number' ? 'any' : null }); - input.value = f.value ?? ''; - } - inputs[f.name] = { f, input }; - if (f.type === 'checkbox') add(form, [h('label', { class: 'check' }, input, f.label)]); - else add(form, [h('label', null, f.label), input]); +// 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(); } } - const close = v => { dlg.close(); dlg.remove(); resolve(v); }; - form.addEventListener('submit', e => { - e.preventDefault(); - const out = {}; - for (const [name, { f, input }] of Object.entries(inputs)) { - let v; - if (f.type === 'checkbox') v = input.checked; - else if (f.type === 'multi') v = [...input.querySelectorAll('input:checked')].map(i => i.value); - else if (f.type === 'lines') v = input.value.split('\n').map(s => s.trim()).filter(Boolean); - else if (f.type === 'number') v = input.value === '' ? null : Number(input.value); - else v = input.value.trim(); - if (f.required && (v === '' || v == null || (Array.isArray(v) && !v.length))) { err.textContent = `${f.label} is required.`; return; } - out[name] = v; - } - close(out); - }); - add(form, [err, h('div', { class: 'buttons' }, h('button', { type: 'button', onclick: () => close(null) }, 'Cancel'), h('button', { class: 'primary', type: 'submit' }, okLabel))]); - dlg.append(form); - dlg.addEventListener('cancel', () => close(null)); - document.body.append(dlg); - dlg.showModal(); - (dlg.querySelector('input:not([type=checkbox]), textarea, select') || dlg).focus(); + } + 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) }); }); } -const confirmDlg = (title, text, ok = 'Yes') => ask(title, [{ type: 'note', text }], ok).then(Boolean); -// ---------- shell ---------- -const app = $('#app'); - -function showLogin() { - stopAll(); - const err = h('p', { class: 'err' }); - const user = h('input', { type: 'text', placeholder: 'Username', autocomplete: 'username', required: true }); - const pass = h('input', { type: 'password', placeholder: 'Password', autocomplete: 'current-password', required: true }); - const form = h('form', { - onsubmit: async e => { - e.preventDefault(); - try { - await api('POST', '/api/login', { username: user.value, password: pass.value }); - await boot(); - } catch (x) { err.textContent = x.message; pass.value = ''; } - } - }, user, pass, h('button', { class: 'primary', type: 'submit' }, 'Sign in'), err); - app.replaceChildren(h('div', { class: 'login panel' }, h('h1', null, 'Megalodon'), h('p', { class: 'muted' }, 'ARK server management'), form)); - user.focus(); +// 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) }); + }); } -let pollTimer = null; -function stopAll() { - clearInterval(pollTimer); - if (stream) stream.close(); - stream = null; view = 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() { - try { me = await api('GET', '/api/me'); } catch { me = null; return showLogin(); } - servers = await api('GET', '/api/servers').catch(() => []); - startStream(); - clearInterval(pollTimer); - pollTimer = setInterval(async () => { - try { servers = await api('GET', '/api/servers'); view?.update?.(); } catch { /* shown by api() when signed out */ } - }, 5000); - route(); + 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 startStream() { - if (stream) stream.close(); - stream = new EventSource('/api/stream'); - stream.onmessage = m => { try { view?.onEvent?.(JSON.parse(m.data)); } catch { /* ignore */ } }; +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')))); } -const NAV = [['#/', 'Overview', 'viewer'], ['#/players', 'Players', 'viewer'], ['#/bans', 'Bans', 'moderator'], ['#/schedules', 'Schedules', 'admin'], ['#/settings', 'Settings', 'admin']]; +// ---------- 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 shell(active, content) { - const nav = h('nav', null, NAV.filter(n => can(n[2])).map(n => h('a', { href: n[0], class: n[0] === active ? 'on' : '' }, n[1]))); - const bar = h('header', { class: 'bar' }, h('span', { class: 'brand' }, 'Megalodon'), nav, - h('span', { class: 'muted small' }, `${me.name} · ${me.role}`), - h('button', { class: 'small', onclick: async () => { await api('POST', '/api/logout').catch(() => { }); me = null; showLogin(); } }, 'Sign out')); - app.replaceChildren(bar, h('main', null, content)); +function 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 route() { - const parts = (location.hash.slice(2) || '').split('/'); +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'); shell('#/', page.el); break; - case 'players': page = playersPage(); shell('#/players', page.el); break; - case 'bans': page = can('moderator') ? bansPage() : overview(); shell('#/bans', page.el); break; - case 'schedules': page = can('admin') ? schedulesPage() : overview(); shell('#/schedules', page.el); break; - case 'settings': page = can('admin') ? settingsPage() : overview(); shell('#/settings', page.el); break; - default: page = overview(); shell('#/', page.el); + 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(); } - view = page; + S.view = page; + main.replaceChildren(page.el); page.update?.(); } -window.addEventListener('hashchange', () => me && route()); // ---------- actions ---------- -async function act(server, body, quiet) { +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)}: ${r.error}`).join('\n'), true); - else if (!quiet) toast(res.length === 1 && res[0].output ? res[0].output : 'Done'); - servers = await api('GET', '/api/servers'); - view?.update?.(); + 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) { fail(e); return null; } + } catch (e) { if (e.message !== SIGNED_OUT) toast(e.message, true); return null; } } async function banDialog(steamID, name, server) { - const r = await ask(`Ban ${name || steamID}`, [ - { name: 'steam_id', label: 'Steam ID', value: steamID, required: true }, - { name: 'reason', label: 'Reason', placeholder: 'Shown in the ban list' }, - { name: 'minutes', label: 'Length', type: 'select', value: '0', options: [{ v: '60', l: '1 hour' }, { v: '1440', l: '1 day' }, { v: '10080', l: '7 days' }, { v: '43200', l: '30 days' }, { v: '0', l: 'Permanent' }] }, - { name: 'all', label: 'Ban on every server (recommended for a cluster)', type: 'checkbox', value: true }, - ], 'Ban'); - if (!r) return; + const 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; - act(only ? server : null, { action: 'ban', steam_id: r.steam_id, player: name, reason: r.reason, minutes: Number(r.minutes), servers: only ? [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('span', { class: 'who' }, e.actor || 'SERVER'), ': ', e.message] : [e.actor && e.type !== 'audit' ? e.actor + ': ' : '', e.type === 'audit' && e.actor ? e.actor + ' — ' : '', e.message]; - return h('div', { class: 'ev' }, h('time', null, clock(e.time)), h('span', { class: 'tag ' + e.type }, e.type), - withServer && e.server ? h('span', { class: 'srv muted' }, serverName(e.server)) : null, h('span', { class: 'txt' }, text)); + 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; - const w = canvas.clientWidth, hgt = canvas.clientHeight; + 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); - const line = css.getPropertyValue('--accent').trim(), mute = css.getPropertyValue('--line').trim(), bad = css.getPropertyValue('--bad').trim(); + const css = getComputedStyle(document.documentElement), tok = n => css.getPropertyValue(n).trim(); g.clearRect(0, 0, w, hgt); - if (!samples.length) return; - const t1 = Date.now() / 1000, t0 = t1 - 86400; - const max = Math.max(4, ...samples.map(s => s.n)); + const 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 = mute; g.beginPath(); g.moveTo(0, hgt - .5); g.lineTo(w, hgt - .5); g.stroke(); - g.fillStyle = bad; + 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 = line; g.lineWidth = 1.6; g.beginPath(); + 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 statusPill(s) { - if (s.restart) return h('span', { class: 'pill warn' }, s.restart.stop ? 'Shutting down soon' : 'Restarting soon'); - if (s.online) return h('span', { class: 'pill ok' }, 'Online'); - return h('span', { class: 'pill bad' }, s.held ? 'Stopped' : 'Offline'); +function 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: 'grid' }); - const feed = h('div', { class: 'log' }); - const tools = h('div', { class: 'row' }); - if (can('moderator')) add(tools, [ - h('button', { onclick: async () => { const r = await ask('Broadcast to every server', [{ name: 'message', label: 'Message', required: true }], 'Send'); if (r) act(null, { action: 'broadcast', message: r.message }); } }, 'Broadcast to all'), - h('button', { onclick: () => act(null, { action: 'save' }) }, 'Save all worlds')]); - if (can('admin')) add(tools, [h('button', { onclick: addServerDialog }, 'Add server')]); - const samples = {}; - async function loadSamples(id) { try { samples[id] = await api('GET', `/api/servers/${encodeURIComponent(id)}/samples`); drawSpark(id); } catch { /* ignore */ } } - const canvases = {}; - const drawSpark = id => canvases[id] && spark(canvases[id], samples[id] || []); + 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', { class: 'stack' }, h('div', { class: 'row between' }, h('h1', null, 'Servers'), tools), cards, - h('div', { class: 'panel' }, h('h2', null, 'Recent activity'), feed)), + 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 (!servers.length) cards.append(h('div', { class: 'empty panel' }, can('admin') ? 'No servers yet. Use "Add server" to connect one over RCON.' : 'No servers are configured.')); - for (const s of servers) { - const canvas = h('canvas'); canvases[s.id] = canvas; - const info = s.info; - cards.append(h('div', { class: 'card' }, - h('h2', null, h('a', { href: '#/s/' + encodeURIComponent(s.id) }, s.name), statusPill(s)), - h('div', { class: 'muted small' }, s.online ? [`${s.players.length}${info ? '/' + info.max_players : ''} players`, info?.map ? ' · ' + info.map : '', info?.version ? ' · v' + info.version : '', s.since ? ' · up ' + since(s.since) : ''] : (s.last_error ? s.last_error : 'Waiting for a response…')), - canvas, - h('div', { class: 'row small muted' }, s.online ? `RCON ${s.rtt_ms} ms` : '', s.peak ? ` · peak ${s.peak}` : '', h('span', { class: 'spacer' }), - h('a', { href: '#/s/' + encodeURIComponent(s.id) }, 'Manage')))); - requestAnimationFrame(() => drawSpark(s.id)); + if (!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(); servers.forEach(s => loadSamples(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.prepend(eventRow(e)); while (feed.children.length > 40) feed.lastChild.remove(); } }, + 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 => evs.forEach(e => feed.append(eventRow(e)))).catch(() => { }); + 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 addServerDialog(existing) { +async function serverDialog(existing) { const e = existing || {}; - const r = await ask(existing ? `Edit ${e.name}` : 'Add a server', [ - { name: 'name', label: 'Name', value: e.name, required: true, placeholder: 'The Island' }, - { name: 'host', label: 'Host or IP', value: e.host, required: true, placeholder: '192.168.1.20' }, - { name: 'rcon_port', label: 'RCON port', type: 'number', value: e.rcon_port || 27020, required: true }, - { name: 'password', label: existing ? 'RCON password (leave blank to keep)' : 'RCON password', type: 'password', required: !existing }, - { name: 'query_port', label: 'Query port (UDP, 0 = off)', type: 'number', value: e.query_port ?? 27015 }, - { name: 'start_command', label: 'Start command (optional, runs on this machine)', value: e.start_command, placeholder: 'docker start ark-island' }, - { name: 'auto_start', label: 'Start it automatically when it has been down for a while', type: 'checkbox', value: e.auto_start }, - ]); + 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; - try { + 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); - servers = await api('GET', '/api/servers'); - view?.update?.(); - toast('Saved'); - } catch (x) { fail(x); } + 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 = () => servers.find(s => s.id === id); - if (!get()) return { el: h('div', { class: 'empty panel' }, 'No such server. ', h('a', { href: '#/' }, 'Back to overview')) }; - const head = h('div', { class: 'panel' }); - const body = h('div'); + const 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 tabBar = h('div', { class: 'tabs' }, tabs.map(t => h('a', { href: `#/s/${encodeURIComponent(id)}/${t[0]}`, class: t[0] === tab ? 'on' : '' }, t[1]))); + 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); - body.append(sub.el); function drawHead() { - const s = get(); - if (!s) return; - const info = s.info; - const ctl = h('div', { class: 'row' }); - if (can('moderator')) add(ctl, [ - h('button', { onclick: () => act(id, { action: 'save' }) }, 'Save world'), - h('button', { onclick: async () => { const r = await ask('Broadcast', [{ name: 'message', label: 'Message to every player', required: true }], 'Send'); if (r) act(id, { action: 'broadcast', message: r.message }); } }, 'Broadcast')]); + 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')) { - add(ctl, [ - s.restart ? h('button', { onclick: () => act(id, { action: 'cancel_restart' }) }, 'Cancel ' + (s.restart.stop ? 'shutdown' : 'restart')) : [ - h('button', { onclick: () => restartDialog(id, false) }, 'Restart…'), - h('button', { class: 'danger', onclick: () => restartDialog(id, true) }, 'Stop…')], - s.can_start && !s.online ? h('button', { onclick: () => act(id, { action: 'start' }) }, 'Start') : null, - h('button', { onclick: async () => { const r = await ask('Set time of day', [{ name: 'time', label: 'Time (HH:MM)', value: '12:00', required: true }]); if (r) act(id, { action: 'set_time', time: r.time }); } }, 'Set time'), - h('button', { onclick: async () => { const r = await ask('Message of the day', [{ name: 'message', label: 'Shown to players when they join', required: true }]); if (r) act(id, { action: 'motd', message: r.message }); } }, 'MOTD'), - h('button', { class: 'danger', onclick: async () => { if (await confirmDlg('Destroy wild creatures?', 'All wild creatures on this server will be removed and respawn.', 'Destroy')) act(id, { action: 'destroy_wild_dinos' }); } }, 'Wipe wild dinos'), - h('button', { onclick: () => addServerDialog(s) }, 'Edit'), - h('button', { class: 'danger', onclick: async () => { if (await confirmDlg('Remove ' + s.name + '?', 'Megalodon stops managing this server. The server itself is not touched.', 'Remove')) { await api('DELETE', '/api/servers/' + encodeURIComponent(id)).catch(fail); servers = await api('GET', '/api/servers'); location.hash = '#/'; } } }, 'Remove')]); + 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')]); } - head.replaceChildren(...[ - h('div', { class: 'row between' }, h('h1', null, s.name), statusPill(s)), - h('div', { class: 'muted small' }, `${s.host}:${s.rcon_port}`, s.online ? ` · ${s.players.length}${info ? '/' + info.max_players : ''} players` : '', info?.map ? ' · ' + info.map : '', info?.version ? ' · v' + info.version : '', s.since ? ' · up ' + since(s.since) : '', s.online ? ` · RCON ${s.rtt_ms} ms` : ''), - !s.online ? h('div', { class: 'banner bad' }, s.held ? 'Stopped from Megalodon.' : 'Not responding.', s.last_error ? ' ' + s.last_error : '', s.offline_since ? ' Down for ' + since(s.offline_since) + '.' : '') : null, - s.restart ? h('div', { class: 'banner' }, `${s.restart.stop ? 'Shutdown' : 'Restart'} at ${clock(s.restart.at)} (in ${fmtDur((new Date(s.restart.at) - Date.now()) / 1000)}) — started by ${s.restart.by}`, s.restart.reason ? ' · ' + s.restart.reason : '') : null, - ctl].filter(Boolean)); + 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', null, h('p', { class: 'small' }, h('a', { href: '#/' }, '← All servers')), head, tabBar, body), + 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 ask(stop ? 'Stop the server' : 'Restart the server', [ - { name: 'minutes', label: 'Countdown in minutes (players get warnings; 0 = now)', type: 'number', value: 5, required: true }, - { name: 'reason', label: 'Reason (shown to players)', placeholder: 'Server update' }, - ], stop ? 'Stop' : 'Restart'); - if (r) act(id, { action: stop ? 'stop' : 'restart', minutes: r.minutes, reason: r.reason }); + 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 table = h('div', { class: 'tablewrap panel' }); + const box = h('div', { class: 'card flush' }); return { - el: table, + el: box, update() { const s = get(); - if (!s?.players.length) { table.replaceChildren(h('div', { class: 'empty' }, s?.online ? 'Nobody is online.' : 'The server is offline.')); return; } - table.replaceChildren(h('table', null, h('thead', null, h('tr', null, ['Player', 'Steam ID', 'Online for', ''].map(t => h('th', null, t)))), - h('tbody', null, s.players.map(p => h('tr', null, - h('td', null, p.name, p.banned ? h('span', { class: 'pill bad' }, ' banned') : null, p.watch ? h('span', { class: 'pill warn' }, ' watched') : null, p.notes ? h('div', { class: 'muted small' }, p.notes) : null), - h('td', null, steamLink(p.steam_id)), h('td', null, since(p.joined_at) || 'just now'), - h('td', { class: 'actions' }, can('moderator') ? [ - h('button', { class: 'small', onclick: async () => { const r = await ask('Message to ' + p.name, [{ name: 'message', label: 'Private message', required: true }], 'Send'); if (r) act(id, { action: 'whisper', steam_id: p.steam_id, message: r.message }); } }, 'Message'), ' ', - h('button', { class: 'small', onclick: async () => { if (await confirmDlg('Kick ' + p.name + '?', 'They can rejoin straight away.', 'Kick')) act(id, { action: 'kick', steam_id: p.steam_id }); } }, 'Kick'), ' ', - h('button', { class: 'small danger', onclick: () => banDialog(p.steam_id, p.name, id) }, 'Ban'), ' ', - h('button', { class: 'small', onclick: () => noteDialog(p.steam_id, p.name, p.notes, p.watch) }, 'Note')] : null)))))); + 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))))))); }, }; } -async function noteDialog(steamID, name, notes, watch) { - const r = await ask('Notes on ' + name, [ - { name: 'notes', label: 'Notes (visible to moderators)', type: 'textarea', value: notes }, - { name: 'watch', label: 'Alert me when this player joins', type: 'checkbox', value: watch }]); - if (r) api('PUT', '/api/players/' + steamID, r).then(() => toast('Saved')).catch(fail); -} - function chatTab(id) { - const log = h('div', { class: 'log chat' }); - const input = h('input', { type: 'text', placeholder: 'Message to everyone', maxlength: 300, class: 'grow' }); + const 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.append(h('div', { class: 'ev' }, h('time', null, clock(e.time)), h('span', { class: e.actor === 'SERVER' ? 'who srv' : 'who' }, e.actor || 'SERVER'), h('span', { class: 'txt' }, e.message, e.detail ? h('span', { class: 'muted small' }, ` (${e.detail})`) : null))); + 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 => { evs.reverse().forEach(line); log.scrollTop = log.scrollHeight; }).catch(fail); - const send = (action) => { const m = input.value.trim(); if (!m) return; act(id, { action, message: m }, true).then(() => { input.value = ''; }); }; + 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: 'panel' }, log, can('moderator') ? h('form', { class: 'row mt', onsubmit: e => { e.preventDefault(); send('say'); } }, input, - h('button', { class: 'primary', type: 'submit' }, 'Say as SERVER'), h('button', { type: 'button', onclick: () => send('broadcast') }, 'Broadcast')) : null), + 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: 'out' }); - const input = h('input', { type: 'text', list: 'cmds', placeholder: 'ListPlayers', class: 'grow', autocomplete: 'off', spellcheck: 'false' }); - const list = h('datalist', { id: 'cmds' }, (me.reference || []).map(c => h('option', { value: c }))); - let hist = []; try { hist = JSON.parse(localStorage.getItem('megalodon.history') || '[]'); } catch { /* none */ } + 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; }; - async function run(cmd) { - print('cmd', '> ' + cmd); - try { - const res = await api('POST', `/api/servers/${encodeURIComponent(id)}/action`, { action: 'exec', command: cmd }); - res.forEach(r => print(r.ok ? '' : 'bad', r.ok ? (r.output || '(no reply)') : r.error)); - } catch (e) { print('bad', e.message); } - } - const macros = h('select', null, h('option', { value: '' }, 'Macro…'), (me.macros || []).map(m => h('option', { value: m.name }, m.name))); + 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; + const name = macros.value; macros.value = ''; if (!name) return; print('cmd', '> macro ' + name); - try { (await api('POST', `/api/servers/${encodeURIComponent(id)}/macro`, { name })).forEach(r => print(r.ok ? '' : 'bad', r.ok ? (r.output || '(no reply)') : r.error)); } catch (e) { print('bad', e.message); } + 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: 'panel' }, out, list, - h('form', { class: 'row', onsubmit: e => { + 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 { /* private mode */ } - input.value = ''; run(c); - } }, input, h('button', { class: 'primary', type: 'submit' }, 'Run'), (me.macros || []).length ? macros : null), - h('p', { class: 'muted small' }, 'Commands go straight to the server\'s console and are recorded in the event log.')), + 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: 'log' }); + 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', null, types.map(t => h('option', { value: t }, t || 'All events'))); - const q = h('input', { type: 'text', placeholder: 'Search' }); - const more = h('button', { class: 'small' }, 'Older'); + 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; } @@ -468,187 +579,193 @@ function eventsTab(id) { if (sel.value) qs.set('type', sel.value); if (q.value) qs.set('q', q.value); if (oldest) qs.set('before', oldest); - try { const evs = await api('GET', '/api/events?' + qs); evs.forEach(e => { log.append(eventRow(e, false)); oldest = e.id; }); more.hidden = evs.length < 100; } catch (e) { fail(e); } + 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: 'panel' }, h('div', { class: 'row' }, sel, q), log, h('div', { class: 'row end' }, more)), - onEvent(e) { if ((!sel.value || sel.value === e.type) && !q.value) log.prepend(eventRow(e, false)); }, + 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: 'tablewrap' }); - const q = h('input', { type: 'text', placeholder: 'Search by name or Steam ID' }); + 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('table', null, h('thead', null, h('tr', null, ['Player', 'Steam ID', 'Last seen', 'Play time', 'Joins', ''].map(t => h('th', null, t)))), - h('tbody', null, rows.slice(0, 500).map(p => h('tr', null, - h('td', null, p.name, p.watch ? h('span', { class: 'pill warn' }, ' watched') : null, p.notes ? h('div', { class: 'muted small' }, p.notes) : null), - h('td', null, steamLink(p.steam_id)), - h('td', null, stamp(p.last_seen), p.last_server ? h('span', { class: 'muted small' }, ' · ' + serverName(p.last_server)) : null), - h('td', null, fmtDur(p.seconds)), h('td', null, p.joins), - h('td', { class: 'actions' }, can('moderator') ? [h('button', { class: 'small', onclick: async () => { await noteDialog(p.steam_id, p.name, p.notes, p.watch); setTimeout(load, 300); } }, 'Note'), ' ', h('button', { class: 'small danger', onclick: () => banDialog(p.steam_id, p.name) }, 'Ban')] : null))))) - : h('div', { class: 'empty' }, 'No players recorded yet.')); + 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(fail); + 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', { class: 'stack' }, h('div', { class: 'row between' }, h('h1', null, 'Players'), q), h('div', { class: 'panel' }, body)) }; + return { el: h('div', {}, pageHeader(...ROUTES.players), h('div', { class: 'toolbar' }, q), body) }; } // ---------- bans ---------- function bansPage() { - const body = h('div', { class: 'tablewrap' }); + const body = h('div', { class: 'card flush' }, skeleton()); const load = () => api('GET', '/api/bans').then(list => { - body.replaceChildren(list.length ? h('table', null, h('thead', null, h('tr', null, ['Player', 'Steam ID', 'Reason', 'By', 'Until', ''].map(t => h('th', null, t)))), - h('tbody', null, list.map(b => h('tr', null, h('td', null, b.name || '—'), h('td', null, steamLink(b.steam_id)), h('td', null, b.reason || ''), h('td', null, b.by, h('div', { class: 'muted small' }, stamp(b.at))), - h('td', null, b.expires ? stamp(b.expires) : 'Never', b.servers?.length ? h('div', { class: 'muted small' }, b.servers.map(serverName).join(', ')) : null), - h('td', { class: 'actions' }, h('button', { class: 'small', onclick: async () => { if (await confirmDlg('Unban ' + (b.name || b.steam_id) + '?', 'They will be able to join again.', 'Unban')) { await act(null, { action: 'unban', steam_id: b.steam_id }); load(); } } }, 'Unban')))))) : h('div', { class: 'empty' }, 'No bans.')); - }).catch(fail); + 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', { class: 'stack' }, h('div', { class: 'row between' }, h('h1', null, 'Bans'), h('button', { onclick: async () => { await banDialog('', ''); setTimeout(load, 500); } }, 'Ban by Steam ID')), - h('p', { class: 'muted small' }, 'Bans are kept by Megalodon and sent to every server. A banned player who shows up anywhere is removed again, even if that server was offline when the ban was made.'), h('div', { class: 'panel' }, body)) }; + 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', 'Wipe wild dinos'], ['motd', 'Set the message of the day']]; +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']; -const serverOptions = () => servers.map(s => ({ v: s.id, l: s.name })); - function describeJob(j) { - const when = j.every ? 'every ' + j.every : 'at ' + j.at.join(', ') + (j.days?.length ? ' on ' + j.days.join(', ') : ' daily'); - return `${(ACTIONS.find(a => a[0] === j.action) || [, j.action])[1]} · ${when} · ${j.servers?.length ? j.servers.map(serverName).join(', ') : 'all servers'}`; + 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'); + const body = h('div', { class: 'card flush' }, skeleton()); let st; - const save = async () => { try { st = await api('PUT', '/api/settings', st); draw(); } catch (e) { fail(e); await load(); } }; + 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 ask(j ? 'Edit job' : 'New job', [ - { name: 'name', label: 'Name', value: e.name, required: true }, - { name: 'action', label: 'Action', type: 'select', value: e.action, options: ACTIONS.map(a => ({ v: a[0], l: a[1] })) }, - { name: 'servers', label: 'Servers (none ticked = all)', type: 'multi', options: serverOptions(), value: e.servers }, - { name: 'every', label: 'Repeat every (e.g. 30m, 6h) — leave empty to use times of day', value: e.every }, - { name: 'at', label: 'Times of day (HH:MM, comma separated)', value: (e.at || []).join(', ') }, - { name: 'days', label: 'Only on these days (times of day only)', type: 'multi', options: DAYS, value: e.days }, - { name: 'message', label: 'Message (broadcast / MOTD)', value: e.message }, - { name: 'command', label: 'Console command', value: e.command }, - { name: 'countdown', label: 'Restart countdown (e.g. 10m)', value: e.countdown }, - { name: 'reason', label: 'Restart reason shown to players', value: e.reason }, - { name: 'enabled', label: 'Enabled', type: 'checkbox', value: e.enabled }, - ]); + 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.split(',').map(s => s.trim()).filter(Boolean); + 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('table', null, h('thead', null, h('tr', null, ['', 'Job', 'What', ''].map(t => h('th', null, t)))), - h('tbody', null, st.jobs.map(j => h('tr', null, - h('td', null, h('input', { type: 'checkbox', checked: j.enabled, title: 'Enabled', onchange: ev => { j.enabled = ev.target.checked; save(); } })), - h('td', null, j.name), h('td', { class: 'muted small' }, describeJob(j)), - h('td', { class: 'actions' }, - h('button', { class: 'small', onclick: async () => { const r = await api('POST', `/api/jobs/${j.id}/run`).catch(fail); if (r) toast('Ran on ' + r.length + ' server(s)'); } }, 'Run now'), ' ', - h('button', { class: 'small', onclick: () => edit(j) }, 'Edit'), ' ', - h('button', { class: 'small danger', onclick: async () => { if (await confirmDlg('Delete ' + j.name + '?', '', 'Delete')) { st.jobs = st.jobs.filter(x => x.id !== j.id); save(); } } }, 'Delete')))))) : h('div', { class: 'empty' }, 'No scheduled jobs. Try a save every 30 minutes, or a daily restart at 04:00.')); + 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(fail); + const load = () => api('GET', '/api/settings').then(s => { st = s; st.jobs ||= []; draw(); }).catch(e => toast(e.message, true)); load(); - return { el: h('div', { class: 'stack' }, h('div', { class: 'row between' }, h('h1', null, 'Schedules'), h('button', { class: 'primary', onclick: () => st && edit() }, 'New job')), - h('p', { class: 'muted small' }, 'Times use the clock of the machine Megalodon runs on. Restarts warn players at 30, 15, 10, 5, 2 and 1 minute, then 30 and 10 seconds, and save the world first.'), h('div', { class: 'panel' }, body)) }; + 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 ---------- -function settingsPage() { - const root = h('div', { class: 'stack' }); - let st; - const save = async () => { try { st = await api('PUT', '/api/settings', st); toast('Saved'); draw(); } catch (e) { fail(e); await load(); } }; +const EVENT_TYPES = ['join', 'leave', 'online', 'offline', 'restart', 'start', 'ban', 'unban', 'kick', 'filter', 'watch', 'schedule', 'error', 'audit', 'chat']; - function section(title, hint, actions, content) { - return h('div', { class: 'panel' }, h('div', { class: 'row between' }, h('h2', null, title), h('div', { class: 'row' }, actions)), hint ? h('p', { class: 'muted small' }, hint) : null, content); - } - const table = (cols, rows) => rows.length ? h('div', { class: 'tablewrap' }, h('table', null, h('thead', null, h('tr', null, cols.map(c => h('th', null, c)))), h('tbody', null, rows))) : h('div', { class: 'empty' }, 'Nothing yet.'); - const EVENTS = ['join', 'leave', 'online', 'offline', 'restart', 'start', 'ban', 'unban', 'kick', 'filter', 'watch', 'schedule', 'error', 'audit', 'chat']; +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 ask(n ? 'Edit notification' : 'Add a notification', [ - { name: 'name', label: 'Name', value: e.name, required: true }, - { name: 'kind', label: 'Kind', type: 'select', value: e.kind, options: [{ v: 'discord', l: 'Discord webhook' }, { v: 'ntfy', l: 'ntfy topic URL' }, { v: 'json', l: 'Generic webhook (JSON)' }] }, - { name: 'url', label: 'URL', value: e.url, required: true }, - { name: 'events', label: 'Events (none ticked = joins, leaves, up/down, restarts, bans)', type: 'multi', options: EVENTS, value: e.events }, - { name: 'servers', label: 'Servers (none ticked = all)', type: 'multi', options: serverOptions(), value: e.servers }, - ]); + 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 ask(m ? 'Edit macro' : 'Add a macro', [ - { name: 'name', label: 'Name', value: e.name, required: true }, - { name: 'commands', label: 'Console commands, one per line', type: 'lines', value: e.commands, required: true }, - { name: 'role', label: 'Who may run it', type: 'select', value: e.role, options: ['moderator', 'admin'] }, - ]); + 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 ask(u ? 'Edit ' + u.name : 'Add a user', [ - u ? { type: 'note', text: 'Leave the password empty to keep it.' } : { name: 'name', label: 'Username', required: true }, - { name: 'role', label: 'Role', type: 'select', value: u?.role || 'viewer', options: ['viewer', 'moderator', 'admin'] }, - { name: 'password', label: 'Password (8+ characters)', type: 'password', required: !u }, - ].filter(Boolean)); - if (!r) return; - try { await api('POST', '/api/users', { name: u?.name || r.name, role: r.role, password: r.password }); toast('Saved'); load(); } catch (e) { fail(e); } + 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 ask('Create an API token', [ - { name: 'name', label: 'Name', required: true, placeholder: 'prometheus' }, - { name: 'role', label: 'Role', type: 'select', value: 'viewer', options: ['viewer', 'moderator', 'admin'] }, - ], 'Create'); + 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; - try { + await act(async () => { const { token } = await api('POST', '/api/tokens', r); - await ask('Copy your token now', [{ type: 'note', text: 'It is shown only once. Send it as "Authorization: Bearer ".' }, { name: 't', label: 'Token', value: token }], 'Done'); - load(); - } catch (e) { fail(e); } + 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(h('h1', null, 'Settings'), - section('Notifications', 'Send events to Discord, ntfy or any webhook.', h('button', { onclick: () => editNotify() }, 'Add'), - table(['Name', 'Kind', 'Events', ''], st.notify.map(n => h('tr', null, h('td', null, n.name), h('td', null, n.kind), h('td', { class: 'muted small' }, n.events?.length ? n.events.join(', ') : 'default'), - h('td', { class: 'actions' }, h('button', { class: 'small', onclick: () => api('POST', '/api/notify/test', { name: n.name }).then(() => toast('Sent')).catch(fail) }, 'Test'), ' ', h('button', { class: 'small', onclick: () => editNotify(n) }, 'Edit'), ' ', - h('button', { class: 'small danger', onclick: () => { st.notify = st.notify.filter(x => x !== n); save(); } }, 'Delete')))))), + 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', { onclick: async () => { const r = await ask('Welcome message', [{ name: 'enabled', label: 'Enabled', type: 'checkbox', value: w.enabled }, { name: 'message', label: 'Message', value: w.message }]); if (r) { st.welcome = r; save(); } } }, 'Edit'), - h('p', null, w.enabled ? '“' + w.message + '”' : h('span', { class: 'muted' }, 'Off'))), - section('Chat filter', 'Watches player chat for words you list. Players are warned, and after enough strikes kicked or banned.', - h('button', { onclick: async () => { const r = await ask('Chat filter', [{ name: 'enabled', label: 'Enabled', type: 'checkbox', value: f.enabled }, { name: 'words', label: 'Words, one per line', type: 'lines', value: f.words }, { name: 'action', label: 'After the strikes', type: 'select', value: f.action, options: [{ v: 'warn', l: 'Only warn' }, { v: 'kick', l: 'Kick' }, { v: 'ban', l: 'Temporary ban' }] }, { name: 'strikes', label: 'Strikes before the action', type: 'number', value: f.strikes }, { name: 'ban_minutes', label: 'Ban length in minutes', type: 'number', value: f.ban_minutes }]); if (r) { st.chat_filter = r; save(); } } }, 'Edit'), - h('p', null, f.enabled ? `${f.words.length} word(s) · ${f.action} after ${f.strikes} strikes` : h('span', { class: 'muted' }, 'Off'))), - section('Macros', 'Named lists of console commands you can run from a server\'s Console tab.', h('button', { onclick: () => editMacro() }, 'Add'), - table(['Name', 'Commands', 'Who', ''], st.macros.map(m => h('tr', null, h('td', null, m.name), h('td', { class: 'mono' }, m.commands.join(' ; ')), h('td', null, m.role), - h('td', { class: 'actions' }, h('button', { class: 'small', onclick: () => editMacro(m) }, 'Edit'), ' ', h('button', { class: 'small danger', onclick: () => { st.macros = st.macros.filter(x => x !== m); save(); } }, 'Delete')))))), - section('Users', 'People who can sign in. Viewers look, moderators handle players, admins run everything.', h('button', { onclick: () => editUser() }, 'Add'), - table(['User', 'Role', ''], users.map(u => h('tr', null, h('td', null, u.name), h('td', null, u.role), h('td', { class: 'actions' }, h('button', { class: 'small', onclick: () => editUser(u) }, 'Edit'), ' ', - h('button', { class: 'small danger', onclick: async () => { if (await confirmDlg('Delete ' + u.name + '?', '', 'Delete')) api('DELETE', '/api/users/' + encodeURIComponent(u.name)).then(load).catch(fail); } }, 'Delete')))))), - section('API tokens', 'For scripts and monitoring. Prometheus can scrape /metrics with a viewer token.', h('button', { onclick: newToken }, 'Create'), - table(['Name', 'Role', ''], tokens.map(t => h('tr', null, h('td', null, t.name), h('td', null, t.role), h('td', { class: 'actions' }, - h('button', { class: 'small danger', onclick: async () => { if (await confirmDlg('Delete token ' + t.name + '?', 'Anything using it stops working.', 'Delete')) api('DELETE', '/api/tokens/' + encodeURIComponent(t.name)).then(load).catch(fail); } }, 'Delete'))))))); + 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(fail); + 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 }; } diff --git a/internal/web/static/index.html b/internal/web/static/index.html index ccdba27..5b6a406 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -3,13 +3,13 @@ + Megalodon - +
-
diff --git a/internal/web/static/style.css b/internal/web/static/style.css index 3ac386d..59f74f6 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -1,93 +1,220 @@ -:root { - --bg: #f4f6f8; --panel: #ffffff; --text: #17212b; --muted: #5d6b79; --line: #dde3e9; - --accent: #1f6fa8; --accent-text: #ffffff; --ok: #1c8a4b; --warn: #b36b00; --bad: #c4332b; - --ok-bg: #e2f4ea; --warn-bg: #fdf0d9; --bad-bg: #fbe5e3; --chip: #eaeff3; -} -@media (prefers-color-scheme: dark) { + /* tokens:begin Design tokens, the Lightwell design language (docs/design.md). This is the only place raw colors and sizes are defined. */ :root { - --bg: #0f151b; --panel: #172029; --text: #e4ebf1; --muted: #8fa0af; --line: #2a3744; - --accent: #4aa3de; --accent-text: #0b1218; --ok: #4cc07d; --warn: #e0a243; --bad: #ef6b62; - --ok-bg: #153325; --warn-bg: #382b12; --bad-bg: #3b1d1b; --chip: #212d38; + --accent:#1f5c5c; --accent-contrast:#ffffff; + --bg:#f6f5f3; --surface:#ffffff; --surface-2:#efede9; --border:#e0ddd7; --border-strong:#c4c0b8; + --text:#1d1c1a; --text-muted:#5f5b54; --text-faint:#7a766e; + --accent-text:var(--accent); + --accent-soft:color-mix(in srgb, var(--accent) 9%, var(--surface)); + --accent-hover:color-mix(in srgb, var(--accent) 88%, #000000); + --focus:color-mix(in srgb, var(--accent) 70%, #ffffff); + --success:#1c6b3a; --success-bg:#e3f1e8; --warning:#85560a; --warning-bg:#f9eed6; --danger:#a3271e; --danger-bg:#f9e6e3; --neutral-bg:#eceae6; + --overlay:rgb(20 19 17 / 0.55); --shadow:0 8px 24px rgb(20 19 17 / 0.14); + --s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px; --s6:32px; --s7:48px; --s8:64px; + --radius:6px; --radius-lg:10px; --border-w:1px; + --font:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif; + --font-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace; + --text-xs:12px; --text-sm:13px; --text-base:15px; --text-lg:18px; --text-xl:22px; --text-2xl:28px; --leading:1.5; + --sidebar-w:232px; --content-max:1120px; --touch:44px; --control-h:38px; + --z-sticky:20; --z-overlay:100; --z-toast:200; --ease:140ms ease; + color-scheme:light; } -} -* { box-sizing: border-box; } -body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; } -a { color: var(--accent); } -h1, h2, h3 { margin: 0 0 .5rem; line-height: 1.2; } -h1 { font-size: 1.5rem; } h2 { font-size: 1.15rem; } h3 { font-size: 1rem; } -.muted { color: var(--muted); } .small { font-size: .85rem; } .mono { font-family: ui-monospace, Consolas, monospace; font-size: .85rem; } -.err { color: var(--bad); min-height: 1.2em; margin: .4rem 0 0; } + @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { + --bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943; + --text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84; + --accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff); + --accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface)); + --accent-hover:color-mix(in srgb, var(--accent) 85%, #ffffff); + --success:#86d3a0; --success-bg:#17301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --neutral-bg:#2b2a27; + --overlay:rgb(0 0 0 / 0.65); --shadow:0 8px 24px rgb(0 0 0 / 0.5); color-scheme:dark; + } } + :root[data-theme="dark"] { + --bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943; + --text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84; + --accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff); + --accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface)); + --accent-hover:color-mix(in srgb, var(--accent) 85%, #ffffff); + --success:#86d3a0; --success-bg:#17301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --neutral-bg:#2b2a27; + --overlay:rgb(0 0 0 / 0.65); --shadow:0 8px 24px rgb(0 0 0 / 0.5); color-scheme:dark; + } + /* tokens:end */ -header.bar { display: flex; align-items: center; gap: 1rem; padding: .6rem 16px; background: var(--panel); border-bottom: 1px solid var(--line); flex-wrap: wrap; position: sticky; top: 0; z-index: 5; } -header.bar .brand { font-weight: 700; font-size: 1.1rem; margin-right: .5rem; } -header.bar nav { display: flex; gap: .25rem; flex-wrap: wrap; flex: 1; } -header.bar nav a { padding: .35rem .7rem; border-radius: 6px; color: var(--text); text-decoration: none; } -header.bar nav a.on { background: var(--chip); font-weight: 600; } -main { max-width: 1120px; margin: 0 auto; padding: 16px; } + *, *::before, *::after { box-sizing:border-box; } + html, body { height:100%; } + html { -webkit-text-size-adjust:100%; } + body { margin:0; background:var(--bg); color:var(--text); font:var(--text-base)/var(--leading) var(--font); -webkit-font-smoothing:antialiased; } + h1, h2, h3, h4 { margin:0; line-height:1.25; font-weight:600; letter-spacing:-.01em; } + h1 { font-size:var(--text-2xl); } h2 { font-size:var(--text-xl); } h3 { font-size:var(--text-lg); } + p { margin:0 0 var(--s3); } + a { color:var(--accent-text); text-underline-offset:2px; } + a:hover { text-decoration-thickness:2px; } + :focus-visible { outline:2px solid var(--focus); outline-offset:2px; border-radius:var(--radius); } + [hidden] { display:none !important; } + .sr-only { position:absolute; width:1px; height:1px; margin:-1px; padding:0; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; border:0; } + .skip { position:absolute; left:var(--s3); top:-100px; z-index:var(--z-toast); background:var(--surface); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius); padding:var(--s2) var(--s3); } + .skip:focus { top:var(--s3); } + .dim { color:var(--text-muted); } .err { color:var(--danger); } .small { font-size:var(--text-sm); } + .num { font-variant-numeric:tabular-nums; } + .clip { display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden; overflow-wrap:anywhere; max-width:340px; } + code, .mono { font-family:var(--font-mono); font-size:var(--text-sm); } + .icon { width:18px; height:18px; flex:none; stroke-width:1.75; } + .inline-icon { display:inline-flex; } -.grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); } -.card, .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; } -.card h2 { display: flex; justify-content: space-between; align-items: center; gap: .5rem; } -.card canvas { width: 100%; height: 44px; display: block; margin: .6rem 0; } -.row { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; } -.row.end { justify-content: flex-end; } .row.between { justify-content: space-between; } -.spacer { flex: 1; } -.stack > * + * { margin-top: 14px; } + /* shell */ + .shell { display:grid; grid-template-columns:var(--sidebar-w) minmax(0,1fr); min-height:100vh; } + .sidebar { position:sticky; top:0; height:100vh; display:flex; flex-direction:column; gap:var(--s3); padding:var(--s5) var(--s3); background:var(--surface); border-right:var(--border-w) solid var(--border); overflow-y:auto; } + .sidebar-head { display:flex; align-items:center; justify-content:space-between; gap:var(--s2); } + .sidebar button.sidebar-close { display:none; } + .brand { display:flex; align-items:center; gap:var(--s2); font-size:var(--text-lg); font-weight:700; letter-spacing:-.02em; padding:0 var(--s3); color:var(--text); text-decoration:none; } + .brand .icon { color:var(--accent-text); } + .navsec { font-size:var(--text-xs); font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--text-muted); padding:var(--s4) var(--s3) var(--s1); } + .nav { display:flex; flex-direction:column; gap:2px; } + .navitem { display:flex; align-items:center; gap:var(--s3); width:100%; min-height:36px; padding:var(--s2) var(--s3); border-radius:var(--radius); color:var(--text-muted); text-decoration:none; font:inherit; font-weight:500; background:none; border:0; cursor:pointer; text-align:left; } + .navitem:hover { background:var(--surface-2); color:var(--text); } + .navitem.on { background:var(--accent-soft); color:var(--accent-text); } + .navitem .label { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .navitem .state { font-size:var(--text-xs); font-weight:600; } + .navitem .state.up { color:var(--success); } .navitem .state.down { color:var(--danger); } + .grow { flex:1 1 auto; min-width:0; } + .userbox { border-top:var(--border-w) solid var(--border); padding-top:var(--s3); } + .userbox .who { padding:var(--s1) var(--s3) var(--s2); font-size:var(--text-sm); color:var(--text-muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .content { min-width:0; } + main { max-width:var(--content-max); margin:0 auto; padding:var(--s6) var(--s5) var(--s8); } + main:focus { outline:none; } + .topbar, .backdrop { display:none; } + .page-h { display:flex; align-items:flex-start; justify-content:space-between; gap:var(--s4); margin-bottom:var(--s5); flex-wrap:wrap; } + .page-h h1 { display:flex; align-items:center; gap:var(--s3); flex-wrap:wrap; } + .page-h p { margin:var(--s1) 0 0; color:var(--text-muted); max-width:70ch; } + .page-h .btn { white-space:nowrap; } + .back { display:inline-block; margin-bottom:var(--s3); font-size:var(--text-sm); } -.pill { display: inline-block; padding: .1rem .55rem; border-radius: 99px; font-size: .78rem; font-weight: 600; background: var(--chip); color: var(--muted); } -.pill.ok { background: var(--ok-bg); color: var(--ok); } .pill.bad { background: var(--bad-bg); color: var(--bad); } .pill.warn { background: var(--warn-bg); color: var(--warn); } -.banner { padding: .55rem .8rem; border-radius: 8px; background: var(--warn-bg); color: var(--warn); margin: .6rem 0; display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; } -.banner.bad { background: var(--bad-bg); color: var(--bad); } + /* cards, layout */ + .card { background:var(--surface); border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); padding:var(--s5); margin-bottom:var(--s4); overflow-x:auto; } + .card.flush { padding:0; overflow:hidden; } + .card > h2 { font-size:var(--text-lg); margin:0 0 var(--s3); } + .card-h { display:flex; align-items:center; justify-content:space-between; gap:var(--s3); margin-bottom:var(--s3); flex-wrap:wrap; } + .card-h h2 { font-size:var(--text-lg); margin:0; } + .cards { display:grid; grid-template-columns:repeat(auto-fill,minmax(300px,1fr)); gap:var(--s4); margin-bottom:var(--s4); } + .cards > .card { margin:0; display:flex; flex-direction:column; gap:var(--s2); } + .cards h3 { font-size:var(--text-lg); } + .cards h3 a { color:var(--text); text-decoration:none; } .cards h3 a:hover { text-decoration:underline; } + .meta { color:var(--text-muted); font-size:var(--text-sm); } + .stack { display:flex; flex-direction:column; gap:var(--s4); } + .scroll { overflow-x:auto; } + .actions { display:flex; gap:var(--s2); flex-wrap:wrap; } + td.actions-cell { text-align:right; white-space:nowrap; } + td.actions-cell button + button { margin-left:var(--s2); } + .form-actions { justify-content:flex-end; margin-top:var(--s2); } + .toolbar { display:flex; align-items:center; gap:var(--s3); margin-bottom:var(--s3); flex-wrap:wrap; } + .toolbar input[type=search], .toolbar input[type=text], .toolbar select { max-width:320px; } + .toolbar .push { margin-left:auto; } + .center { max-width:440px; margin:var(--s8) auto; padding:0 var(--s4); } + .center .brand { justify-content:center; font-size:var(--text-xl); padding-bottom:var(--s4); } + .spark { width:100%; height:48px; display:block; } + .sendrow { display:flex; gap:var(--s2); margin-top:var(--s3); flex-wrap:wrap; } + .sendrow input { flex:1 1 220px; width:auto; } -button, .btn { font: inherit; padding: .35rem .8rem; border-radius: 7px; border: 1px solid var(--line); background: var(--panel); color: var(--text); cursor: pointer; } -button:hover { border-color: var(--accent); } -button.primary { background: var(--accent); color: var(--accent-text); border-color: var(--accent); font-weight: 600; } -button.danger { color: var(--bad); } button.danger:hover { border-color: var(--bad); } -button.small { padding: .15rem .5rem; font-size: .82rem; } -button:disabled { opacity: .5; cursor: default; } -input, select, textarea { font: inherit; color: var(--text); background: var(--bg); border: 1px solid var(--line); border-radius: 7px; padding: .4rem .55rem; max-width: 100%; } -input:focus, select:focus, textarea:focus, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } -input[type=checkbox] { width: 1.1rem; height: 1.1rem; accent-color: var(--accent); } -textarea { width: 100%; min-height: 5rem; font-family: ui-monospace, Consolas, monospace; font-size: .88rem; } + /* forms */ + label { display:flex; flex-direction:column; gap:var(--s1); font-size:var(--text-sm); font-weight:600; color:var(--text); } + label.chk { flex-direction:row; align-items:flex-start; gap:var(--s2); font-size:var(--text-base); font-weight:400; } + input, select, textarea { background:var(--surface); color:var(--text); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius); padding:var(--s2) var(--s3); min-height:var(--control-h); font:inherit; font-weight:400; min-width:0; width:100%; } + textarea { min-height:96px; resize:vertical; font-family:var(--font-mono); font-size:var(--text-sm); } + input[type=checkbox] { width:16px; height:16px; min-height:0; padding:0; margin-top:3px; accent-color:var(--accent); } + input:focus, select:focus, textarea:focus { outline:2px solid var(--focus); outline-offset:0; border-color:var(--accent); } + input:disabled, select:disabled { opacity:.6; } + input[aria-invalid="true"] { border-color:var(--danger); } + .sub { color:var(--text-muted); font-size:var(--text-sm); font-weight:400; } + fieldset { border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); padding:var(--s4); margin:0; min-width:0; } + legend { font-size:var(--text-sm); font-weight:600; color:var(--text-muted); padding:0 var(--s2); } + .pillrow { display:flex; flex-wrap:wrap; gap:var(--s2) var(--s4); } -.tabs { display: flex; gap: .25rem; border-bottom: 1px solid var(--line); margin: 1rem 0; flex-wrap: wrap; } -.tabs a { padding: .45rem .9rem; text-decoration: none; color: var(--muted); border-bottom: 2px solid transparent; } -.tabs a.on { color: var(--text); border-color: var(--accent); font-weight: 600; } + /* buttons: btn is the primary action, ghost the default, danger and quiet the others */ + button.btn, button.ghost { display:inline-flex; align-items:center; justify-content:center; gap:var(--s2); min-height:var(--control-h); padding:0 var(--s4); border-radius:var(--radius); font:inherit; font-weight:500; cursor:pointer; white-space:nowrap; text-decoration:none; transition:background var(--ease), border-color var(--ease); } + button.btn { background:var(--accent); color:var(--accent-contrast); border:var(--border-w) solid var(--accent); } + button.btn:hover { background:var(--accent-hover); border-color:var(--accent-hover); } + button.ghost { background:var(--surface); border:var(--border-w) solid var(--border-strong); color:var(--text); } + button.ghost:hover { background:var(--surface-2); } + button.quiet { background:transparent; border-color:transparent; } + button.danger { color:var(--danger); border-color:var(--danger); } + button.danger:hover { background:var(--danger-bg); } + button.btn-icon { width:var(--control-h); padding:0; } + td button.ghost, .actions button.ghost { min-height:30px; padding:0 var(--s3); font-size:var(--text-sm); } + button:disabled { opacity:.55; cursor:not-allowed; } -table { width: 100%; border-collapse: collapse; } -th, td { text-align: left; padding: .45rem .6rem; border-bottom: 1px solid var(--line); vertical-align: middle; } -th { font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); } -td.actions { white-space: nowrap; text-align: right; } -.tablewrap { overflow-x: auto; } -.empty { padding: 1.2rem; text-align: center; color: var(--muted); } + /* tables */ + table { width:100%; border-collapse:collapse; } + th { text-align:left; font-size:var(--text-sm); color:var(--text-muted); font-weight:600; padding:var(--s3) var(--s4); border-bottom:var(--border-w) solid var(--border); white-space:nowrap; background:var(--surface); } + td { padding:var(--s3) var(--s4); border-bottom:var(--border-w) solid var(--border); vertical-align:middle; } + tbody tr:hover td { background:var(--surface-2); } + tr:last-child td { border-bottom:0; } + td.wrap { overflow-wrap:anywhere; min-width:150px; max-width:320px; } -.log { max-height: 55vh; overflow-y: auto; font-size: .9rem; } -.ev { display: flex; gap: .6rem; padding: .25rem 0; border-bottom: 1px solid var(--line); align-items: baseline; } -.ev time { color: var(--muted); font-size: .8rem; white-space: nowrap; min-width: 4.8rem; } -.ev .tag { font-size: .7rem; text-transform: uppercase; padding: 0 .4rem; border-radius: 4px; background: var(--chip); color: var(--muted); white-space: nowrap; } -.ev .tag.join, .ev .tag.online, .ev .tag.start { background: var(--ok-bg); color: var(--ok); } -.ev .tag.leave { background: var(--chip); } -.ev .tag.offline, .ev .tag.ban, .ev .tag.kick, .ev .tag.error { background: var(--bad-bg); color: var(--bad); } -.ev .tag.restart, .ev .tag.filter, .ev .tag.watch, .ev .tag.schedule { background: var(--warn-bg); color: var(--warn); } -.ev .txt { overflow-wrap: anywhere; } -.chat .who { font-weight: 600; } .chat .srv { color: var(--accent); } -pre.out { background: var(--bg); border: 1px solid var(--line); border-radius: 8px; padding: .7rem; min-height: 12rem; max-height: 50vh; overflow: auto; margin: 0 0 .6rem; white-space: pre-wrap; overflow-wrap: anywhere; font-size: .85rem; } -pre.out .cmd { color: var(--accent); } pre.out .bad { color: var(--bad); } + /* badges: status always carries a text label, never color alone */ + .badge { display:inline-flex; align-items:center; gap:var(--s1); padding:2px var(--s2); border-radius:999px; font-size:var(--text-xs); font-weight:600; background:var(--neutral-bg); color:var(--text-muted); white-space:nowrap; } + .badge-success { background:var(--success-bg); color:var(--success); } + .badge-warning { background:var(--warning-bg); color:var(--warning); } + .badge-danger { background:var(--danger-bg); color:var(--danger); } + .badge-accent { background:var(--accent-soft); color:var(--accent-text); } + .tag { display:inline-block; padding:1px var(--s2); border:var(--border-w) solid var(--border); border-radius:var(--radius); font-size:var(--text-xs); color:var(--text-muted); } + .logbox { margin:0; background:var(--bg); border:var(--border-w) solid var(--border); border-radius:var(--radius); padding:var(--s3); font:var(--text-sm)/1.6 var(--font-mono); height:320px; overflow:auto; white-space:pre-wrap; word-break:break-word; } + .logbox .cmd { color:var(--accent-text); } .logbox .bad { color:var(--danger); } -dialog { border: 1px solid var(--line); border-radius: 12px; background: var(--panel); color: var(--text); padding: 18px 20px; width: min(520px, calc(100vw - 32px)); } -dialog::backdrop { background: rgba(0,0,0,.45); } -dialog label { display: block; margin: .6rem 0 .15rem; font-size: .85rem; color: var(--muted); } -dialog label.check { display: flex; gap: .5rem; align-items: center; color: var(--text); font-size: .95rem; } -dialog input[type=text], dialog input[type=number], dialog input[type=password], dialog select { width: 100%; } -dialog .multi { display: flex; flex-wrap: wrap; gap: .3rem .9rem; } -dialog .multi label { display: flex; gap: .35rem; align-items: center; margin: 0; color: var(--text); font-size: .9rem; } -dialog .buttons { display: flex; justify-content: flex-end; gap: .5rem; margin-top: 1rem; } + /* the event and chat feed */ + .feed { max-height:60vh; overflow-y:auto; } + .ev { display:grid; grid-template-columns:5.5rem 6rem minmax(0,1fr); gap:var(--s3); align-items:baseline; padding:var(--s2) 0; border-bottom:var(--border-w) solid var(--border); font-size:var(--text-sm); } + .ev:last-child { border-bottom:0; } + .ev time { color:var(--text-muted); font-variant-numeric:tabular-nums; white-space:nowrap; } + .ev .txt { overflow-wrap:anywhere; } + .ev .txt strong { font-weight:600; } + .ev.chat { grid-template-columns:5.5rem minmax(0,1fr); } + .ev .srv { color:var(--text-muted); } -#toasts { position: fixed; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: .5rem; z-index: 50; max-width: min(420px, calc(100vw - 32px)); } -.toast { background: var(--text); color: var(--bg); padding: .6rem .9rem; border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,.25); overflow-wrap: anywhere; } -.toast.bad { background: var(--bad); color: #fff; } + /* empty state, notice, loading */ + .empty { text-align:center; padding:var(--s7) var(--s5); color:var(--text-muted); border:var(--border-w) dashed var(--border-strong); border-radius:var(--radius-lg); margin:var(--s3); } + .empty h3 { color:var(--text); margin-bottom:var(--s1); } + .empty p { margin:0 auto var(--s3); max-width:56ch; } + .empty .icon { width:28px; height:28px; margin:0 auto var(--s3); color:var(--text-faint); display:block; } + .card > .empty { margin:0; } + .notice { padding:var(--s3) var(--s4); border-radius:var(--radius); border:var(--border-w) solid var(--border); background:var(--surface-2); font-size:var(--text-sm); margin-bottom:var(--s4); display:flex; gap:var(--s3); align-items:center; flex-wrap:wrap; } + .notice-warning { background:var(--warning-bg); border-color:var(--warning); color:var(--warning); } + .notice-danger { background:var(--danger-bg); border-color:var(--danger); color:var(--danger); } + .skeleton { background:linear-gradient(90deg, var(--surface-2), var(--border), var(--surface-2)); background-size:200% 100%; animation:shimmer 1.3s infinite; border-radius:var(--radius); height:16px; margin-bottom:14px; } + .w60 { width:60%; } .w75 { width:75%; } .w90 { width:90%; } .w100 { width:100%; } + @keyframes shimmer { to { background-position:-200% 0; } } -.login { max-width: 340px; margin: 14vh auto 0; } -.login form > * { display: block; width: 100%; margin-top: .6rem; } -@media (max-width: 640px) { main { padding: 10px; } td.actions { white-space: normal; } } -.grow { flex: 1; min-width: 0; } .mt { margin-top: .7rem; } + /* dialogs, tabs, toasts */ + .shade { position:fixed; inset:0; background:var(--overlay); display:grid; grid-template-columns:minmax(0,1fr); place-items:start center; padding:var(--s7) var(--s4); overflow:auto; z-index:var(--z-overlay); } + .modal { background:var(--surface); border-radius:var(--radius-lg); box-shadow:var(--shadow); padding:var(--s5); width:min(480px,100%); } + .modal.wide { width:min(640px,100%); } + .modal h2 { margin:0 0 var(--s3); font-size:var(--text-lg); } + .tabs2 { display:inline-flex; margin-bottom:var(--s4); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius); overflow:hidden; max-width:100%; flex-wrap:wrap; background:var(--surface); } + .tabs2 a { background:var(--surface); color:var(--text-muted); padding:0 var(--s4); min-height:var(--control-h); display:inline-flex; align-items:center; text-decoration:none; font-weight:500; } + .tabs2 a + a { border-left:var(--border-w) solid var(--border-strong); } + .tabs2 a:hover { background:var(--surface-2); } + .tabs2 a.on { background:var(--accent-soft); color:var(--accent-text); } + .toasts { position:fixed; right:var(--s4); bottom:var(--s4); z-index:var(--z-toast); display:flex; flex-direction:column; gap:var(--s2); max-width:min(360px, calc(100vw - 32px)); } + .toast { background:var(--text); color:var(--bg); padding:var(--s3) var(--s4); border-radius:var(--radius); box-shadow:var(--shadow); font-size:var(--text-sm); overflow-wrap:anywhere; } + .toast-error { background:var(--danger); color:var(--accent-contrast); } + + @media (prefers-reduced-motion: reduce) { .skeleton { animation:none; } * { transition:none !important; } html { scroll-behavior:auto; } } + @media (pointer: coarse) { + button.btn, button.ghost, .navitem, input, select, .tabs2 a { min-height:var(--touch); } + td button.ghost, .actions button.ghost { min-height:36px; } + input, select, textarea { font-size:16px; } + } + @media (max-width:860px) { + .shell { grid-template-columns:minmax(0,1fr); grid-template-rows:auto minmax(0,1fr); } + .sidebar { display:none; } + .sidebar.open { display:flex; position:fixed; inset:0 auto 0 0; width:min(280px,85vw); height:100dvh; z-index:var(--z-overlay); box-shadow:var(--shadow); } + .sidebar button.sidebar-close { display:inline-flex; } + .topbar { display:flex; align-items:center; justify-content:space-between; min-height:52px; padding:var(--s2) var(--s4); background:var(--surface); border-bottom:var(--border-w) solid var(--border); position:sticky; top:0; z-index:var(--z-sticky); } + .backdrop.open { display:block; position:fixed; inset:0; background:var(--overlay); z-index:calc(var(--z-overlay) - 1); } + main { padding:var(--s5) var(--s4) var(--s7); } + .ev { grid-template-columns:4.5rem minmax(0,1fr); } .ev .badge { display:none; } + } + @media (max-width:640px) { + .modal { padding:var(--s4); } + .shade { padding:var(--s4) var(--s3); } + .toasts { left:var(--s3); right:var(--s3); bottom:var(--s3); max-width:none; } + .page-h > .btn { width:100%; } + } + .icon-16 { width:16px; height:16px; } .icon-22 { width:22px; height:22px; } diff --git a/scripts/lint-design.mjs b/scripts/lint-design.mjs new file mode 100644 index 0000000..4d89efe --- /dev/null +++ b/scripts/lint-design.mjs @@ -0,0 +1,47 @@ +// Style gate for the dashboard, following the Lightwell design language (see docs/design.md). +// Fails on em or en dashes, emoji, exclamation marks in interface copy, and raw colors outside the +// token block of the dashboard stylesheet. Run with: node scripts/lint-design.mjs +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { extname, join, relative, sep } from "node:path"; + +const root = new URL("..", import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1"); +const skipDirs = new Set(["node_modules", ".git", "dist", "bin", "data"]); +const exts = new Set([".html", ".css", ".js", ".md", ".go", ".sh", ".mjs", ".json", ".yml"]); + +const dash = /[–—−]/; +const emoji = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{1F900}-\u{1F9FF}\u{FE0F}\u{2B50}\u{2705}\u{274C}]/u; +const rawColor = /#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(/; + +function* walk(p) { + const st = statSync(p, { throwIfNoEntry: false }); + if (!st) return; + if (st.isFile()) return yield p; + for (const name of readdirSync(p)) if (!skipDirs.has(name)) yield* walk(join(p, name)); +} + +const problems = []; +for (const file of walk(root)) { + if (!exts.has(extname(file))) continue; + const rel = relative(root, file).split(sep).join("/"); + if (rel === "scripts/lint-design.mjs" || rel === "megalodon.json") continue; + const isPage = rel.startsWith("internal/web/static/"); + let inTokens = false; + readFileSync(file, "utf8").split("\n").forEach((line, i) => { + const at = (msg) => problems.push(`${rel}:${i + 1}: ${msg}`); + if (line.includes("tokens:begin")) inTokens = true; + if (dash.test(line)) at("em dash, en dash or minus sign. Use a comma, colon, period or the word to."); + if (emoji.test(line)) at("emoji or pictographic symbol. Use an approved outline icon."); + if (isPage && !inTokens && rawColor.test(line.replace(/\/\*.*?\*\//g, "")) && !/placeholder|href="#|&#/.test(line)) { + at("raw color outside the token block. Use a token."); + } + if (isPage && /(?:toast|textContent\s*=|class:\s*'(?:empty|notice)')[^;\n]*'[^']*[A-Za-z]![^']*'/.test(line)) at("exclamation mark in interface copy."); + if (line.includes("tokens:end")) inTokens = false; + }); +} + +if (problems.length) { + console.error(problems.join("\n")); + console.error(`\n${problems.length} style problem(s).`); + process.exit(1); +} +console.log("Style checks passed.");