Files
delphi-database/static/js/notifications.js
HotSwapp bac8cc4bd5 changes
2025-08-18 20:20:04 -05:00

364 lines
14 KiB
JavaScript

/**
* NotificationManager & UI helpers for real-time document events.
* - Handles WebSocket auth, reconnection with backoff, heartbeats
* - Exposes simple hooks for message handling and state updates
* - Provides small UI helpers: connection badge, status badge, event list
*/
(function(){
// ----------------------------------------------------------------------------
// Utilities
// ----------------------------------------------------------------------------
function getAuthToken() {
try {
return (window.app && window.app.token) || localStorage.getItem('auth_token') || null;
} catch (_) {
return null;
}
}
function buildWsUrl(path) {
const loc = window.location;
const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:';
const token = encodeURIComponent(getAuthToken() || '');
const sep = path.includes('?') ? '&' : '?';
return `${proto}//${loc.host}${path}${sep}token=${token}`;
}
function nowIso() {
try { return new Date().toISOString(); } catch(_) { return String(Date.now()); }
}
function clamp(min, v, max) { return Math.max(min, Math.min(max, v)); }
// ----------------------------------------------------------------------------
// NotificationManager
// ----------------------------------------------------------------------------
class NotificationManager {
/**
* @param {Object} options
* @param {() => string} options.getUrl - function returning WS path (starting with /api/...)
* @param {(msg: object) => void} [options.onMessage]
* @param {(state: string) => void} [options.onStateChange]
* @param {boolean} [options.autoConnect]
* @param {boolean} [options.debug]
*/
constructor({ getUrl, onMessage = null, onStateChange = null, autoConnect = true, debug = false } = {}) {
this._getUrl = typeof getUrl === 'function' ? getUrl : null;
this._onMessage = typeof onMessage === 'function' ? onMessage : null;
this._onStateChange = typeof onStateChange === 'function' ? onStateChange : null;
this._ws = null;
this._closed = false;
this._state = 'idle';
this._backoffMs = 1000;
this._reconnectTimer = null;
this._pingTimer = null;
this._debug = !!debug;
this._lastUrl = null;
// offline/online handling
this._offline = !navigator.onLine;
this._handleOnline = () => {
this._offline = false;
this._setState('online');
if (this._ws == null && !this._closed) {
// reconnect immediately when back online
this._scheduleReconnect(0);
}
};
this._handleOffline = () => {
this._offline = true;
this._setState('offline');
this._teardownSocket();
};
window.addEventListener('online', this._handleOnline);
window.addEventListener('offline', this._handleOffline);
if (autoConnect) {
this.connect();
}
}
_log(level, msg, extra = null) {
if (!this._debug) return;
try {
// eslint-disable-next-line no-console
console[level](`[NotificationManager] ${msg}`, extra || '');
} catch (_) {}
}
_setState(next) {
if (this._state === next) return;
this._state = next;
if (typeof this._onStateChange === 'function') {
try { this._onStateChange(next); } catch (_) {}
}
}
getState() { return this._state; }
connect() {
if (!this._getUrl) throw new Error('NotificationManager: getUrl not provided');
if (this._ws && this._ws.readyState <= 1) return; // already open/connecting
if (this._offline) { this._setState('offline'); return; }
const path = this._getUrl();
this._lastUrl = path;
const url = buildWsUrl(path);
this._log('info', 'connecting', { url });
this._setState('connecting');
try {
this._ws = new WebSocket(url);
} catch (e) {
this._log('error', 'WebSocket ctor failed', e);
this._scheduleReconnect();
return;
}
this._ws.onopen = () => {
this._log('info', 'connected');
this._setState('open');
this._backoffMs = 1000;
// heartbeat: send ping every 30s
this._pingTimer = setInterval(() => {
try { this.send({ type: 'ping', timestamp: nowIso() }); } catch(_) {}
}, 30000);
};
this._ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (!msg || typeof msg !== 'object') return;
// handle standard types
if (msg.type === 'heartbeat') return; // no-op
if (this._debug && msg.type === 'welcome') this._log('info', 'welcome', msg);
if (typeof this._onMessage === 'function') {
this._onMessage(msg);
}
} catch (_) {
// ignore parse errors
}
};
this._ws.onerror = () => {
this._log('warn', 'ws error');
this._setState('error');
};
this._ws.onclose = (ev) => {
this._log('info', 'closed', { code: ev && ev.code, reason: ev && ev.reason });
if (this._pingTimer) { clearInterval(this._pingTimer); this._pingTimer = null; }
this._setState(this._offline ? 'offline' : 'closed');
if (!this._closed) {
this._scheduleReconnect();
}
};
}
_teardownSocket() {
try { if (this._ws && this._ws.readyState <= 1) this._ws.close(); } catch(_) {}
this._ws = null;
if (this._pingTimer) { clearInterval(this._pingTimer); this._pingTimer = null; }
}
_scheduleReconnect(delayMs = null) {
if (this._offline) return; // wait for online event
if (this._closed) return;
if (this._reconnectTimer) clearTimeout(this._reconnectTimer);
const ms = delayMs == null ? this._backoffMs : delayMs;
const next = clamp(1000, this._backoffMs * 2, 30000);
this._log('info', `reconnecting in ${ms}ms`);
this._setState('reconnecting');
this._reconnectTimer = setTimeout(() => {
this._reconnectTimer = null;
this._backoffMs = next;
this.connect();
}, ms);
}
reconnectNow() {
if (this._offline) return;
if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; }
this._backoffMs = 1000;
this._teardownSocket();
this.connect();
}
send(payload) {
if (!this._ws || this._ws.readyState !== 1) return false;
try {
this._ws.send(JSON.stringify(payload));
return true;
} catch (_) { return false; }
}
close() {
this._closed = true;
if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; }
this._teardownSocket();
this._setState('closed');
window.removeEventListener('online', this._handleOnline);
window.removeEventListener('offline', this._handleOffline);
}
}
// ----------------------------------------------------------------------------
// UI helpers
// ----------------------------------------------------------------------------
function createConnectionBadge() {
const dot = document.createElement('span');
dot.className = 'inline-flex items-center gap-1 text-xs';
const circle = document.createElement('span');
circle.className = 'inline-block w-2.5 h-2.5 rounded-full bg-neutral-400';
const label = document.createElement('span');
label.textContent = 'offline';
label.className = 'text-neutral-500';
dot.appendChild(circle);
dot.appendChild(label);
function update(state) {
const map = {
open: ['bg-green-500', 'text-green-600', 'live'],
connecting: ['bg-amber-500', 'text-amber-600', 'connecting'],
reconnecting: ['bg-amber-500', 'text-amber-600', 'reconnecting'],
closed: ['bg-neutral-400', 'text-neutral-500', 'disconnected'],
error: ['bg-red-500', 'text-red-600', 'error'],
offline: ['bg-neutral-400', 'text-neutral-500', 'offline'],
online: ['bg-amber-500', 'text-amber-600', 'connecting']
};
const cfg = map[state] || map.closed;
circle.className = `inline-block w-2.5 h-2.5 rounded-full ${cfg[0]}`;
label.className = `${cfg[1]}`;
label.textContent = cfg[2];
}
return { element: dot, update };
}
function createStatusBadge(status) {
const span = document.createElement('span');
const s = String(status || '').toLowerCase();
let cls = 'bg-neutral-100 text-neutral-700 border border-neutral-300';
if (s === 'processing') cls = 'bg-amber-100 text-amber-700 border border-amber-400';
else if (s === 'completed' || s === 'success') cls = 'bg-green-100 text-green-700 border border-green-400';
else if (s === 'failed' || s === 'error') cls = 'bg-red-100 text-red-700 border border-red-400';
span.className = `inline-block px-2 py-0.5 text-xs rounded ${cls}`;
span.textContent = (s || '').toUpperCase() || 'UNKNOWN';
return span;
}
function appendEvent(listEl, { fileNo, status, message = null, timestamp = null, max = 50 }) {
if (!listEl) return;
const row = document.createElement('div');
row.className = 'flex items-center justify-between gap-3 p-2 border rounded-lg';
const left = document.createElement('div');
left.className = 'flex items-center gap-2 text-sm';
const code = document.createElement('code');
code.textContent = fileNo ? `#${fileNo}` : '';
left.appendChild(code);
if (message) {
const msg = document.createElement('span');
msg.className = 'text-neutral-600 dark:text-neutral-300';
msg.textContent = String(message);
left.appendChild(msg);
}
const right = document.createElement('div');
right.className = 'flex items-center gap-2';
right.appendChild(createStatusBadge(status));
if (timestamp) {
const time = document.createElement('span');
time.className = 'text-xs text-neutral-500';
try { time.textContent = new Date(timestamp).toLocaleTimeString(); } catch(_) { time.textContent = String(timestamp); }
right.appendChild(time);
}
row.appendChild(left);
row.appendChild(right);
listEl.prepend(row);
while (listEl.children.length > (max || 50)) {
listEl.removeChild(listEl.lastElementChild);
}
}
// ----------------------------------------------------------------------------
// High-level helpers for pages
// ----------------------------------------------------------------------------
function connectFileNotifications({ fileNo, onEvent, onState }) {
if (!fileNo) return null;
const mgr = new NotificationManager({
getUrl: () => `/api/documents/ws/status/${encodeURIComponent(fileNo)}`,
onMessage: (msg) => {
if (!msg || !msg.type) return;
// Types: document_processing, document_completed, document_failed
if (/^document_/.test(String(msg.type))) {
const status = String(msg.type).replace('document_', '');
const data = msg.data || {};
const payload = {
fileNo: data.file_no || fileNo,
status,
timestamp: msg.timestamp || nowIso(),
message: data.filename || data.file_name || data.message || null,
data
};
if (typeof onEvent === 'function') onEvent(payload);
// Default toast
try {
const friendly = status === 'processing' ? 'Processing started' : (status === 'completed' ? 'Document ready' : 'Generation failed');
const tone = status === 'completed' ? 'success' : (status === 'failed' ? 'danger' : 'info');
if (window.alerts && window.alerts.show) window.alerts.show(`${friendly} for #${payload.fileNo}`, tone, { duration: status==='processing' ? 2500 : 5000 });
} catch(_) {}
}
},
onStateChange: (s) => {
if (typeof onState === 'function') onState(s);
// Optional user feedback on connectivity
try {
if (s === 'offline' && window.alerts) window.alerts.warning('You are offline. Live updates paused.', { duration: 3000 });
if (s === 'open' && window.alerts) window.alerts.success('Live document updates connected.', { duration: 1500 });
} catch(_) {}
},
autoConnect: true,
debug: false
});
return mgr;
}
function connectAdminDocumentStream({ onEvent, onState }) {
const mgr = new NotificationManager({
getUrl: () => `/api/admin/ws/documents`,
onMessage: (msg) => {
if (!msg || !msg.type) return;
if (msg.type === 'admin_document_event') {
const data = msg.data || {};
const payload = {
fileNo: data.file_no || null,
status: (data.status || '').toLowerCase(),
timestamp: msg.timestamp || nowIso(),
message: data.message || null,
data
};
if (typeof onEvent === 'function') onEvent(payload);
}
},
onStateChange: (s) => {
if (typeof onState === 'function') onState(s);
},
autoConnect: true,
debug: false
});
return mgr;
}
// ----------------------------------------------------------------------------
// Exports
// ----------------------------------------------------------------------------
window.notifications = window.notifications || {};
window.notifications.NotificationManager = NotificationManager;
window.notifications.createConnectionBadge = createConnectionBadge;
window.notifications.createStatusBadge = createStatusBadge;
window.notifications.appendEvent = appendEvent;
window.notifications.connectFileNotifications = connectFileNotifications;
window.notifications.connectAdminDocumentStream = connectAdminDocumentStream;
})();