changes
This commit is contained in:
363
static/js/notifications.js
Normal file
363
static/js/notifications.js
Normal file
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* 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;
|
||||
})();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user