Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions app/blueprints/events_bp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})

Expand All @@ -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/<int:event_id>/acknowledge", methods=["POST"])
Expand Down
33 changes: 32 additions & 1 deletion app/collectors/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand All @@ -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))

Expand Down
119 changes: 119 additions & 0 deletions app/collectors/modem.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import logging
import time
from datetime import datetime, timezone

from .base import Collector, CollectorResult
from ..analyzer import apply_spike_suppression
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion app/i18n/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions app/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions app/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions app/i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -304,13 +304,17 @@
"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",
"event_type_channel_change": "Cambio de canal",
"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",
Expand Down Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions app/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -301,13 +301,17 @@
"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",
"event_type_channel_change": "Changement de canal",
"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",
Expand Down Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion app/i18n/template.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
{
"_meta": {
"language_name": "",
"flag": ""
Expand Down Expand Up @@ -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": "",
Expand Down Expand Up @@ -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": "",
Expand Down
Loading