phase 5 dynamic alert registry (bot canonical, settings-site with fallback)

This commit is contained in:
2026-04-18 19:14:51 +00:00
parent e2443fd94a
commit 0f62fb9020
7 changed files with 334 additions and 6 deletions

View File

@@ -3,7 +3,7 @@
const NOTIFICATION_PRESETS = ['15m', '30m', '1h', '2h', '4h', '8h', '1d'];
const NOTIFICATION_TAB_KEYS = {
const FALLBACK_TAB_KEYS = {
surge: [
'surge_tickets',
'surge_game',
@@ -42,7 +42,7 @@
chat: ['chat_messages', 'chat_time']
};
const NOTIFICATION_ALERT_DESCRIPTIONS = {
const FALLBACK_ALERT_DESCRIPTIONS = {
surge_tickets: 'Fires when total active ticket volume exceeds configured surge thresholds, signaling broad queue pressure that needs staffing attention.',
surge_game: 'Fires when one game accumulates tickets unusually fast within the configured window, indicating a localized incident that should be triaged.',
surge_stale: 'Fires when too many tickets stay unresolved past the stale-time threshold, prompting staff to clear aging backlog.',
@@ -80,6 +80,77 @@
let notificationThresholdsState = {};
// Active sources. Start as fallback; replaced/merged when the bot-side
// registry (GET /api/notifications/alerts) returns successfully. On 404 or
// network failure the fallbacks remain authoritative.
let activeTabKeys = FALLBACK_TAB_KEYS;
let activeAlertDescriptions = FALLBACK_ALERT_DESCRIPTIONS;
async function fetchAlertRegistry() {
try {
const res = await fetch('/api/notifications/alerts', { credentials: 'same-origin' });
if (!res.ok) return null;
const data = await res.json();
if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
// Accept only if at least one known category is a non-empty array
const hasShape = ['surge', 'patterns', 'unclaimed'].some(
cat => Array.isArray(data[cat]) && data[cat].length > 0
);
return hasShape ? data : null;
} catch (_) {
return null;
}
}
// Merge bot registry with fallback, preserving fallback order for existing
// keys (so rapid_t2_t3 and chat keys stay where the UI expects them).
// Registry-only keys get appended to their category; registry descriptions
// override fallback text.
function mergeRegistryWithFallback(registry) {
const tabKeys = {};
const alertDescriptions = { ...FALLBACK_ALERT_DESCRIPTIONS };
Object.keys(FALLBACK_TAB_KEYS).forEach(cat => { tabKeys[cat] = [...FALLBACK_TAB_KEYS[cat]]; });
Object.entries(registry).forEach(([category, entries]) => {
if (!Array.isArray(entries)) return;
if (!tabKeys[category]) tabKeys[category] = [];
const seen = new Set(tabKeys[category]);
for (const e of entries) {
if (!e || typeof e.key !== 'string') continue;
if (!seen.has(e.key)) {
tabKeys[category].push(e.key);
seen.add(e.key);
}
if (typeof e.description === 'string') {
alertDescriptions[e.key] = e.description;
}
}
});
return { tabKeys, alertDescriptions };
}
function applyMergedRegistry(section, registry) {
const merged = mergeRegistryWithFallback(registry);
activeTabKeys = merged.tabKeys;
activeAlertDescriptions = merged.alertDescriptions;
window.Notifications.registry = registry;
Object.entries(activeTabKeys).forEach(([category, keys]) => {
const select = section.querySelector(`[data-notif-category="${category}"]`);
if (!select) return;
const existing = new Set(Array.from(select.options).map(o => o.value));
keys.forEach(key => {
if (!existing.has(key)) {
const option = document.createElement('option');
option.value = key;
option.textContent = toHumanLabel(key);
select.appendChild(option);
}
});
renderAlertDescription(category);
});
}
function initNotificationsEditor(config) {
const section = document.getElementById('s-notifications');
if (!section) return;
@@ -94,7 +165,7 @@
btn.addEventListener('click', () => setNotificationTab(btn.dataset.notifTab));
});
Object.entries(NOTIFICATION_TAB_KEYS).forEach(([category, keys]) => {
Object.entries(activeTabKeys).forEach(([category, keys]) => {
const select = section.querySelector(`[data-notif-category="${category}"]`);
const chipsWrap = section.querySelector(`[data-notif-chips="${category}"]`);
const input = section.querySelector(`[data-notif-input="${category}"]`);
@@ -136,6 +207,15 @@
});
setNotificationTab('surge');
// Background: pull canonical registry from the bot, merge with fallback,
// append any registry-only keys and refresh descriptions. Fallback stays
// in use if the endpoint 404s (settings-site deployed ahead of bot) or
// the fetch fails (network/proxy error).
fetchAlertRegistry().then(registry => {
if (!registry) return;
applyMergedRegistry(section, registry);
}).catch(() => {});
}
function parseNotificationThresholdsConfig(config) {
@@ -234,7 +314,7 @@
const descriptionEl = document.querySelector(`#s-notifications [data-notif-description="${category}"]`);
if (!descriptionEl) return;
const alertKey = getSelectedAlertKey(category);
descriptionEl.textContent = NOTIFICATION_ALERT_DESCRIPTIONS[alertKey] || 'No description available for this alert key yet.';
descriptionEl.textContent = activeAlertDescriptions[alertKey] || 'No description available for this alert key yet.';
}
function syncNotificationThresholdsField() {
@@ -270,8 +350,12 @@
initNotificationsEditor,
isValidThresholdValue,
toHumanLabel,
fetchAlertRegistry,
NOTIFICATION_PRESETS,
NOTIFICATION_TAB_KEYS,
NOTIFICATION_ALERT_DESCRIPTIONS
FALLBACK_TAB_KEYS,
FALLBACK_ALERT_DESCRIPTIONS,
registry: null,
get tabKeys() { return activeTabKeys; },
get alertDescriptions() { return activeAlertDescriptions; }
};
})();

View File

@@ -182,6 +182,7 @@ app.post('/api/config', apiLimiter, requireAuth, proxy('POST', '/config'));
app.get('/api/discord/guild', apiLimiter, requireAuth, proxy('GET', '/discord/guild'));
app.post('/api/restart', apiLimiter, requireAuth, proxy('POST', '/restart'));
app.get('/api/restart/status', apiLimiter, requireAuth, proxy('GET', '/restart/status'));
app.get('/api/notifications/alerts', apiLimiter, requireAuth, proxy('GET', '/notifications/alerts'));
app.get('/*splat', requireAuth, (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));