mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-03-30 23:03:03 -07:00
feat: implement Server-Sent Events (SSE) for real-time updates and notifications
This commit is contained in:
78
docs/API_SSE.md
Normal file
78
docs/API_SSE.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# SSE (Server-Sent Events)
|
||||
|
||||
Real-time app state updates via Server-Sent Events. Reduces server load ~95% vs polling.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `/sse/state` | GET | Stream state updates (requires Bearer token) |
|
||||
| `/sse/stats` | GET | Debug: connected clients, queued events |
|
||||
|
||||
## Usage
|
||||
|
||||
### Connect to SSE Stream
|
||||
```bash
|
||||
curl -H "Authorization: Bearer YOUR_API_TOKEN" \
|
||||
http://localhost:5000/sse/state
|
||||
```
|
||||
|
||||
### Check Connection Stats
|
||||
```bash
|
||||
curl -H "Authorization: Bearer YOUR_API_TOKEN" \
|
||||
http://localhost:5000/sse/stats
|
||||
```
|
||||
|
||||
## Event Types
|
||||
|
||||
- `state_update` - App state changed (e.g., "Scanning", "Processing")
|
||||
- `unread_notifications_count_update` - Number of unread notifications changed (count: int)
|
||||
|
||||
## Backend Integration
|
||||
|
||||
Broadcasts automatically triggered in `app_state.py` via `broadcast_state_update()`:
|
||||
|
||||
```python
|
||||
from api_server.sse_broadcast import broadcast_state_update
|
||||
|
||||
# Called on every state change - no additional code needed
|
||||
broadcast_state_update(current_state="Scanning", settings_imported=time.time())
|
||||
```
|
||||
|
||||
## Frontend Integration
|
||||
|
||||
Auto-enabled via `sse_manager.js`:
|
||||
|
||||
```javascript
|
||||
// In browser console:
|
||||
netAlertXStateManager.getStats().then(stats => {
|
||||
console.log("Connected clients:", stats.connected_clients);
|
||||
});
|
||||
```
|
||||
|
||||
## Fallback Behavior
|
||||
|
||||
- If SSE fails after 3 attempts, automatically switches to polling
|
||||
- Polling starts at 1s, backs off to 30s max
|
||||
- No user-visible difference in functionality
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `server/api_server/sse_endpoint.py` | SSE endpoints & event queue |
|
||||
| `server/api_server/sse_broadcast.py` | Broadcast helper functions |
|
||||
| `front/js/sse_manager.js` | Client-side SSE connection manager |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| Connection refused | Check backend running, API token correct |
|
||||
| No events received | Verify `broadcast_state_update()` is called on state changes |
|
||||
| High memory | Events not processed fast enough, check client logs |
|
||||
| Using polling instead of SSE | Normal fallback - check browser console for errors |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
|
||||
@@ -58,6 +58,9 @@ from .mcp_endpoint import ( # noqa: E402 [flake8 lint suppression]
|
||||
mcp_messages,
|
||||
openapi_spec
|
||||
)
|
||||
from .sse_endpoint import ( # noqa: E402 [flake8 lint suppression]
|
||||
create_sse_endpoint
|
||||
)
|
||||
# tools and mcp routes have been moved into this module (api_server_start)
|
||||
|
||||
# Flask application
|
||||
@@ -81,7 +84,8 @@ CORS(
|
||||
r"/logs/*": {"origins": "*"},
|
||||
r"/api/tools/*": {"origins": "*"},
|
||||
r"/auth/*": {"origins": "*"},
|
||||
r"/mcp/*": {"origins": "*"}
|
||||
r"/mcp/*": {"origins": "*"},
|
||||
r"/sse/*": {"origins": "*"}
|
||||
},
|
||||
supports_credentials=True,
|
||||
allow_headers=["Authorization", "Content-Type"],
|
||||
@@ -1084,8 +1088,16 @@ def check_auth():
|
||||
# Background Server Start
|
||||
# --------------------------
|
||||
def is_authorized():
|
||||
token = request.headers.get("Authorization")
|
||||
is_authorized = token == f"Bearer {get_setting_value('API_TOKEN')}"
|
||||
expected_token = get_setting_value('API_TOKEN')
|
||||
|
||||
# Check Authorization header first (primary method)
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
header_token = auth_header.split()[-1] if auth_header.startswith("Bearer ") else ""
|
||||
|
||||
# Also check query string token (for SSE and other streaming endpoints)
|
||||
query_token = request.args.get("token", "")
|
||||
|
||||
is_authorized = (header_token == expected_token) or (query_token == expected_token)
|
||||
|
||||
if not is_authorized:
|
||||
msg = "[api] Unauthorized access attempt - make sure your GRAPHQL_PORT and API_TOKEN settings are correct."
|
||||
@@ -1095,6 +1107,10 @@ def is_authorized():
|
||||
return is_authorized
|
||||
|
||||
|
||||
# Mount SSE endpoints after is_authorized is defined (avoid circular import)
|
||||
create_sse_endpoint(app, is_authorized)
|
||||
|
||||
|
||||
def start_server(graphql_port, app_state):
|
||||
"""Start the GraphQL server in a background thread."""
|
||||
|
||||
|
||||
48
server/api_server/sse_broadcast.py
Normal file
48
server/api_server/sse_broadcast.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
Integration layer to broadcast state changes via SSE
|
||||
Call these functions from the backend whenever state changes occur
|
||||
"""
|
||||
from logger import mylog
|
||||
from .sse_endpoint import broadcast_event
|
||||
|
||||
|
||||
def broadcast_state_update(current_state: str, settings_imported: float = None, **kwargs) -> None:
|
||||
"""
|
||||
Broadcast a state update to all connected SSE clients
|
||||
Call this from app_state.updateState() or equivalent
|
||||
|
||||
Args:
|
||||
current_state: The new application state string
|
||||
settings_imported: Optional timestamp of last settings import
|
||||
**kwargs: Additional state data to broadcast
|
||||
"""
|
||||
try:
|
||||
state_data = {
|
||||
"currentState": current_state,
|
||||
"timestamp": kwargs.get("timestamp"),
|
||||
**({"settingsImported": settings_imported} if settings_imported else {}),
|
||||
**{k: v for k, v in kwargs.items() if k not in ["timestamp"]},
|
||||
}
|
||||
broadcast_event("state_update", state_data)
|
||||
except ImportError:
|
||||
pass # SSE not available, silently skip
|
||||
except Exception as e:
|
||||
mylog("debug", [f"[SSE] Failed to broadcast state update: {e}"])
|
||||
|
||||
|
||||
def broadcast_unread_notifications_count(count: int) -> None:
|
||||
"""
|
||||
Broadcast unread notifications count to all connected SSE clients
|
||||
Call this from messaging.in_app functions when notifications change
|
||||
|
||||
Args:
|
||||
count: Number of unread notifications (must be int)
|
||||
"""
|
||||
try:
|
||||
# Ensure count is an integer
|
||||
count = int(count) if count else 0
|
||||
broadcast_event("unread_notifications_count_update", {"count": count})
|
||||
except ImportError:
|
||||
pass # SSE not available, silently skip
|
||||
except Exception as e:
|
||||
mylog("debug", [f"[SSE] Failed to broadcast unread count update: {e}"])
|
||||
164
server/api_server/sse_endpoint.py
Normal file
164
server/api_server/sse_endpoint.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
SSE (Server-Sent Events) Endpoint
|
||||
Provides real-time state updates to frontend via HTTP streaming
|
||||
Reduces polling overhead from 60+ requests/minute to 1 persistent connection
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from flask import Response, request
|
||||
from logger import mylog
|
||||
|
||||
# Thread-safe event queue
|
||||
_event_queue = deque(maxlen=100) # Keep last 100 events
|
||||
_queue_lock = threading.Lock()
|
||||
_subscribers = set() # Track active subscribers
|
||||
_subscribers_lock = threading.Lock()
|
||||
|
||||
|
||||
class StateChangeEvent:
|
||||
"""Represents a state change event to broadcast"""
|
||||
|
||||
def __init__(self, event_type: str, data: dict, timestamp: float = None):
|
||||
self.event_type = event_type # 'state_update', 'settings_changed', 'device_update', etc
|
||||
self.data = data
|
||||
self.timestamp = timestamp or time.time()
|
||||
self.id = int(self.timestamp * 1000) # Use millisecond timestamp as ID
|
||||
|
||||
def to_sse_format(self) -> str:
|
||||
"""Convert to SSE format with error handling"""
|
||||
try:
|
||||
return f"id: {self.id}\nevent: {self.event_type}\ndata: {json.dumps(self.data)}\n\n"
|
||||
except Exception as e:
|
||||
mylog("none", [f"[SSE] Failed to serialize event: {e}"])
|
||||
return ""
|
||||
|
||||
|
||||
def broadcast_event(event_type: str, data: dict) -> None:
|
||||
"""
|
||||
Broadcast an event to all connected SSE clients
|
||||
Called by backend when state changes occur
|
||||
"""
|
||||
try:
|
||||
event = StateChangeEvent(event_type, data)
|
||||
with _queue_lock:
|
||||
_event_queue.append(event)
|
||||
mylog("debug", [f"[SSE] Broadcasted event: {event_type}"])
|
||||
except Exception as e:
|
||||
mylog("none", [f"[SSE] Failed to broadcast event: {e}"])
|
||||
|
||||
|
||||
def register_subscriber(client_id: str) -> None:
|
||||
"""Track new SSE subscriber"""
|
||||
with _subscribers_lock:
|
||||
_subscribers.add(client_id)
|
||||
mylog("debug", [f"[SSE] Subscriber registered: {client_id} (total: {len(_subscribers)})"])
|
||||
|
||||
|
||||
def unregister_subscriber(client_id: str) -> None:
|
||||
"""Track disconnected SSE subscriber"""
|
||||
with _subscribers_lock:
|
||||
_subscribers.discard(client_id)
|
||||
mylog(
|
||||
"debug",
|
||||
[f"[SSE] Subscriber unregistered: {client_id} (remaining: {len(_subscribers)})"],
|
||||
)
|
||||
|
||||
|
||||
def get_subscriber_count() -> int:
|
||||
"""Get number of active SSE connections"""
|
||||
with _subscribers_lock:
|
||||
return len(_subscribers)
|
||||
|
||||
|
||||
def sse_stream(client_id: str):
|
||||
"""
|
||||
Generator for SSE stream
|
||||
Yields events to client with reconnect guidance
|
||||
"""
|
||||
register_subscriber(client_id)
|
||||
|
||||
# Send initial connection message
|
||||
yield "id: 0\nevent: connected\ndata: {}\nretry: 3000\n\n"
|
||||
|
||||
# Send initial unread notifications count on connect
|
||||
try:
|
||||
from messaging.in_app import get_unread_notifications
|
||||
initial_notifications = get_unread_notifications().json
|
||||
unread_count = len(initial_notifications) if isinstance(initial_notifications, list) else 0
|
||||
broadcast_event("unread_notifications_count_update", {"count": unread_count})
|
||||
except Exception as e:
|
||||
mylog("debug", [f"[SSE] Failed to broadcast initial unread count: {e}"])
|
||||
|
||||
last_event_id = 0
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Check for new events since last_event_id
|
||||
with _queue_lock:
|
||||
new_events = [
|
||||
e for e in _event_queue if e.id > last_event_id
|
||||
]
|
||||
|
||||
if new_events:
|
||||
for event in new_events:
|
||||
sse_data = event.to_sse_format()
|
||||
if sse_data:
|
||||
yield sse_data
|
||||
last_event_id = event.id
|
||||
else:
|
||||
# Send keepalive every 30 seconds to prevent connection timeout
|
||||
time.sleep(1)
|
||||
if int(time.time()) % 30 == 0:
|
||||
yield ": keepalive\n\n"
|
||||
|
||||
except GeneratorExit:
|
||||
unregister_subscriber(client_id)
|
||||
except Exception as e:
|
||||
mylog("none", [f"[SSE] Stream error for {client_id}: {e}"])
|
||||
unregister_subscriber(client_id)
|
||||
|
||||
|
||||
def create_sse_endpoint(app, is_authorized=None) -> None:
|
||||
"""Mount SSE endpoints to Flask app - /sse/state and /sse/stats
|
||||
|
||||
Args:
|
||||
app: Flask app instance
|
||||
is_authorized: Optional function to check authorization (if None, allows all)
|
||||
"""
|
||||
|
||||
@app.route("/sse/state", methods=["GET"])
|
||||
def api_sse_state():
|
||||
"""SSE endpoint for real-time state updates"""
|
||||
if is_authorized and not is_authorized():
|
||||
return {"none": "Unauthorized"}, 401
|
||||
|
||||
client_id = request.args.get("client", f"client-{int(time.time() * 1000)}")
|
||||
mylog("debug", [f"[SSE] Client connected: {client_id}"])
|
||||
|
||||
return Response(
|
||||
sse_stream(client_id),
|
||||
mimetype="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
|
||||
@app.route("/sse/stats", methods=["GET"])
|
||||
def api_sse_stats():
|
||||
"""Get SSE endpoint statistics for debugging"""
|
||||
if is_authorized and not is_authorized():
|
||||
return {"none": "Unauthorized"}, 401
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"connected_clients": get_subscriber_count(),
|
||||
"queued_events": len(_event_queue),
|
||||
"max_queue_size": _event_queue.maxlen,
|
||||
}
|
||||
|
||||
mylog("info", ["[SSE] Endpoints mounted: /sse/state, /sse/stats"])
|
||||
@@ -5,6 +5,7 @@ from const import applicationPath, apiPath
|
||||
from logger import mylog
|
||||
from helper import checkNewVersion
|
||||
from utils.datetime_utils import timeNowDB, timeNow
|
||||
from api_server.sse_broadcast import broadcast_state_update
|
||||
|
||||
# Register NetAlertX directories using runtime configuration
|
||||
INSTALL_PATH = applicationPath
|
||||
@@ -151,6 +152,12 @@ class app_state_class:
|
||||
except (TypeError, ValueError) as e:
|
||||
mylog("none", [f"[app_state_class] Failed to serialize object to JSON: {e}"],)
|
||||
|
||||
# Broadcast state change via SSE if available
|
||||
try:
|
||||
broadcast_state_update(self.currentState, self.settingsImported, timestamp=self.lastUpdated)
|
||||
except Exception as e:
|
||||
mylog("none", [f"[app_state] SSE broadcast: {e}"])
|
||||
|
||||
return
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ sys.path.extend([f"{INSTALL_PATH}/server"])
|
||||
from const import apiPath # noqa: E402 [flake8 lint suppression]
|
||||
from logger import mylog # noqa: E402 [flake8 lint suppression]
|
||||
from utils.datetime_utils import timeNowDB # noqa: E402 [flake8 lint suppression]
|
||||
from api_server.sse_broadcast import broadcast_unread_notifications_count # noqa: E402 [flake8 lint suppression]
|
||||
|
||||
|
||||
NOTIFICATION_API_FILE = apiPath + 'user_notifications.json'
|
||||
@@ -72,6 +73,13 @@ def write_notification(content, level="alert", timestamp=None):
|
||||
with open(NOTIFICATION_API_FILE, "w") as file:
|
||||
json.dump(notifications, file, indent=4)
|
||||
|
||||
# Broadcast unread count update
|
||||
try:
|
||||
unread_count = sum(1 for n in notifications if n.get("read", 0) == 0)
|
||||
broadcast_unread_notifications_count(unread_count)
|
||||
except Exception as e:
|
||||
mylog("none", [f"[Notification] Failed to broadcast unread count: {e}"])
|
||||
|
||||
|
||||
# Trim notifications
|
||||
def remove_old(keepNumberOfEntries):
|
||||
@@ -156,6 +164,13 @@ def mark_all_notifications_read():
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
mylog("debug", "[Notification] All notifications marked as read.")
|
||||
|
||||
# Broadcast unread count update
|
||||
try:
|
||||
broadcast_unread_notifications_count(0)
|
||||
except Exception as e:
|
||||
mylog("none", [f"[Notification] Failed to broadcast unread count: {e}"])
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@@ -169,6 +184,13 @@ def delete_notifications():
|
||||
with open(NOTIFICATION_API_FILE, "w") as f:
|
||||
json.dump([], f, indent=4)
|
||||
mylog("debug", "[Notification] All notifications deleted.")
|
||||
|
||||
# Broadcast unread count update
|
||||
try:
|
||||
broadcast_unread_notifications_count(0)
|
||||
except Exception as e:
|
||||
mylog("none", [f"[Notification] Failed to broadcast unread count: {e}"])
|
||||
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@@ -219,6 +241,13 @@ def mark_notification_as_read(guid=None, max_attempts=3):
|
||||
with open(NOTIFICATION_API_FILE, "w") as f:
|
||||
json.dump(notifications, f, indent=4)
|
||||
|
||||
# Broadcast unread count update
|
||||
try:
|
||||
unread_count = sum(1 for n in notifications if n.get("read", 0) == 0)
|
||||
broadcast_unread_notifications_count(unread_count)
|
||||
except Exception as e:
|
||||
mylog("none", [f"[Notification] Failed to broadcast unread count: {e}"])
|
||||
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
mylog("none", f"[Notification] Attempt {attempts + 1} failed: {e}")
|
||||
@@ -258,6 +287,13 @@ def delete_notification(guid):
|
||||
with open(NOTIFICATION_API_FILE, "w") as f:
|
||||
json.dump(filtered_notifications, f, indent=4)
|
||||
|
||||
# Broadcast unread count update
|
||||
try:
|
||||
unread_count = sum(1 for n in filtered_notifications if n.get("read", 0) == 0)
|
||||
broadcast_unread_notifications_count(unread_count)
|
||||
except Exception as e:
|
||||
mylog("none", [f"[Notification] Failed to broadcast unread count: {e}"])
|
||||
|
||||
return {"success": True}
|
||||
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user