diff --git a/backend/monitoring/urls.py b/backend/monitoring/urls.py index 66f0a4b..dd45b6f 100644 --- a/backend/monitoring/urls.py +++ b/backend/monitoring/urls.py @@ -7,6 +7,7 @@ urlpatterns = [ path("dashboard/", views.dashboard_snapshot, name="dashboard-snapshot"), path("gps/latest/", views.gps_latest, name="gps-latest"), path("network/latest/", views.network_latest, name="network-latest"), + path("clock/calibrate/", views.clock_calibration, name="clock-calibration"), path("video/status/", views.video_status, name="video-status"), path("video/frame/", views.video_frame, name="video-frame"), path("video/display-probe/", views.video_display_probe, name="video-display-probe"), diff --git a/backend/monitoring/video.py b/backend/monitoring/video.py index edf8071..9dc24b4 100644 --- a/backend/monitoring/video.py +++ b/backend/monitoring/video.py @@ -51,7 +51,13 @@ class VideoDisplayProbeStore: request_to_paint_ms=None, response_to_paint_ms=None, backend_to_request_ms=None, + backend_to_request_ms_raw=None, + backend_to_paint_ms=None, backend_to_paint_ms_raw=None, + browser_backend_clock_offset_ms=None, + browser_backend_clock_rtt_ms=None, + browser_backend_clock_sample_count=0, + browser_backend_clock_calibrated_at=None, ) def record_event(self, payload: dict[str, Any]) -> None: @@ -59,6 +65,10 @@ class VideoDisplayProbeStore: request_started_unix_ms = payload.get("request_started_unix_ms") response_received_unix_ms = payload.get("response_received_unix_ms") paint_unix_ms = payload.get("paint_unix_ms") + browser_backend_clock_offset_ms = self._coerce_float(payload.get("browser_backend_clock_offset_ms")) + browser_backend_clock_rtt_ms = self._coerce_float(payload.get("browser_backend_clock_rtt_ms")) + browser_backend_clock_sample_count = self._coerce_int(payload.get("browser_backend_clock_sample_count")) + browser_backend_clock_calibrated_at = self._coerce_text(payload.get("browser_backend_clock_calibrated_at")) request_to_paint_ms = self._duration_ms(paint_unix_ms, request_started_unix_ms, clamp_floor_zero=True) response_to_paint_ms = self._duration_ms(paint_unix_ms, response_received_unix_ms, clamp_floor_zero=True) backend_received_unix_ms = None @@ -67,11 +77,24 @@ class VideoDisplayProbeStore: backend_received_unix_ms = int(backend_received_unix_ns) / 1_000_000.0 except (TypeError, ValueError): backend_received_unix_ms = None - backend_to_request_ms = self._duration_ms(request_started_unix_ms, backend_received_unix_ms, clamp_floor_zero=False) + backend_received_browser_unix_ms = None + if backend_received_unix_ms is not None and browser_backend_clock_offset_ms is not None: + backend_received_browser_unix_ms = round(backend_received_unix_ms + browser_backend_clock_offset_ms, 3) + backend_to_request_ms_raw = self._duration_ms(request_started_unix_ms, backend_received_unix_ms, clamp_floor_zero=False) backend_to_paint_ms_raw = self._duration_ms(paint_unix_ms, backend_received_unix_ms, clamp_floor_zero=False) + backend_to_request_ms = self._duration_ms( + request_started_unix_ms, + backend_received_browser_unix_ms, + clamp_floor_zero=True, + ) + backend_to_paint_ms = self._duration_ms( + paint_unix_ms, + backend_received_browser_unix_ms, + clamp_floor_zero=True, + ) status = VideoDisplayProbeStatus( - updated_at=str(payload.get("updated_at") or ""), + updated_at=self._coerce_text(payload.get("updated_at")), frame_seq=int(payload["frame_seq"]) if payload.get("frame_seq") is not None else None, frame_hash=str(payload.get("frame_hash") or ""), input_to_next_fresh_frame_ms=self._coerce_float(payload.get("input_to_next_fresh_frame_ms")), @@ -80,11 +103,29 @@ class VideoDisplayProbeStore: request_to_paint_ms=request_to_paint_ms, response_to_paint_ms=response_to_paint_ms, backend_to_request_ms=backend_to_request_ms, + backend_to_request_ms_raw=backend_to_request_ms_raw, + backend_to_paint_ms=backend_to_paint_ms, backend_to_paint_ms_raw=backend_to_paint_ms_raw, + browser_backend_clock_offset_ms=browser_backend_clock_offset_ms, + browser_backend_clock_rtt_ms=browser_backend_clock_rtt_ms, + browser_backend_clock_sample_count=browser_backend_clock_sample_count, + browser_backend_clock_calibrated_at=browser_backend_clock_calibrated_at, + ) + logged_payload = dict(payload) + logged_payload.update( + { + "request_to_paint_ms": request_to_paint_ms, + "response_to_paint_ms": response_to_paint_ms, + "backend_received_browser_unix_ms": backend_received_browser_unix_ms, + "backend_to_request_ms": backend_to_request_ms, + "backend_to_request_ms_raw": backend_to_request_ms_raw, + "backend_to_paint_ms": backend_to_paint_ms, + "backend_to_paint_ms_raw": backend_to_paint_ms_raw, + } ) with self._lock: self._latest = status - self._logger.write(payload) + self._logger.write(logged_payload) def get_status(self) -> dict[str, Any]: with self._lock: @@ -99,7 +140,13 @@ class VideoDisplayProbeStore: "request_to_paint_ms": latest.request_to_paint_ms, "response_to_paint_ms": latest.response_to_paint_ms, "backend_to_request_ms": latest.backend_to_request_ms, + "backend_to_request_ms_raw": latest.backend_to_request_ms_raw, + "backend_to_paint_ms": latest.backend_to_paint_ms, "backend_to_paint_ms_raw": latest.backend_to_paint_ms_raw, + "browser_backend_clock_offset_ms": latest.browser_backend_clock_offset_ms, + "browser_backend_clock_rtt_ms": latest.browser_backend_clock_rtt_ms, + "browser_backend_clock_sample_count": latest.browser_backend_clock_sample_count, + "browser_backend_clock_calibrated_at": latest.browser_backend_clock_calibrated_at, } def close(self) -> None: @@ -114,6 +161,20 @@ class VideoDisplayProbeStore: except (TypeError, ValueError): return None + @staticmethod + def _coerce_int(value: Any) -> int: + try: + if value is None: + return 0 + return int(value) + except (TypeError, ValueError): + return 0 + + @staticmethod + def _coerce_text(value: Any) -> str | None: + text = str(value or "").strip() + return text or None + @classmethod def _duration_ms(cls, end_ms: Any, start_ms: Any, *, clamp_floor_zero: bool) -> float | None: end_value = cls._coerce_float(end_ms) @@ -152,7 +213,13 @@ class VideoDisplayProbeStatus: request_to_paint_ms: float | None response_to_paint_ms: float | None backend_to_request_ms: float | None + backend_to_request_ms_raw: float | None + backend_to_paint_ms: float | None backend_to_paint_ms_raw: float | None + browser_backend_clock_offset_ms: float | None + browser_backend_clock_rtt_ms: float | None + browser_backend_clock_sample_count: int + browser_backend_clock_calibrated_at: str | None class OmniSocketVideoReceiver: diff --git a/backend/monitoring/views.py b/backend/monitoring/views.py index 3edb0f5..fb9664e 100644 --- a/backend/monitoring/views.py +++ b/backend/monitoring/views.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import time from django.views.decorators.csrf import csrf_exempt from django.http import HttpResponse, StreamingHttpResponse @@ -31,6 +32,20 @@ def network_latest(request): return Response(network_service.get_latest()) +@api_view(["GET"]) +def clock_calibration(request): + server_received_unix_ns = time.time_ns() + server_sent_unix_ns = time.time_ns() + response = Response( + { + "server_received_unix_ms": round(server_received_unix_ns / 1_000_000.0, 3), + "server_sent_unix_ms": round(server_sent_unix_ns / 1_000_000.0, 3), + } + ) + response["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + return response + + @api_view(["GET"]) def video_status(request): return Response(video_service.get_status()) diff --git a/frontend/src/components/VideoPanel.vue b/frontend/src/components/VideoPanel.vue index 8016a29..3e06a1c 100644 --- a/frontend/src/components/VideoPanel.vue +++ b/frontend/src/components/VideoPanel.vue @@ -1,7 +1,7 @@