from __future__ import annotations import threading import time from datetime import datetime, timezone from typing import Any, Iterable from .live import RedisUnavailableError, TrafficHistory from .store import AlertStore SUMMARY_WINDOWS = (900, 3600, 21600, 86400) class AnalyticsSnapshotCache: """Redis-backed dashboard snapshots refreshed only for windows actually in use. Older builds rebuilt all four windows every minute, which meant scanning and decoding the complete 24-hour Redis history even when the browser displayed only 15 minutes. This cache keeps persisted snapshots, but refreshes only recently requested windows and uses a slower cadence for wider ranges. """ def __init__( self, store: AlertStore, history: TrafficHistory, stop_event: threading.Event, interval_seconds: int = 60, ) -> None: # AlertStore stays in the signature for backwards compatibility with the # application wiring, but traffic analytics are Redis-only. self.store = store self.history = history self.stop_event = stop_event self.interval_seconds = max(15, int(interval_seconds)) self._thread = threading.Thread(target=self._run, name="analytics-snapshots", daemon=True) self._wake = threading.Event() self._lock = threading.RLock() self._requested_at: dict[int, float] = {} self._last_refresh: dict[int, float] = {} self._errors = 0 self._refreshes = 0 # If a browser has not used a window for this long, stop rebuilding it. self._active_ttl_seconds = max(300, self.interval_seconds * 10) def start(self) -> None: if not self._thread.is_alive(): self._thread.start() def stop(self, timeout: float = 2.0) -> None: self._wake.set() if self._thread.is_alive(): self._thread.join(timeout=timeout) def get(self, window_seconds: int) -> dict[str, Any]: window = self._normalise_window(window_seconds) now = time.monotonic() with self._lock: self._requested_at[window] = now try: cached = self.history.snapshot(window) except RedisUnavailableError: raise cadence = self._refresh_interval(window) if cached is not None: cached = self._decorate(cached, "redis-cache") age = float(cached.get("snapshot_age_seconds") or 0) stale = age > cadence * 1.5 cached["snapshot_stale"] = stale cached["snapshot_refreshing"] = stale cached["snapshot_refresh_interval_seconds"] = cadence self._overlay_current_throughput(cached) if stale: self._wake.set() return cached # First request after an empty Redis volume returns a shell immediately; # only this requested range is built in the background. self._wake.set() now_ms = int(time.time() * 1000) bins_count = 60 bin_ms = max(1000, int(window * 1000 / bins_count)) return { "window_seconds": window, "events": 0, "bytes": 0, "alerts": 0, "blocked": 0, "timeline": [ { "ts_ms": now_ms - window * 1000 + idx * bin_ms, "events": 0, "bytes": 0, "alerts": 0, "bps": 0, "in_bps": 0, "out_bps": 0, "other_bps": 0, "pps": 0, } for idx in range(bins_count) ], "snapshot_source": "redis-background", "snapshot_age_seconds": 0, "snapshot_loading": True, "snapshot_refreshing": True, "snapshot_refresh_interval_seconds": cadence, } def refresh_all(self) -> None: """Explicit maintenance/test operation; normal background work is demand-driven.""" self.refresh_windows(SUMMARY_WINDOWS) def refresh_windows(self, windows: Iterable[int]) -> None: normalized = sorted({self._normalise_window(window) for window in windows}) if not normalized: return try: # analytics_many scans only the widest requested window once. snapshots = self.history.analytics_many(normalized) now_wall = time.time() for window in normalized: self.history.save_snapshot(window, snapshots[window]) with self._lock: self._last_refresh[window] = now_wall self._refreshes += 1 except (RedisUnavailableError, KeyError, ValueError, OSError): with self._lock: self._errors += 1 except Exception: with self._lock: self._errors += 1 def status(self) -> dict[str, Any]: persisted = self.history.snapshot_status(SUMMARY_WINDOWS) now = time.monotonic() with self._lock: active = [ window for window, requested in self._requested_at.items() if now - requested <= self._active_ttl_seconds ] refreshes = self._refreshes errors = self._errors return { "backend": "redis", "interval_seconds": self.interval_seconds, "windows": list(SUMMARY_WINDOWS), "persisted": persisted, "active_windows": sorted(active), "cadence_seconds": {str(window): self._refresh_interval(window) for window in SUMMARY_WINDOWS}, "refreshes": refreshes, "errors": errors, } def _run(self) -> None: # Do not scan 24h on process startup. The first browser/API request marks # its selected range active and wakes this worker. while not self.stop_event.is_set(): self._wake.wait(timeout=min(5.0, float(self.interval_seconds))) self._wake.clear() if self.stop_event.is_set(): break due = self._due_windows() if due: self.refresh_windows(due) def _due_windows(self) -> list[int]: now_mono = time.monotonic() now_wall = time.time() due: list[int] = [] with self._lock: requests = dict(self._requested_at) last_refresh = dict(self._last_refresh) persisted = { int(row["window_seconds"]): row.get("generated_at") for row in self.history.snapshot_status(requests.keys()) } for window, requested_at in requests.items(): if now_mono - requested_at > self._active_ttl_seconds: continue cadence = self._refresh_interval(window) last = last_refresh.get(window, 0.0) if last <= 0 and window in persisted: last = self._generated_epoch(persisted[window]) if last <= 0 or now_wall - last >= cadence: due.append(window) return sorted(due) def _refresh_interval(self, window: int) -> int: base = self.interval_seconds if window <= 900: return base if window <= 3600: return max(base * 2, 120) if window <= 21600: return max(base * 5, 300) return max(base * 15, 900) def _overlay_current_throughput(self, payload: dict[str, Any]) -> None: """Keep the 'now' rate fresh without rescanning the selected history window.""" try: sample = self.history.latest_throughput() except RedisUnavailableError: return if not sample: return now_ms = int(time.time() * 1000) ts_ms = int(sample.get("ts_ms") or 0) interval = max(float(sample.get("interval_ms") or 1000) / 1000.0, 0.001) age_ms = max(0, now_ms - ts_ms) if age_ms > max(3000, round(interval * 3000)): current = current_in = current_out = current_pps = 0 else: current = round(max(int(sample.get("bytes_total") or 0), 0) * 8 / interval) current_in = round(max(int(sample.get("bytes_in") or 0), 0) * 8 / interval) current_out = round(max(int(sample.get("bytes_out") or 0), 0) * 8 / interval) current_pps = round(max(int(sample.get("packets_total") or 0), 0) / interval, 2) payload["current_bps"] = current payload["current_in_bps"] = current_in payload["current_out_bps"] = current_out payload["current_other_bps"] = max(0, current - current_in - current_out) payload["current_pps"] = current_pps @staticmethod def _generated_epoch(value: Any) -> float: if not value: return 0.0 try: parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00")) if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=timezone.utc) return parsed.timestamp() except (TypeError, ValueError): return 0.0 @staticmethod def _normalise_window(value: int) -> int: value = int(value) if value in SUMMARY_WINDOWS: return value return min(SUMMARY_WINDOWS, key=lambda item: abs(item - value)) @staticmethod def _decorate(payload: dict[str, Any], source: str) -> dict[str, Any]: result = dict(payload) generated = result.get("generated_at") age = 0.0 if generated: try: parsed = datetime.fromisoformat(str(generated).replace("Z", "+00:00")) if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=timezone.utc) age = max(0.0, (datetime.now(timezone.utc) - parsed.astimezone(timezone.utc)).total_seconds()) except ValueError: age = 0.0 result["snapshot_source"] = source result["snapshot_age_seconds"] = round(age, 1) return result