From 1b6d4b28059dcdb16efcafb0cca4b447d2c5e659 Mon Sep 17 00:00:00 2001 From: NonCertus <252629670+NonCertus@users.noreply.github.com> Date: Sat, 25 Apr 2026 23:22:21 +0200 Subject: [PATCH] feat: add device tracking (reboots/SW updates/IPs), improve event log counting/test stability --- app/blueprints/events_bp.py | 22 +- app/collectors/demo.py | 33 ++- app/collectors/modem.py | 119 ++++++++ app/i18n/__init__.py | 2 +- app/i18n/de.json | 7 + app/i18n/en.json | 7 + app/i18n/es.json | 7 + app/i18n/fr.json | 7 + app/i18n/template.json | 8 +- app/notifier.py | 52 ++-- app/static/js/events.js | 162 +++++++---- app/storage/__init__.py | 2 + app/storage/base.py | 10 + app/storage/device.py | 28 ++ app/storage/events.py | 38 ++- app/templates/index.html | 41 +-- app/templates/settings.html | 2 +- app/templates/settings/notifications.html | 18 ++ .../collectors/test_discovery_orchestrator.py | 3 + tests/collectors/test_modem_collector.py | 269 ++++++++++++++++++ tests/e2e/conftest.py | 17 +- tests/test_events.py | 31 +- tests/test_notifier.py | 15 +- 23 files changed, 775 insertions(+), 125 deletions(-) create mode 100644 app/storage/device.py diff --git a/app/blueprints/events_bp.py b/app/blueprints/events_bp.py index c364371b..ce9b654f 100644 --- a/app/blueprints/events_bp.py +++ b/app/blueprints/events_bp.py @@ -28,11 +28,20 @@ def api_events_list(): event_type = request.args.get("event_type") or None ack_param = request.args.get("acknowledged") acknowledged = int(ack_param) if ack_param is not None and ack_param != "" else None + event_prefix = request.args.get("event_prefix") or None + exclude_operational = request.args.get("exclude_operational", "false").lower() == "true" + events = _storage.get_events( limit=limit, offset=offset, severity=severity, event_type=event_type, acknowledged=acknowledged, + exclude_operational=exclude_operational, event_prefix=event_prefix + ) + unack = _storage.get_event_count( + acknowledged=0, + exclude_operational=exclude_operational, + event_prefix=event_prefix, + severity=severity ) - unack = _storage.get_event_count(acknowledged=0) _localize_timestamps(events) return jsonify({"events": events, "unacknowledged_count": unack}) @@ -44,7 +53,16 @@ def api_events_count(): _storage = get_storage() if not _storage: return jsonify({"count": 0}) - return jsonify({"count": _storage.get_event_count(acknowledged=0)}) + event_prefix = request.args.get("event_prefix") or None + severity = request.args.get("severity") or None + exclude_operational = request.args.get("exclude_operational", "false").lower() == "true" + count = _storage.get_event_count( + acknowledged=0, + exclude_operational=exclude_operational, + event_prefix=event_prefix, + severity=severity + ) + return jsonify({"count": count}) @events_bp.route("/api/events//acknowledge", methods=["POST"]) diff --git a/app/collectors/demo.py b/app/collectors/demo.py index dad3988f..bcafa305 100644 --- a/app/collectors/demo.py +++ b/app/collectors/demo.py @@ -498,7 +498,7 @@ def _seed_events(self, now): "details": {"prev": 37.0, "current": snr_val, "threshold": "warning"}, }) - # Channel change events scattered across 9 months + # Channel change events for d in [240, 180, 120, 60, 25, 3]: t = now - timedelta(days=d, hours=random.randint(0, 23)) events.extend([ @@ -518,6 +518,37 @@ def _seed_events(self, now): }, ]) + # Device Tracking Events (Reboots, SW updates, IP changes) + # 1. A firmware update 6 months ago + t_sw = now - timedelta(days=180, hours=3) + events.append({ + "timestamp": t_sw.strftime("%Y-%m-%dT%H:%M:%SZ"), + "severity": "info", + "event_type": "device_sw_update", + "message": "Reboot: (reason: firmware upgrade) Prior uptime: 112d 14h 7m, SW: v1.8.4 → v2.0.1, WAN IPv4/v6: 93.212.4.11 / 2001:db8::1 → 93.212.5.82 / 2001:db8::2", + "details": {"old_sw": "v1.8.4", "new_sw": "v2.0.1", "reboot_reason": "firmware upgrade", "prior_uptime": 9727620, "ip_changed": True} + }) + + # 2. A simple reboot 3 months ago + t_rb = now - timedelta(days=90, hours=14) + events.append({ + "timestamp": t_rb.strftime("%Y-%m-%dT%H:%M:%SZ"), + "severity": "warning", + "event_type": "device_reboot", + "message": "Reboot: (reason: power cycle) Prior uptime: 89d 22h 0m", + "details": {"reboot_reason": "power cycle", "prior_uptime": 7768800, "ip_changed": False} + }) + + # 3. An IP change (no reboot) 1 month ago + t_ip = now - timedelta(days=30, hours=1) + events.append({ + "timestamp": t_ip.strftime("%Y-%m-%dT%H:%M:%SZ"), + "severity": "info", + "event_type": "device_ip_change", + "message": "WAN IPv4: 93.212.5.82 → 93.212.8.194", + "details": {"old_ipv4": "93.212.5.82", "new_ipv4": "93.212.8.194"} + }) + self._storage.save_events(events, is_demo=True) log.info("Demo: seeded %d events", len(events)) diff --git a/app/collectors/modem.py b/app/collectors/modem.py index 51492776..dba2606e 100644 --- a/app/collectors/modem.py +++ b/app/collectors/modem.py @@ -4,6 +4,7 @@ import logging import time +from datetime import datetime, timezone from .base import Collector, CollectorResult from ..analyzer import apply_spike_suppression @@ -12,6 +13,19 @@ log = logging.getLogger("docsis.collector.modem") +def format_uptime(seconds: int) -> str: + """Format uptime seconds into a human-readable string: Xd Yh Zm.""" + if seconds is None: + return "unknown" + + days = seconds // (24 * 3600) + seconds %= (24 * 3600) + hours = seconds // 3600 + seconds %= 3600 + minutes = seconds // 60 + + return f"{days}d {hours}h {minutes}m" + class ModemCollector(Collector): """Collects DOCSIS data from a modem driver, runs analysis, detects events, @@ -47,6 +61,111 @@ def collect(self) -> CollectorResult: ) self._web.update_state(device_info=self._device_info) + # Device state tracking (reboots, sw updates, IP changes) + if self._device_info: + uptime = self._device_info.get("uptime_seconds") + sw_version = self._device_info.get("sw_version") + ipv4 = self._device_info.get("wan_ipv4") + ipv6 = self._device_info.get("wan_ipv6") + reboot_reason = self._device_info.get("reboot_reason") + + old_state = self._storage.get_device_state() + now = datetime.now(timezone.utc) + now_iso = now.strftime("%Y-%m-%dT%H:%M:%SZ") + + events_to_log = [] + + # Detection: Compare current poll with last known state from DB + sw_changed = False + uptime_decreased = False + ip_changed = False + + old_sw = old_state.get("sw_version") if old_state else None + if sw_version and old_sw and sw_version != old_sw: + sw_changed = True + + old_uptime = old_state.get("uptime_seconds") if old_state else None + if uptime is not None and old_uptime is not None and uptime < old_uptime: + uptime_decreased = True + + # IP change detection for both IPv4 and IPv6 + old_ipv4 = old_state.get("wan_ipv4") if old_state else None + old_ipv6 = old_state.get("wan_ipv6") if old_state else None + + ipv4_changed = ipv4 and old_ipv4 and ipv4 != old_ipv4 + ipv6_changed = ipv6 and old_ipv6 and ipv6 != old_ipv6 + if ipv4_changed or ipv6_changed: + ip_changed = True + + # Format IP change snippet for inclusion in reboot/update messages + ip_msg = "" + if ip_changed: + if ipv4_changed and ipv6_changed: + ip_msg = f"WAN IPv4/v6: {old_ipv4} / {old_ipv6} → {ipv4} / {ipv6}" + elif ipv4_changed: + ip_msg = f"WAN IPv4: {old_ipv4} → {ipv4}" + else: + ip_msg = f"WAN IPv6: {old_ipv6} → {ipv6}" + + # Construct parts for inclusion in messages + uptime_fmt = format_uptime(old_uptime) + msg_parts = [] + if uptime_fmt != "unknown": + msg_parts.append(f"Prior uptime: {uptime_fmt}") + + # Priority 1: Software Update (usually implies a reboot) + if sw_changed: + msg_parts.append(f"SW: {old_sw} → {sw_version}") + if ip_changed: + msg_parts.append(ip_msg) + if reboot_reason: + msg_parts.append(f"Reason: {reboot_reason}") + + events_to_log.append({ + "timestamp": now_iso, + "severity": "info", + "event_type": "device_sw_update", + "message": ", ".join(msg_parts), + "details": {"old_sw": old_sw, "new_sw": sw_version, "reboot_reason": reboot_reason, "prior_uptime": old_uptime, "ip_changed": ip_changed} + }) + # Priority 2: Standard Reboot (uptime drop without SW change) + elif uptime_decreased: + if ip_changed: + msg_parts.append(ip_msg) + if reboot_reason: + msg_parts.append(f"Reason: {reboot_reason}") + + events_to_log.append({ + "timestamp": now_iso, + "severity": "warning", + "event_type": "device_reboot", + "message": ", ".join(msg_parts), + "details": {"reboot_reason": reboot_reason, "prior_uptime": old_uptime, "ip_changed": ip_changed} + }) + # Priority 3: Standalone IP change (no reboot detected) + elif ip_changed: + events_to_log.append({ + "timestamp": now_iso, + "severity": "info", + "event_type": "device_ip_change", + "message": ip_msg, + "details": {"old_ipv4": old_ipv4, "new_ipv4": ipv4, "old_ipv6": old_ipv6, "new_ipv6": ipv6} + }) + + if events_to_log: + self._storage.save_events(events_to_log) + if self._notifier: + self._notifier.dispatch(events_to_log) + + # Update the state database to hold the current 'last known' values. + self._storage.update_device_state( + uptime if uptime is not None else old_uptime, + sw_version if sw_version is not None else old_sw, + ipv4 if ipv4 is not None else old_ipv4, + ipv6 if ipv6 is not None else old_ipv6, + now_iso + ) + if self._connection_info is None: self._connection_info = self._driver.get_connection_info() if self._connection_info: diff --git a/app/i18n/__init__.py b/app/i18n/__init__.py index f58496a7..6d2a5916 100644 --- a/app/i18n/__init__.py +++ b/app/i18n/__init__.py @@ -12,7 +12,7 @@ if not _fname.endswith(".json"): continue _code = _fname[:-5] # "en.json" -> "en" - with open(os.path.join(_DIR, _fname), "r", encoding="utf-8") as _f: + with open(os.path.join(_DIR, _fname), "r", encoding="utf-8-sig") as _f: _data = json.load(_f) _meta = _data.pop("_meta", {}) _TRANSLATIONS[_code] = _data diff --git a/app/i18n/de.json b/app/i18n/de.json index 608c0373..91b865c2 100644 --- a/app/i18n/de.json +++ b/app/i18n/de.json @@ -309,11 +309,15 @@ "event_severity_warning": "Warnung", "event_severity_critical": "Kritisch", "event_hide_operational": "Betriebsmeldungen ausblenden", + "event_filter_device": "Gerät", "event_type_health_change": "Gesundheitsänderung", "event_type_power_change": "Pegeländerung", "event_type_snr_change": "SNR-Änderung", "event_type_channel_change": "Kanaländerung", "event_type_modulation_change": "Modulationsänderung", + "event_type_device_sw_update": "Softwareaktualisierung", + "event_type_device_reboot": "Neustart", + "event_type_device_ip_change": "IP-Adressänderung", "event_type_modem_restart_detected": "Modem-Neustart erkannt", "event_type_error_spike": "Fehleranstieg", "event_acknowledged": "Bestätigt", @@ -736,6 +740,9 @@ "notify_event_snr_change": "SNR-Änderung", "notify_event_channel_change": "Kanaländerung", "notify_event_modulation_change": "Modulationsänderung", + "notify_event_device_sw_update": "Softwareaktualisierung", + "notify_event_device_reboot": "Neustart", + "notify_event_device_ip_change": "IP-Adressänderung", "notify_event_error_spike": "Fehleranstieg", "notify_event_monitoring_started": "Überwachung gestartet", "severity_critical": "kritisch", diff --git a/app/i18n/en.json b/app/i18n/en.json index e7e0e131..dcb934ca 100644 --- a/app/i18n/en.json +++ b/app/i18n/en.json @@ -309,11 +309,15 @@ "event_severity_warning": "Warning", "event_severity_critical": "Critical", "event_hide_operational": "Hide Operational", + "event_filter_device": "Device", "event_type_health_change": "Health Change", "event_type_power_change": "Power Change", "event_type_snr_change": "SNR Change", "event_type_channel_change": "Channel Change", "event_type_modulation_change": "Modulation Change", + "event_type_device_sw_update": "Software Update", + "event_type_device_reboot": "Reboot", + "event_type_device_ip_change": "IP Change", "event_type_modem_restart_detected": "Modem restart detected", "event_type_error_spike": "Error Spike", "event_acknowledged": "Acknowledged", @@ -736,6 +740,9 @@ "notify_event_snr_change": "SNR Change", "notify_event_channel_change": "Channel Change", "notify_event_modulation_change": "Modulation Change", + "notify_event_device_sw_update": "Software Update", + "notify_event_device_reboot": "Reboot", + "notify_event_device_ip_change": "IP Change", "notify_event_error_spike": "Error Spike", "notify_event_monitoring_started": "Monitoring Started", "severity_critical": "critical", diff --git a/app/i18n/es.json b/app/i18n/es.json index 476d8881..3311325e 100644 --- a/app/i18n/es.json +++ b/app/i18n/es.json @@ -304,6 +304,7 @@ "event_severity_warning": "Advertencia", "event_severity_critical": "Critico", "event_hide_operational": "Ocultar mensajes operativos", + "event_filter_device": "Dispositivo", "event_type_health_change": "Cambio de salud", "event_type_power_change": "Cambio de potencia", "event_type_snr_change": "Cambio de SNR", @@ -311,6 +312,9 @@ "event_type_modulation_change": "Cambio de modulacion", "event_type_modem_restart_detected": "Reinicio del módem detectado", "event_type_error_spike": "Pico de errores", + "event_type_device_sw_update": "Actualización de software", + "event_type_device_reboot": "Reinicio", + "event_type_device_ip_change": "Cambio de IP", "event_acknowledged": "Confirmado", "event_all_severities": "Todas las severidades", "event_all_types": "Todos los tipos", @@ -731,6 +735,9 @@ "notify_event_snr_change": "Cambio de SNR", "notify_event_channel_change": "Cambio de canal", "notify_event_modulation_change": "Cambio de modulación", + "notify_event_device_sw_update": "Actualización de software", + "notify_event_device_reboot": "Reinicio", + "notify_event_device_ip_change": "Cambio de IP", "notify_event_error_spike": "Pico de errores", "notify_event_monitoring_started": "Monitoreo iniciado", "severity_critical": "crítico", diff --git a/app/i18n/fr.json b/app/i18n/fr.json index 9676266a..d0c69526 100644 --- a/app/i18n/fr.json +++ b/app/i18n/fr.json @@ -301,6 +301,7 @@ "event_severity_warning": "Avertissement", "event_severity_critical": "Critique", "event_hide_operational": "Masquer les messages operationnels", + "event_filter_device": "Appareil", "event_type_health_change": "Changement de sante", "event_type_power_change": "Changement de puissance", "event_type_snr_change": "Changement de SNR", @@ -308,6 +309,9 @@ "event_type_modulation_change": "Changement de modulation", "event_type_modem_restart_detected": "Redémarrage du modem détecté", "event_type_error_spike": "Pic d'erreurs", + "event_type_device_sw_update": "Mise à jour logicielle", + "event_type_device_reboot": "Redémarrage", + "event_type_device_ip_change": "Changement d'IP", "event_acknowledged": "Confirme", "event_all_severities": "Toutes les gravites", "event_all_types": "Tous les types", @@ -728,6 +732,9 @@ "notify_event_snr_change": "Changement de SNR", "notify_event_channel_change": "Changement de canal", "notify_event_modulation_change": "Changement de modulation", + "notify_event_device_sw_update": "Mise à jour logicielle", + "notify_event_device_reboot": "Redémarrage", + "notify_event_device_ip_change": "Changement d'IP", "notify_event_error_spike": "Pic d'erreurs", "notify_event_monitoring_started": "Surveillance démarrée", "severity_critical": "critique", diff --git a/app/i18n/template.json b/app/i18n/template.json index 62e1c3bc..c22843ad 100644 --- a/app/i18n/template.json +++ b/app/i18n/template.json @@ -1,4 +1,4 @@ -{ +{ "_meta": { "language_name": "", "flag": "" @@ -296,6 +296,9 @@ "event_type_channel_change": "", "event_type_modulation_change": "", "event_type_error_spike": "", + "event_type_device_sw_update": "", + "event_type_device_reboot": "", + "event_type_device_ip_change": "", "event_acknowledged": "", "event_all_severities": "", "event_all_types": "", @@ -702,6 +705,9 @@ "notify_event_snr_change": "", "notify_event_channel_change": "", "notify_event_modulation_change": "", + "notify_event_device_sw_update": "", + "notify_event_device_reboot": "", + "notify_event_device_ip_change": "", "notify_event_error_spike": "", "notify_event_monitoring_started": "", "severity_critical": "", diff --git a/app/notifier.py b/app/notifier.py index 648250ee..12b53d98 100644 --- a/app/notifier.py +++ b/app/notifier.py @@ -148,44 +148,39 @@ class NotificationDispatcher: def __init__(self, config_mgr): self._config_mgr = config_mgr - self._channels = [] self._cooldown_tracker = {} # event_type -> last_sent_timestamp + + def _get_cooldown_overrides(self) -> dict[str, int]: try: - self._default_cooldown = int(config_mgr.get("notify_cooldown", 3600)) - except (ValueError, TypeError): - self._default_cooldown = 3600 - try: - self._cooldown_overrides = json.loads( - config_mgr.get("notify_cooldowns", "{}") - ) + return json.loads(self._config_mgr.get("notify_cooldowns", "{}")) except (json.JSONDecodeError, TypeError): - self._cooldown_overrides = {} - self._min_severity = config_mgr.get("notify_min_severity", "warning") - self._setup_channels() + return {} - def _setup_channels(self): + def _get_channels(self) -> list[NotificationChannel]: + channels = [] url = self._config_mgr.get("notify_webhook_url") if url: if is_discord_webhook_url(url): - self._channels.append(DiscordWebhookChannel(url)) - log.info("Notification channel: Discord webhook configured") + channels.append(DiscordWebhookChannel(url)) else: headers = {} token = self._config_mgr.get("notify_webhook_token") if token: headers["Authorization"] = f"Bearer {token}" - self._channels.append(WebhookChannel(url, headers)) - log.info("Notification channel: webhook configured") + channels.append(WebhookChannel(url, headers)) + return channels def dispatch(self, events: list[EventDict]) -> None: """Send qualifying events to all configured channels.""" - if not self._channels: + channels = self._get_channels() + if not channels: return + for event in events: if not self._should_send(event): continue payload = self._build_payload(event) - for channel in self._channels: + for channel in channels: try: channel.send(payload) except Exception as e: @@ -196,7 +191,8 @@ def dispatch(self, events: list[EventDict]) -> None: def _should_send(self, event) -> bool: # Severity filter - min_level = SEVERITY_ORDER.get(self._min_severity, 1) + min_severity = self._config_mgr.get("notify_min_severity", "warning") + min_level = SEVERITY_ORDER.get(min_severity, 1) event_level = SEVERITY_ORDER.get(event.get("severity", "info"), 0) if event_level < min_level: return False @@ -204,12 +200,21 @@ def _should_send(self, event) -> bool: # Cooldown per event_type now = time.time() key = event.get("event_type", "unknown") - cooldown = self._cooldown_overrides.get(key, self._default_cooldown) + + try: + default_cooldown = int(self._config_mgr.get("notify_cooldown", 3600)) + except (ValueError, TypeError): + default_cooldown = 3600 + + overrides = self._get_cooldown_overrides() + cooldown = overrides.get(key, default_cooldown) + if isinstance(cooldown, str): try: cooldown = int(cooldown) except ValueError: - cooldown = self._default_cooldown + cooldown = default_cooldown + if cooldown == 0: # 0 = disabled, never send this type return False if key in self._cooldown_tracker: @@ -231,7 +236,8 @@ def _build_payload(event: EventDict) -> NotificationPayload: def test(self) -> NotificationTestResult: """Send a test notification to all channels. Returns {success, error}.""" - if not self._channels: + channels = self._get_channels() + if not channels: return {"success": False, "error": "No notification channels configured"} payload = { "source": "docsight", @@ -242,7 +248,7 @@ def test(self) -> NotificationTestResult: "details": {"test": True}, } errors = [] - for channel in self._channels: + for channel in channels: try: if not channel.send(payload): errors.append(f"{type(channel).__name__}: send returned false") diff --git a/app/static/js/events.js b/app/static/js/events.js index f33fb393..9d0735f9 100644 --- a/app/static/js/events.js +++ b/app/static/js/events.js @@ -3,12 +3,17 @@ /* ── State ── */ var _eventsOffset = 0; var _eventsPageSize = 50; +var _eventsRequestCount = 0; +var _badgeRequestCount = 0; var _eventTypeLabels = { health_change: T.event_type_health_change || 'Health Change', power_change: T.event_type_power_change || 'Power Change', snr_change: T.event_type_snr_change || 'SNR Change', channel_change: T.event_type_channel_change || 'Channel Change', modulation_change: T.event_type_modulation_change || 'Modulation Change', + device_sw_update: T.event_type_device_sw_update || 'Software Update', + device_reboot: T.event_type_device_reboot || 'Device Reboot', + device_ip_change: T.event_type_device_ip_change || 'IP Change', error_spike: T.event_type_error_spike || 'Error Spike', smart_capture_triggered: T.event_type_smart_capture_triggered || 'Smart Capture' }; @@ -20,6 +25,7 @@ var _sevLabels = { /* Phase 4.3: Pill filter toggle function */ var _currentSeverityFilter = ''; +var _deviceOnlyFilter = false; var _hideOperational = true; var _OPERATIONAL_EVENT_TYPES = { monitoring_started: true, monitoring_stopped: true }; @@ -133,10 +139,12 @@ function toggleHideOperational() { btn.setAttribute('aria-pressed', String(_hideOperational)); } loadEvents(); + refreshEventBadge(); } function filterEventsBySeverity(severity) { _currentSeverityFilter = severity; + _deviceOnlyFilter = false; var pills = document.querySelectorAll('.severity-pill:not(#hide-operational-btn)'); pills.forEach(function(pill) { var isActive = pill.getAttribute('data-severity') === severity; @@ -146,13 +154,38 @@ function filterEventsBySeverity(severity) { } }); loadEvents(); + refreshEventBadge(); +} + +function filterEventsByDevice() { + _deviceOnlyFilter = !_deviceOnlyFilter; + _currentSeverityFilter = ''; + + var pills = document.querySelectorAll('.severity-pill:not(#hide-operational-btn)'); + pills.forEach(function(pill) { + if (pill.id === 'device-filter-pill') { + pill.classList.toggle('active', _deviceOnlyFilter); + pill.setAttribute('aria-pressed', String(_deviceOnlyFilter)); + } else { + pill.classList.remove('active'); + if (pill.hasAttribute('aria-pressed')) { + pill.setAttribute('aria-pressed', 'false'); + } + } + }); + loadEvents(); + refreshEventBadge(); } function loadEvents(append) { if (!append) _eventsOffset = 0; + var tableRequestId = ++_eventsRequestCount; + var badgeRequestId = ++_badgeRequestCount; var severity = _currentSeverityFilter; var params = '?limit=' + _eventsPageSize + '&offset=' + _eventsOffset; if (severity) params += '&severity=' + severity; + if (_hideOperational) params += '&exclude_operational=true'; + if (_deviceOnlyFilter) params += '&event_prefix=device_'; var tbody = document.getElementById('events-tbody'); var tableCard = document.getElementById('events-table-card'); @@ -177,52 +210,44 @@ function loadEvents(append) { var events = data.events || []; var unack = data.unacknowledged_count || 0; - // Filter operational events client-side - if (_hideOperational) { - var opCount = 0; - events = events.filter(function(ev) { - if (_OPERATIONAL_EVENT_TYPES[ev.event_type]) { - if (!ev.acknowledged) opCount++; - return false; - } - return true; - }); - unack = Math.max(0, unack - opCount); + // Events and unack count are now natively filtered by the backend! + // Events and unack count are now natively filtered by the backend! + var eventsViewEl = document.getElementById('view-events'); + if (badgeRequestId === _badgeRequestCount && eventsViewEl && eventsViewEl.classList.contains('active')) { + updateEventBadge(unack); + ackAllBtn.style.display = unack > 0 ? '' : 'none'; } - updateEventBadge(unack); - ackAllBtn.style.display = unack > 0 ? '' : 'none'; - - if (events.length === 0 && !append) { - empty.textContent = T.event_no_events || 'No events detected yet.'; - empty.style.display = ''; - return; + if (tableRequestId === _eventsRequestCount) { + if (events.length === 0 && !append) { + empty.textContent = T.event_no_events || 'No events detected yet.'; + empty.style.display = ''; + return; + } + events.forEach(function(ev) { + var tr = document.createElement('tr'); + if (ev.acknowledged) tr.className = 'event-acked'; + tr.setAttribute('data-event-id', ev.id); + var sevClass = 'sev-badge-' + ev.severity; + var sevLabel = _sevLabels[ev.severity] || ev.severity; + var sevIcons = { info: 'info', warning: 'triangle-alert', critical: 'octagon-alert' }; + var sevIcon = sevIcons[ev.severity] || 'info'; + var typeLabel = _eventTypeLabel(ev.event_type); + var ackBtn = ev.acknowledged + ? '' + : ''; + tr.innerHTML = + '' + escapeHtml(ev.timestamp.replace('T', ' ')) + '' + + '' + sevLabel + '' + + '' + escapeHtml(typeLabel) + '' + + '' + formatEventMessage(ev) + '' + + '' + ackBtn + ''; + tbody.appendChild(tr); + }); + tableCard.style.display = ''; + moreBtn.style.display = events.length >= _eventsPageSize ? '' : 'none'; + if (typeof lucide !== 'undefined') lucide.createIcons(); } - events.forEach(function(ev) { - var tr = document.createElement('tr'); - if (ev.acknowledged) tr.className = 'event-acked'; - tr.setAttribute('data-event-id', ev.id); - var sevClass = 'sev-badge-' + ev.severity; - var sevLabel = _sevLabels[ev.severity] || ev.severity; - var sevIcons = { info: 'info', warning: 'triangle-alert', critical: 'octagon-alert' }; - var sevIcon = sevIcons[ev.severity] || 'info'; - var typeLabel = _eventTypeLabel(ev.event_type); - // Note: escapeHtml is used on all user-facing content to prevent XSS. - // The ack button uses a hardcoded event ID (integer) which is safe. - var ackBtn = ev.acknowledged - ? '' - : ''; - tr.innerHTML = - '' + escapeHtml(ev.timestamp.replace('T', ' ')) + '' + - '' + sevLabel + '' + - '' + escapeHtml(typeLabel) + '' + - '' + formatEventMessage(ev) + '' + - '' + ackBtn + ''; - tbody.appendChild(tr); - }); - tableCard.style.display = ''; - moreBtn.style.display = events.length >= _eventsPageSize ? '' : 'none'; - if (typeof lucide !== 'undefined') lucide.createIcons(); }) .catch(function() { loading.style.display = 'none'; @@ -271,19 +296,40 @@ function updateEventBadge(count) { }); } -// Fetch badge count on page load (exclude operational if hidden) -fetch('/api/events?limit=200&offset=0') - .then(function(r) { return r.json(); }) - .then(function(data) { - var unack = data.unacknowledged_count || 0; - if (_hideOperational) { - var events = data.events || []; - var opCount = 0; - events.forEach(function(ev) { - if (_OPERATIONAL_EVENT_TYPES[ev.event_type] && !ev.acknowledged) opCount++; - }); - unack = Math.max(0, unack - opCount); +window.refreshEventBadge = function() { + var params = ''; + var eventsViewEl = document.getElementById('view-events'); + var isEventsView = eventsViewEl && eventsViewEl.classList.contains('active'); + var requestId = ++_badgeRequestCount; + + if (typeof _hideOperational !== 'undefined' && _hideOperational) { + params += (params ? '&' : '?') + 'exclude_operational=true'; + } + + // Only apply severity/device filters if we are actually looking at the events view + if (isEventsView) { + if (typeof _deviceOnlyFilter !== 'undefined' && _deviceOnlyFilter) { + params += (params ? '&' : '?') + 'event_prefix=device_'; } - updateEventBadge(unack); - }) - .catch(function() {}); + if (typeof _currentSeverityFilter !== 'undefined' && _currentSeverityFilter) { + params += (params ? '&' : '?') + 'severity=' + _currentSeverityFilter; + } + } + + params += (params ? '&' : '?') + 't=' + Date.now(); + + fetch('/api/events/count' + params) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (requestId === _badgeRequestCount) { + updateEventBadge(data.count || 0); + } + }) + .catch(function() {}); +}; + +// Fetch badge count on page load +refreshEventBadge(); + +// Periodically refresh badge count +setInterval(refreshEventBadge, 60000); diff --git a/app/storage/__init__.py b/app/storage/__init__.py index bf097d71..c2889f01 100644 --- a/app/storage/__init__.py +++ b/app/storage/__init__.py @@ -7,6 +7,7 @@ from .tokens import TokenMixin from .smart_capture import SmartCaptureMixin from .cleanup import CleanupMixin +from .device import DeviceStorageMixin __all__ = [ "SnapshotStorage", @@ -23,6 +24,7 @@ class SnapshotStorage( AnalysisMixin, SmartCaptureMixin, CleanupMixin, + DeviceStorageMixin, StorageBase, ): """Persist DOCSIS analysis snapshots to SQLite.""" diff --git a/app/storage/base.py b/app/storage/base.py index a591fe60..ef3bd70a 100644 --- a/app/storage/base.py +++ b/app/storage/base.py @@ -81,6 +81,16 @@ def _init_db(self): us_channels_json TEXT NOT NULL ) """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS device_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + uptime_seconds INTEGER, + sw_version TEXT, + wan_ipv4 TEXT, + wan_ipv6 TEXT, + updated_at TEXT NOT NULL + ) + """) conn.execute(""" CREATE INDEX IF NOT EXISTS idx_snapshots_ts ON snapshots(timestamp) diff --git a/app/storage/device.py b/app/storage/device.py new file mode 100644 index 00000000..c9e8af7c --- /dev/null +++ b/app/storage/device.py @@ -0,0 +1,28 @@ +"""Storage mixin for device state.""" + +import sqlite3 +from typing import Dict, Any + +class DeviceStorageMixin: + def get_device_state(self) -> Dict[str, Any]: + """Fetch the current tracked device state.""" + with sqlite3.connect(self.db_path) as conn: + conn.row_factory = sqlite3.Row + row = conn.execute("SELECT * FROM device_state WHERE id = 1").fetchone() + if row: + return dict(row) + return {} + + def update_device_state(self, uptime: int | None, sw_version: str | None, ipv4: str | None, ipv6: str | None, updated_at: str): + """Update the tracked device state. Inserts if missing, otherwise overwrites.""" + with sqlite3.connect(self.db_path) as conn: + conn.execute(""" + INSERT INTO device_state (id, uptime_seconds, sw_version, wan_ipv4, wan_ipv6, updated_at) + VALUES (1, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + uptime_seconds = excluded.uptime_seconds, + sw_version = excluded.sw_version, + wan_ipv4 = excluded.wan_ipv4, + wan_ipv6 = excluded.wan_ipv6, + updated_at = excluded.updated_at + """, (uptime, sw_version, ipv4, ipv6, updated_at)) diff --git a/app/storage/events.py b/app/storage/events.py index 0e99fc79..f34b8612 100644 --- a/app/storage/events.py +++ b/app/storage/events.py @@ -65,7 +65,7 @@ def save_events_with_ids(self, events_list: list[EventDict], is_demo: bool = Fal e["_id"] = row_id return ids - def get_events(self, limit: int = 200, offset: int = 0, severity: str | None = None, event_type: str | None = None, acknowledged: bool | None = None) -> list[dict]: + def get_events(self, limit: int = 200, offset: int = 0, severity: str | None = None, event_type: str | None = None, acknowledged: bool | None = None, exclude_operational: bool = False, event_prefix: str | None = None) -> list[dict]: """Return list of event dicts, newest first, with optional filters.""" query = "SELECT id, timestamp, severity, event_type, message, details, acknowledged FROM events" conditions = [] @@ -79,6 +79,11 @@ def get_events(self, limit: int = 200, offset: int = 0, severity: str | None = N if acknowledged is not None: conditions.append("acknowledged = ?") params.append(int(acknowledged)) + if exclude_operational: + conditions.append("event_type NOT IN ('monitoring_started', 'monitoring_stopped')") + if event_prefix: + conditions.append("event_type LIKE ?") + params.append(event_prefix + '%') if conditions: query += " WHERE " + " AND ".join(conditions) query += " ORDER BY timestamp DESC, id DESC LIMIT ? OFFSET ?" @@ -97,17 +102,28 @@ def get_events(self, limit: int = 200, offset: int = 0, severity: str | None = N results.append(event) return results - def get_event_count(self, acknowledged=None): - """Return event count, optionally filtered by acknowledged status.""" + def get_event_count(self, acknowledged=None, exclude_operational: bool = False, event_prefix: str | None = None, severity: str | None = None): + """Return event count, optionally filtered by status, type or severity.""" + query = "SELECT COUNT(*) FROM events" + conditions = [] + params = [] if acknowledged is not None: - with sqlite3.connect(self.db_path) as conn: - row = conn.execute( - "SELECT COUNT(*) FROM events WHERE acknowledged = ?", - (int(acknowledged),), - ).fetchone() - else: - with sqlite3.connect(self.db_path) as conn: - row = conn.execute("SELECT COUNT(*) FROM events").fetchone() + conditions.append("acknowledged = ?") + params.append(int(acknowledged)) + if exclude_operational: + conditions.append("event_type NOT IN ('monitoring_started', 'monitoring_stopped')") + if event_prefix: + conditions.append("event_type LIKE ?") + params.append(event_prefix + '%') + if severity: + conditions.append("severity = ?") + params.append(severity) + + if conditions: + query += " WHERE " + " AND ".join(conditions) + + with sqlite3.connect(self.db_path) as conn: + row = conn.execute(query, params).fetchone() return row[0] if row else 0 def acknowledge_event(self, event_id): diff --git a/app/templates/index.html b/app/templates/index.html index f7570cef..912c430b 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -410,7 +410,7 @@

{{ s.ds_total }}/{{ s.us_total }} Ch - + {% if connection_info and connection_info.max_downstream_kbps %} {{ connection_info.max_downstream_kbps // 1000 }}/{{ connection_info.max_upstream_kbps // 1000 }} MBit/s {% elif booked_download and booked_download > 0 %} @@ -938,7 +938,7 @@

{{ t.no_docsis_title }}

- +
@@ -952,7 +952,7 @@

{{ t.no_docsis_title }}

- +
@@ -966,7 +966,7 @@

{{ t.no_docsis_title }}

- +
@@ -1292,7 +1292,7 @@

{{ t.bqm_import_title }}

- +