mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-06 12:53:44 +00:00
Nav.refresh() fetches the current page and compares the server's HTML now with the HTML the page started from; only what differs is patched into the live page (text, attributes, class changes, rows added or taken away). Typed input stays (the browser keeps what the user changed while the default value follows the server), and parts built by the page's scripts are left alone. Where a patch would lose script-built or script-bound parts, or the page's layout changed, the page is swapped in again with its scripts, keeping the scroll position, open tabs, open folding parts and typed input; while the user is typing it offers a refresh instead. Live.refreshPage (account, character, bug report and play key pages) uses it instead of reloading the page, and the "This changed while you were editing" banner's Refresh does too. Check: open a play key; in another tab change its uses or active switch: the first tab's values and badge change without a reload, and notes typed there stay. Same on a bug report page with a half typed resolution when it's resolved elsewhere. On a character page, an in-game save updates it without losing the open tab or scroll. Test: NavRulesJs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
346 lines
13 KiB
JavaScript
346 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('moderate_properties') ? counts.pendingProperties || 0 : 0),
|
|
'pet_names': counts.pendingPetNames || 0,
|
|
'bug_reports': counts.unresolvedBugReports || 0,
|
|
'reports': counts.openEconomyFlags || 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 () {});
|
|
});
|
|
})();
|