364 lines
14 KiB
JavaScript
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;
|
|
})();
|
|
|
|
|