feat: implement Server-Sent Events (SSE) for real-time updates and notifications

This commit is contained in:
Jokob @NetAlertX
2026-01-11 06:15:27 +00:00
parent 6deb83a53d
commit 5a0332bba5
11 changed files with 621 additions and 32 deletions

View File

@@ -11,5 +11,6 @@ function getApiBase()
apiBase = `${protocol}://${host}:${port}`;
}
return apiBase;
// Remove trailing slash for consistency
return apiBase.replace(/\/$/, '');
}

View File

@@ -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

View File

@@ -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
View 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();

View File

@@ -44,6 +44,7 @@
<script src="lib/datatables.net/js/dataTables.select.min.js"></script>
<script src="js/common.js?v=<?php include 'php/templates/version.php'; ?>"></script>
<script src="js/sse_manager.js?v=<?php include 'php/templates/version.php'; ?>"></script>
<script src="js/api.js?v=<?php include 'php/templates/version.php'; ?>"></script>
<script src="js/modal.js?v=<?php include 'php/templates/version.php'; ?>"></script>
<script src="js/tests.js?v=<?php include 'php/templates/version.php'; ?>"></script>
@@ -100,19 +101,23 @@
<!-- Servertime to the right of the hostname -->
<script>
// -------------------------------------------------------------
// Updates the backend application state/status in the header
function updateState(){
// ===================================================================
// DEPRECATED: updateState() - Replaced by SSE-based state manager
// Kept for backward compatibility, will be removed in future version
// ===================================================================
function updateState() {
// Delegate to SSE manager if available
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) {
document.getElementById('state').innerHTML = appState["currentState"].replaceAll('"', '');
setTimeout("updateState()", 1000);
})
}
// -------------------------------------------------------------
// ===================================================================
// updates the date and time in the header
function update_servertime() {
// Get the current date and time in the specified time zone
@@ -481,7 +486,9 @@
// Update server time in the header
update_servertime()
// Update server state in the header
updateState()
// Initialize SSE-based state manager (replaces updateState polling)
if (typeof netAlertXStateManager !== 'undefined') {
netAlertXStateManager.init();
}
</script>