Files
DarkflameServer/dDashboardServer/static/js/dashboard.js
Aaron Kimbrell 6c43753fb2 feat(dashboard): chat flags queue
Staff flag chat for review (chat_flag): one message or several from one
conversation, with who it is about and a note. The flag keeps a copy of the
messages and 10 before and after. The Chat Flags page (Inbox, badge for open
flags) filters by status, character and account; a flag links to the character,
the account (mute, warn, ban), their player reports, their other flags and the
conversation, and shows its history. Reviewers (chat_flag_review) mark it
actioned or dismissed, change the note, link a player report or comment; all of
it is in the flag's history and the audit log. The sidebar adds Team Chat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:23:23 -05:00

348 lines
13 KiB
JavaScript

/**
* Live updates over a single WebSocket.
*
* The server pushes three topics:
* - dashboard_update : server status and totals
* - table_changed : {table, id?} - refetch that table through the normal API
* - moderation_counts : moderation queue sizes for the sidebar badges (GM 3+)
*
* Pages call Live.watchTable(name, dataTable), Live.on(name, callback) or Live.refreshPage(names, id) to react to
* changes; what a page registers lasts until nav.js swaps in another page (Live.resetPage). Game servers report their
* writes as they happen (world -> master -> dashboard), and the dashboard also diffs a database snapshot for writers
* outside the game. After a reconnect every watcher is told to resync, since changes may have been missed while the
* socket was down.
*/
(function () {
'use strict';
var MAX_BACKOFF_MS = 30000;
var RELOAD_THROTTLE_MS = 1500; // busy servers report changes constantly; reload tables at most this often
var PAGE_REFRESH_MIN_MS = 3000;
var ws = null;
var attempts = 0;
var tableWatchers = {}; // table name -> [callback]
var statusWatchers = [];
var actionWaiters = {}; // requestId -> callback for actions started from this tab
var topicWatchers = {}; // extra topics a page asked for (e.g. player_positions) -> [callback]
var wasConnected = false;
var pageGeneration = 0; // goes up on every page change; callbacks of a page that's gone do nothing
// The restart countdown lives across page changes, so it keeps the browser's own timer (nav.js stops the page's)
var coreSetInterval = window.setInterval.bind(window);
function setIndicator(text, cls) {
var el = document.getElementById('live-indicator');
if (!el) return;
el.textContent = text;
el.className = cls;
}
function subscribe(topic) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ event: 'subscribe', subscription: topic }));
}
function notifyTable(table, data) {
(tableWatchers[table] || []).forEach(function (cb) {
try { cb(data); } catch (e) { console.error(e); }
});
}
function handleMessage(data) {
switch (data.event) {
case 'dashboard_update':
statusWatchers.forEach(function (cb) { cb(data); });
break;
case 'table_changed':
notifyTable(data.table, data);
break;
case 'moderation_counts':
updateBadges(data);
break;
case 'action_result':
resolveAction(data);
break;
case 'session_ended':
// Signed out everywhere, banned or no longer allowed on the dashboard
api.get('/api/auth/me').then(function (me) { if (!me.valid) window.location.href = '/login'; }).catch(function () {});
break;
default:
(topicWatchers[data.event] || []).forEach(function (cb) {
try { cb(data); } catch (e) { console.error(e); }
});
}
if (data.event === 'dashboard_update') showRestart(data.restart);
}
function connect() {
var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
try {
ws = new WebSocket(protocol + '//' + window.location.host + '/ws');
} catch (e) {
scheduleReconnect();
return;
}
ws.onopen = function () {
attempts = 0;
setIndicator('live', 'text-success');
if (wasConnected) {
// Anything could have changed while we were away
Object.keys(tableWatchers).forEach(function (table) { notifyTable(table, { table: table, resync: true }); });
}
wasConnected = true;
subscribe('dashboard_update');
if (DASH.gmLevel >= 1) subscribe('table_changed');
if (DASH.can('moderate_names')) subscribe('moderation_counts');
subscribe('action_result');
Object.keys(topicWatchers).forEach(subscribe);
};
ws.onmessage = function (event) {
try { handleMessage(JSON.parse(event.data)); } catch (e) { /* ignore malformed frames */ }
};
ws.onclose = function () {
ws = null;
setIndicator('reconnecting…', 'text-warning');
scheduleReconnect();
};
ws.onerror = function () { /* onclose follows */ };
}
function scheduleReconnect() {
attempts++;
var delay = Math.min(MAX_BACKOFF_MS, 1000 * Math.pow(2, attempts - 1));
// Our session may have expired; check before hammering the socket
if (attempts > 3) {
api.get('/api/auth/me').then(function (me) { if (!me.valid) window.location.href = '/login'; }).catch(function () {});
}
setTimeout(connect, delay);
}
function resolveAction(result) {
var cb = actionWaiters[result.requestId];
if (!cb) return;
delete actionWaiters[result.requestId];
cb(result);
}
function updateBadges(counts) {
var map = {
'moderation': (counts.pendingNames || 0) + (DASH.can('moderate_pet_names') ? counts.pendingPetNames || 0 : 0) + (DASH.can('guilds_manage') ? counts.pendingGuildNames || 0 : 0) + (DASH.can('moderate_properties') ? counts.pendingProperties || 0 : 0),
'pet_names': counts.pendingPetNames || 0,
'guilds': counts.pendingGuildNames || 0,
'bug_reports': counts.unresolvedBugReports || 0,
'reports': counts.openEconomyFlags || 0,
'chat_flags': counts.openChatFlags || 0
};
Object.keys(map).forEach(function (key) {
var el = document.querySelector('[data-badge="' + key + '"]');
if (!el) return;
el.textContent = map[key];
el.classList.toggle('d-none', !map[key]);
});
}
// Run fn at most once per interval, always including the last call (leading and trailing)
function throttle(fn, interval) {
var last = 0, timer = null;
return function () {
var wait = last + interval - Date.now();
if (wait <= 0 && !timer) {
last = Date.now();
fn();
} else if (!timer) {
timer = setTimeout(function () { timer = null; last = Date.now(); fn(); }, Math.max(wait, 0));
}
};
}
// Whether the user is in the middle of something a page reload would throw away
function isBusy() {
var active = document.activeElement;
if (active && active !== document.body && active.matches('input:not([type=checkbox]):not([type=radio]), textarea, select, [contenteditable]')) return true;
if (document.querySelector('.modal.show, .dropdown-menu.show, .offcanvas.show')) return true;
var selection = window.getSelection && window.getSelection();
return !!(selection && String(selection).length);
}
var SCROLL_KEY = 'live-scroll:' + window.location.pathname + window.location.search;
try {
var savedScroll = sessionStorage.getItem(SCROLL_KEY);
if (savedScroll !== null) {
sessionStorage.removeItem(SCROLL_KEY);
window.addEventListener('load', function () { window.scrollTo(0, parseInt(savedScroll, 10) || 0); });
}
} catch (e) { /* storage unavailable */ }
function reloadKeepingScroll() {
try { sessionStorage.setItem(SCROLL_KEY, String(window.scrollY)); } catch (e) { /* storage unavailable */ }
window.location.reload();
}
function showStaleBanner() {
if (document.getElementById('live-stale')) return;
var banner = document.createElement('div');
banner.id = 'live-stale';
banner.className = 'alert alert-info d-flex align-items-center gap-3 position-fixed bottom-0 start-50 translate-middle-x mb-3 shadow';
banner.setAttribute('role', 'status');
banner.style.zIndex = 1090;
var text = document.createElement('span');
text.textContent = 'This changed while you were editing.';
var button = document.createElement('button');
button.type = 'button';
button.className = 'btn btn-sm btn-primary';
button.textContent = 'Refresh';
button.addEventListener('click', function () {
banner.remove();
if (window.Nav) Nav.refresh({ force: true }); else reloadKeepingScroll();
});
banner.append(text, button);
document.body.appendChild(banner);
}
// A countdown on every page while a server restart is scheduled
var restartTimer = null;
function showRestart(restart) {
var banner = document.getElementById('restart-banner');
clearInterval(restartTimer);
if (!restart || !restart.at) {
if (banner) banner.remove();
return;
}
if (!banner) {
banner = document.createElement('div');
banner.id = 'restart-banner';
banner.className = 'alert alert-warning d-flex align-items-center gap-2 mb-3';
banner.setAttribute('role', 'status');
var main = document.querySelector('main') || document.body;
main.insertBefore(banner, main.firstChild);
}
function tick() {
var left = Math.max(0, Math.round(restart.at - Date.now() / 1000));
var text = left >= 60 ? Math.floor(left / 60) + ' min ' + (left % 60) + ' s' : left + ' s';
banner.textContent = 'Server restart in ' + text + (restart.reason ? ': ' + restart.reason : '') + (restart.by ? ' (scheduled by ' + restart.by + ')' : '');
}
tick();
restartTimer = coreSetInterval(tick, 1000);
}
window.Live = {
// Receive a socket topic this page needs (subscribed now and after every reconnect)
onTopic: function (topic, cb) {
var first = !topicWatchers[topic];
(topicWatchers[topic] = topicWatchers[topic] || []).push(cb);
if (first) subscribe(topic);
},
// Call cb({table, id}) whenever the named table changes
on: function (table, cb) {
(tableWatchers[table] = tableWatchers[table] || []).push(cb);
},
// Reload a DataTable (keeping its page) whenever the named table changes
watchTable: function (table, dataTable) {
var generation = pageGeneration;
Live.on(table, throttle(function () { if (generation === pageGeneration) dataTable.ajax.reload(null, false); }, RELOAD_THROTTLE_MS));
},
/**
* For server-rendered pages about one row: update the page in place when that row changes in any of the given
* tables (or any row, with anyRow). Nav.refresh patches only what the server shows differently, so what is being
* typed stays; where it can't (the page would be rebuilt) and the user is busy, it offers a refresh instead.
* Without nav.js the page reloads, or offers a refresh while the user is busy.
*/
refreshPage: function (tables, id, options) {
options = options || {};
var generation = pageGeneration;
var refresh = throttle(function () {
if (generation !== pageGeneration) return;
if (window.Nav) Nav.refresh();
else if (isBusy()) showStaleBanner();
else reloadKeepingScroll();
}, PAGE_REFRESH_MIN_MS);
[].concat(tables).forEach(function (table) {
Live.on(table, function (e) {
if (e.resync || options.anyRow || String(e.id) === String(id)) refresh();
});
});
},
throttle: throttle,
isBusy: isBusy,
showStaleBanner: showStaleBanner,
onStatus: function (cb) { statusWatchers.push(cb); },
// Forget what the page registered (nav.js, when it swaps in another page). Topics only it asked for are dropped.
resetPage: function () {
pageGeneration++;
tableWatchers = {};
statusWatchers = [];
Object.keys(topicWatchers).forEach(function (topic) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ event: 'unsubscribe', subscription: topic }));
});
topicWatchers = {};
var stale = document.getElementById('live-stale');
if (stale) stale.remove();
},
// The sidebar's waiting counts again (nav.js, when the menu was rebuilt)
refreshBadges: function () {
if (DASH.can('moderate_names')) api.get('/api/moderation/counts').then(updateBadges).catch(function () {});
},
/**
* Wait for background work (a scan, a search) and fetch its result, including its data, which only the account
* that started it can read. Resolves with {success, message, data}. Keeps checking with a growing interval
* in case the socket is down.
*/
waitForResult: function (requestId) {
return new Promise(function (resolve) {
var done = false, delay = 1000;
function fetchResult() {
if (done) return;
api.get('/api/actions/' + requestId).then(function (r) {
if (r.status === 'done') { done = true; delete actionWaiters[requestId]; resolve(r); } else schedule();
}).catch(schedule);
}
function schedule() {
if (done) return;
setTimeout(fetchResult, delay);
delay = Math.min(delay * 1.5, 10000);
}
actionWaiters[requestId] = fetchResult;
schedule();
});
},
/**
* Wait for the result of an asynchronous player action (kick, rescue, ...). Falls back to polling
* /api/actions/:id if the socket is down. Resolves with {success, message, affected}.
*/
waitForAction: function (requestId) {
return new Promise(function (resolve) {
var done = false;
function finish(result) { if (!done) { done = true; resolve(result); } }
actionWaiters[requestId] = finish;
var polls = 0;
(function poll() {
if (done || polls++ > 15) return;
setTimeout(function () {
if (done) return;
api.get('/api/actions/' + requestId).then(function (r) {
if (r.status === 'done') { delete actionWaiters[requestId]; finish(r); } else poll();
}).catch(poll);
}, 1000);
})();
});
}
};
// Logout clears the HttpOnly cookie server-side
document.addEventListener('click', function (e) {
if (!e.target.closest('#logoutBtn')) return;
e.preventDefault();
api.post('/api/auth/logout').finally(function () { window.location.href = '/login'; });
});
document.addEventListener('DOMContentLoaded', function () {
var initial = document.querySelector('[data-initial]');
if (initial) initial.textContent = (document.body.dataset.username || '?').charAt(0).toUpperCase();
connect();
if (DASH.can('moderate_names')) api.get('/api/moderation/counts').then(updateBadges).catch(function () {});
// Staff who manage restarts also see who scheduled it; everyone else gets when and why from the status
if (DASH.can('server_restart')) api.get('/api/server/restart').then(function (r) { showRestart(r && r.at ? r : null); }).catch(function () {});
else api.get('/api/status').then(function (s) { showRestart(s.restart); }).catch(function () {});
});
})();