mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-04-04 09:11:34 -07:00
feat: implement Server-Sent Events (SSE) for real-time updates and notifications
This commit is contained in:
@@ -11,5 +11,6 @@ function getApiBase()
|
||||
apiBase = `${protocol}://${host}:${port}`;
|
||||
}
|
||||
|
||||
return apiBase;
|
||||
// Remove trailing slash for consistency
|
||||
return apiBase.replace(/\/$/, '');
|
||||
}
|
||||
@@ -1636,9 +1636,18 @@ function clearCache() {
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Function to check if cache needs to be refreshed because of setting changes
|
||||
// ===================================================================
|
||||
// DEPRECATED: checkSettingChanges() - Replaced by SSE-based manager
|
||||
// Settings changes are now handled via SSE events
|
||||
// Kept for backward compatibility, will be removed in future version
|
||||
// ===================================================================
|
||||
function checkSettingChanges() {
|
||||
// SSE manager handles settings_changed events now
|
||||
if (typeof netAlertXStateManager !== 'undefined' && netAlertXStateManager.initialized) {
|
||||
return; // SSE handles this now
|
||||
}
|
||||
|
||||
// Fallback for backward compatibility
|
||||
$.get('php/server/query_json.php', { file: 'app_state.json', nocache: Date.now() }, function(appState) {
|
||||
const importedMilliseconds = parseInt(appState["settingsImported"] * 1000);
|
||||
const lastReloaded = parseInt(sessionStorage.getItem(sessionStorageKey + '_time'));
|
||||
@@ -1652,7 +1661,7 @@ function checkSettingChanges() {
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// ===================================================================
|
||||
// Display spinner and reload page if not yet initialized
|
||||
async function handleFirstLoad(callback) {
|
||||
if (!isAppInitialized()) {
|
||||
@@ -1661,7 +1670,7 @@ async function handleFirstLoad(callback) {
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// ===================================================================
|
||||
// Execute callback once the app is initialized and GraphQL server is running
|
||||
async function callAfterAppInitialized(callback) {
|
||||
if (!isAppInitialized() || !(await isGraphQLServerRunning())) {
|
||||
@@ -1673,7 +1682,7 @@ async function callAfterAppInitialized(callback) {
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// ===================================================================
|
||||
// Polling function to repeatedly check if the server is running
|
||||
async function waitForGraphQLServer() {
|
||||
const pollInterval = 2000; // 2 seconds between each check
|
||||
|
||||
@@ -441,11 +441,14 @@ function safeDecodeURIComponent(content) {
|
||||
// -----------------------------------------------------------------------------
|
||||
// Backend notification Polling
|
||||
// -----------------------------------------------------------------------------
|
||||
// Function to check for notifications
|
||||
/**
|
||||
* Check for new notifications and display them
|
||||
* Now powered by SSE (Server-Sent Events) instead of polling
|
||||
* The unread count is updated in real-time by sse_manager.js
|
||||
*/
|
||||
function checkNotification() {
|
||||
const apiBase = getApiBase();
|
||||
const apiToken = getSetting("API_TOKEN");
|
||||
const notificationEndpoint = `${apiBase}/messaging/in-app/unread`;
|
||||
const notificationEndpoint = `${getApiBase()}/messaging/in-app/unread`;
|
||||
|
||||
$.ajax({
|
||||
url: notificationEndpoint,
|
||||
@@ -458,7 +461,6 @@ function checkNotification() {
|
||||
{
|
||||
// Find the oldest unread notification with level "interrupt"
|
||||
const oldestInterruptNotification = response.find(notification => notification.read === 0 && notification.level === "interrupt");
|
||||
const allUnreadNotification = response.filter(notification => notification.read === 0 && notification.level === "alert");
|
||||
|
||||
if (oldestInterruptNotification) {
|
||||
// Show modal dialog with the oldest unread notification
|
||||
@@ -471,11 +473,10 @@ function checkNotification() {
|
||||
if($("#modal-ok").is(":visible") == false)
|
||||
{
|
||||
showModalOK("Notification", decodedContent, function() {
|
||||
const apiBase = getApiBase();
|
||||
const apiToken = getSetting("API_TOKEN");
|
||||
// Mark the notification as read
|
||||
$.ajax({
|
||||
url: `${apiBase}/messaging/in-app/read/${oldestInterruptNotification.guid}`,
|
||||
const apiToken = getSetting("API_TOKEN");
|
||||
// Mark the notification as read
|
||||
$.ajax({
|
||||
url: `${getApiBase()}/messaging/in-app/read/${oldestInterruptNotification.guid}`,
|
||||
type: 'POST',
|
||||
headers: { "Authorization": `Bearer ${apiToken}` },
|
||||
success: function(response) {
|
||||
@@ -494,8 +495,6 @@ function checkNotification() {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleUnreadNotifications(allUnreadNotification.length)
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
@@ -579,8 +578,9 @@ function addOrUpdateNumberBrackets(input, count) {
|
||||
}
|
||||
|
||||
|
||||
// Start checking for notifications periodically
|
||||
setInterval(checkNotification, 3000);
|
||||
// Check for interrupt-level notifications (modal display) less frequently now that count is via SSE
|
||||
// This still polls for interrupt notifications to display them in modals
|
||||
setInterval(checkNotification, 10000); // Every 10 seconds instead of 3 seconds (SSE handles count updates)
|
||||
|
||||
// --------------------------------------------------
|
||||
// User notification handling methods
|
||||
|
||||
223
front/js/sse_manager.js
Normal file
223
front/js/sse_manager.js
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* NetAlertX SSE (Server-Sent Events) Manager
|
||||
* Replaces polling with real-time updates from backend
|
||||
* Falls back to polling if SSE unavailable
|
||||
*/
|
||||
|
||||
class NetAlertXStateManager {
|
||||
constructor() {
|
||||
this.eventSource = null;
|
||||
this.clientId = `client-${Math.random().toString(36).substr(2, 9)}`;
|
||||
this.pollInterval = null;
|
||||
this.pollBackoffInterval = 1000; // Start at 1s
|
||||
this.maxPollInterval = 30000; // Max 30s
|
||||
this.useSSE = true;
|
||||
this.sseConnectAttempts = 0;
|
||||
this.maxSSEAttempts = 3;
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the state manager
|
||||
* Tries SSE first, falls back to polling if unavailable
|
||||
*/
|
||||
init() {
|
||||
if (this.initialized) return;
|
||||
|
||||
console.log("[NetAlertX State] Initializing state manager...");
|
||||
this.trySSE();
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt SSE connection with fetch streaming
|
||||
* Uses Authorization header like all other endpoints
|
||||
*/
|
||||
async trySSE() {
|
||||
if (this.sseConnectAttempts >= this.maxSSEAttempts) {
|
||||
console.warn("[NetAlertX State] SSE failed after max attempts, switching to polling");
|
||||
this.useSSE = false;
|
||||
this.startPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiToken = getSetting("API_TOKEN");
|
||||
const apiBase = getApiBase().replace(/\/$/, '');
|
||||
const sseUrl = `${apiBase}/sse/state?client=${encodeURIComponent(this.clientId)}`;
|
||||
|
||||
const response = await fetch(sseUrl, {
|
||||
headers: { 'Authorization': `Bearer ${apiToken}` }
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
console.log("[NetAlertX State] Connected to SSE");
|
||||
this.sseConnectAttempts = 0;
|
||||
|
||||
// Stream and parse SSE events
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
this.handleSSEError();
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const events = buffer.split('\n\n');
|
||||
buffer = events[events.length - 1];
|
||||
|
||||
events.slice(0, -1).forEach(e => this.processSSEEvent(e));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[NetAlertX State] SSE error:", e);
|
||||
this.handleSSEError();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and dispatch a single SSE event
|
||||
*/
|
||||
processSSEEvent(eventText) {
|
||||
if (!eventText || !eventText.trim()) return;
|
||||
|
||||
const lines = eventText.split('\n');
|
||||
let eventType = null, eventData = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event:')) eventType = line.substring(6).trim();
|
||||
else if (line.startsWith('data:')) eventData = line.substring(5).trim();
|
||||
}
|
||||
|
||||
if (!eventType || !eventData) return;
|
||||
|
||||
try {
|
||||
switch (eventType) {
|
||||
case 'state_update':
|
||||
this.handleStateUpdate(JSON.parse(eventData));
|
||||
break;
|
||||
case 'unread_notifications_count_update':
|
||||
this.handleUnreadNotificationsCountUpdate(JSON.parse(eventData));
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[NetAlertX State] Parse error for ${eventType}:`, e, "eventData:", eventData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle SSE connection error with exponential backoff
|
||||
*/
|
||||
handleSSEError() {
|
||||
this.sseConnectAttempts++;
|
||||
if (this.eventSource) {
|
||||
this.eventSource.close();
|
||||
this.eventSource = null;
|
||||
}
|
||||
|
||||
if (this.sseConnectAttempts < this.maxSSEAttempts) {
|
||||
console.log(`[NetAlertX State] Retry ${this.sseConnectAttempts}/${this.maxSSEAttempts}...`);
|
||||
setTimeout(() => this.trySSE(), 5000);
|
||||
} else {
|
||||
this.trySSE();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle state update from SSE
|
||||
*/
|
||||
handleStateUpdate(appState) {
|
||||
try {
|
||||
if (document.getElementById("state")) {
|
||||
const cleanState = appState["currentState"].replaceAll('"', "");
|
||||
document.getElementById("state").innerHTML = cleanState;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[NetAlertX State] Failed to update state display:", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle unread notifications count update
|
||||
*/
|
||||
handleUnreadNotificationsCountUpdate(data) {
|
||||
try {
|
||||
const count = data.count || 0;
|
||||
console.log("[NetAlertX State] Unread notifications count:", count);
|
||||
handleUnreadNotifications(count);
|
||||
} catch (e) {
|
||||
console.error("[NetAlertX State] Failed to handle unread count update:", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling fallback (if SSE fails)
|
||||
*/
|
||||
startPolling() {
|
||||
console.log("[NetAlertX State] Starting polling fallback...");
|
||||
this.poll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll the server for state updates
|
||||
*/
|
||||
poll() {
|
||||
$.get(
|
||||
"php/server/query_json.php",
|
||||
{ file: "app_state.json", nocache: Date.now() },
|
||||
(appState) => {
|
||||
this.handleStateUpdate(appState);
|
||||
this.pollBackoffInterval = 1000; // Reset on success
|
||||
this.pollInterval = setTimeout(() => this.poll(), this.pollBackoffInterval);
|
||||
}
|
||||
).fail(() => {
|
||||
// Exponential backoff on failure
|
||||
this.pollBackoffInterval = Math.min(
|
||||
this.pollBackoffInterval * 1.5,
|
||||
this.maxPollInterval
|
||||
);
|
||||
this.pollInterval = setTimeout(() => this.poll(), this.pollBackoffInterval);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all updates
|
||||
*/
|
||||
stop() {
|
||||
if (this.eventSource) {
|
||||
this.eventSource.close();
|
||||
this.eventSource = null;
|
||||
}
|
||||
if (this.pollInterval) {
|
||||
clearTimeout(this.pollInterval);
|
||||
this.pollInterval = null;
|
||||
}
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stats for debugging
|
||||
*/
|
||||
async getStats() {
|
||||
try {
|
||||
const apiToken = getSetting("API_TOKEN");
|
||||
const apiBase = getApiBase();
|
||||
const response = await fetch(`${apiBase}/sse/stats`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
},
|
||||
});
|
||||
return await response.json();
|
||||
} catch (e) {
|
||||
console.error("[NetAlertX State] Failed to get stats:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance
|
||||
let netAlertXStateManager = new NetAlertXStateManager();
|
||||
Reference in New Issue
Block a user