This commit is contained in:
Mateusz Gruszczyński
2026-08-17 10:07:19 +02:00
parent 074d17be89
commit cc3c446c8e
29 changed files with 627 additions and 105 deletions
+53 -4
View File
@@ -124,6 +124,7 @@ class WebServer:
forensic_pcap: ForensicPcapRing | None = None,
traffic_source: Any | None = None,
metrics_provider: Callable[[], str] | None = None,
healthcheck_provider: Callable[[], dict] | None = None,
) -> None:
self.config = config
self.store = store
@@ -140,8 +141,17 @@ class WebServer:
self.forensic_pcap = forensic_pcap
self.traffic_source = traffic_source
self.metrics_provider = metrics_provider
self.healthcheck_provider = healthcheck_provider or health_provider
self.metrics_access = MetricsAccessControl(config) if metrics_provider is not None else None
self.backup_manager = backup_manager or BackupManager(config.db_path, os.path.dirname(config.db_path) or ".")
self._status_cache_lock = threading.Lock()
self._status_cache: dict[str, Any] | None = None
self._status_cache_at = 0.0
self._status_cache_seconds = 30.0
self._stats_cache_lock = threading.Lock()
self._stats_cache: dict[str, Any] | None = None
self._stats_cache_at = 0.0
self._stats_cache_seconds = 60.0
self.auth = SessionAuth(config, store)
self._login_lock = threading.Lock()
self._login_attempts: dict[str, deque[float]] = defaultdict(deque)
@@ -149,10 +159,17 @@ class WebServer:
self.thread = threading.Thread(target=self.server.serve_forever, name="web-ui", daemon=True)
def _status_payload(self) -> dict:
now = time.monotonic()
with self._status_cache_lock:
if self._status_cache is not None and now - self._status_cache_at < self._status_cache_seconds:
return dict(self._status_cache)
payload = dict(self.health_provider())
payload["summary"] = self.store.summary()
if self.traffic_history is not None:
history = self.traffic_history.status()
# main.health() already obtains this once for the service table. Reuse
# it instead of issuing another Redis + archive status query.
history = dict(payload.get("traffic_history") or self.traffic_history.status())
if self.live_pipeline is not None:
history.update(self.live_pipeline.status())
if self.event_bus is not None:
@@ -160,6 +177,30 @@ class WebServer:
payload["traffic_history"] = history
if self.analytics_cache is not None:
payload["analytics_snapshots"] = self.analytics_cache.status()
with self._status_cache_lock:
self._status_cache = dict(payload)
self._status_cache_at = time.monotonic()
return payload
def _healthcheck_payload(self) -> dict:
return dict(self.healthcheck_provider())
def _stats_payload(self) -> dict:
"""Cache aggregate SQLite statistics shared by all dashboard clients."""
now = time.monotonic()
with self._stats_cache_lock:
if self._stats_cache is not None and now - self._stats_cache_at < self._stats_cache_seconds:
return dict(self._stats_cache)
payload = {
"summary": self.store.summary(),
"analytics": self.store.analytics(),
"ndr": self.store.ndr_summary(),
}
with self._stats_cache_lock:
self._stats_cache = dict(payload)
self._stats_cache_at = time.monotonic()
return payload
def _analytics_payload(self, window_seconds: int) -> dict:
@@ -192,6 +233,14 @@ class WebServer:
def _login_allowed(self, client_ip: str) -> bool:
now = time.monotonic()
with self._login_lock:
if len(self._login_attempts) >= 4096 and client_ip not in self._login_attempts:
for key, values in list(self._login_attempts.items()):
while values and values[0] < now - 300:
values.popleft()
if not values:
self._login_attempts.pop(key, None)
while len(self._login_attempts) >= 4096:
self._login_attempts.pop(next(iter(self._login_attempts)), None)
attempts = self._login_attempts[client_ip]
while attempts and attempts[0] < now - 300:
attempts.popleft()
@@ -267,7 +316,7 @@ class WebServer:
self._static(parsed.path)
return
if parsed.path == "/api/health":
self._json(outer._status_payload())
self._json(outer._healthcheck_payload())
return
if parsed.path == "/api/auth/session":
self._auth_session()
@@ -281,7 +330,7 @@ class WebServer:
self._json(store.summary())
return
if parsed.path == "/api/stats":
self._json({"summary": store.summary(), "analytics": store.analytics(), "ndr": store.ndr_summary()})
self._json(outer._stats_payload())
return
if parsed.path == "/api/config":
self._json(config.public_dict())
@@ -845,7 +894,7 @@ class WebServer:
if now - last_throughput >= 1:
self._ws_send_json({"type": "throughput", "data": outer._current_throughput_payload(window)})
last_throughput = now
if now - last_status >= 5:
if now - last_status >= 30:
self._ws_send_json({"type": "status", "data": outer._status_payload()})
last_status = now
if now - last_analytics >= 10: