diff --git a/ctrl/Tiltfile b/ctrl/Tiltfile index 63499fd..aa2e6e3 100644 --- a/ctrl/Tiltfile +++ b/ctrl/Tiltfile @@ -19,21 +19,19 @@ docker_build( ], ) -# Detection UI (Vue 3) +# Detection UI (Vue 3) — context is ui/ so framework link resolves docker_build( 'mpr-detection', - context='../ui/detection-app', + context='../ui', dockerfile='../ui/detection-app/Dockerfile', live_update=[ - sync('../ui/detection-app/src', '/app/src'), - sync('../ui/detection-app/index.html', '/app/index.html'), - sync('../ui/detection-app/vite.config.ts', '/app/vite.config.ts'), + sync('../ui/detection-app/src', '/ui/detection-app/src'), + sync('../ui/detection-app/index.html', '/ui/detection-app/index.html'), + sync('../ui/detection-app/vite.config.ts', '/ui/detection-app/vite.config.ts'), + sync('../ui/framework/src', '/ui/framework/src'), ], ) -# Framework changes trigger a full rebuild (live_update can't reach outside context) -watch_file('../ui/framework/src') - # --- Resources --- k8s_resource('redis') diff --git a/ctrl/k8s/base/envoy.yaml b/ctrl/k8s/base/envoy.yaml new file mode 100644 index 0000000..926e14b --- /dev/null +++ b/ctrl/k8s/base/envoy.yaml @@ -0,0 +1,78 @@ +static_resources: + listeners: + - name: http + address: + socket_address: + address: 0.0.0.0 + port_value: 8080 + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ingress + codec_type: AUTO + route_config: + name: local_routes + virtual_hosts: + - name: mpr + domains: ["mpr.local.ar", "k8s.mpr.local.ar", "*"] + routes: + # SSE — long timeout, no buffering, strip /api prefix + - match: + prefix: "/api/detect/stream/" + route: + cluster: fastapi + prefix_rewrite: "/detect/stream/" + timeout: 3600s + idle_timeout: 3600s + # FastAPI — strip /api/ prefix + - match: + prefix: "/api/" + route: + cluster: fastapi + prefix_rewrite: "/" + # Detection UI (with WebSocket upgrade for Vite HMR) + - match: + prefix: "/detection/" + route: + cluster: detection-ui + upgrade_configs: + - upgrade_type: websocket + # Default + - match: + prefix: "/" + route: + cluster: detection-ui + prefix_rewrite: "/detection/" + http_filters: + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + clusters: + - name: fastapi + connect_timeout: 5s + type: STRICT_DNS + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: fastapi + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: fastapi + port_value: 8702 + - name: detection-ui + connect_timeout: 5s + type: STRICT_DNS + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: detection-ui + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: detection-ui + port_value: 5175 diff --git a/ctrl/k8s/base/gateway.yaml b/ctrl/k8s/base/gateway.yaml index 3122984..dcea5d2 100644 --- a/ctrl/k8s/base/gateway.yaml +++ b/ctrl/k8s/base/gateway.yaml @@ -1,86 +1,3 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: envoy-gateway-config - namespace: mpr -data: - envoy.yaml: | - static_resources: - listeners: - - name: http - address: - socket_address: - address: 0.0.0.0 - port_value: 8080 - filter_chains: - - filters: - - name: envoy.filters.network.http_connection_manager - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager - stat_prefix: ingress - codec_type: AUTO - route_config: - name: local_routes - virtual_hosts: - - name: mpr - domains: ["k8s.mpr.local.ar", "*"] - routes: - # SSE — long timeout, no buffering - - match: - prefix: "/api/detect/stream/" - route: - cluster: fastapi - timeout: 3600s - idle_timeout: 3600s - # FastAPI — strip /api/ prefix - - match: - prefix: "/api/" - route: - cluster: fastapi - prefix_rewrite: "/" - # Detection UI - - match: - prefix: "/detection/" - route: - cluster: detection-ui - # Default - - match: - prefix: "/" - route: - cluster: detection-ui - prefix_rewrite: "/detection/" - http_filters: - - name: envoy.filters.http.router - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router - clusters: - - name: fastapi - connect_timeout: 5s - type: STRICT_DNS - lb_policy: ROUND_ROBIN - load_assignment: - cluster_name: fastapi - endpoints: - - lb_endpoints: - - endpoint: - address: - socket_address: - address: fastapi - port_value: 8702 - - name: detection-ui - connect_timeout: 5s - type: STRICT_DNS - lb_policy: ROUND_ROBIN - load_assignment: - cluster_name: detection-ui - endpoints: - - lb_endpoints: - - endpoint: - address: - socket_address: - address: detection-ui - port_value: 5175 ---- apiVersion: apps/v1 kind: Deployment metadata: diff --git a/ctrl/k8s/base/kustomization.yaml b/ctrl/k8s/base/kustomization.yaml index cc501ab..b9f453b 100644 --- a/ctrl/k8s/base/kustomization.yaml +++ b/ctrl/k8s/base/kustomization.yaml @@ -10,3 +10,10 @@ resources: - fastapi.yaml - detection-ui.yaml - gateway.yaml + +configMapGenerator: + - name: envoy-gateway-config + files: + - envoy.yaml + options: + disableNameSuffixHash: true diff --git a/detect/events.py b/detect/events.py index b6253a7..212446f 100644 --- a/detect/events.py +++ b/detect/events.py @@ -31,11 +31,12 @@ ALL_EVENT_TYPES = [ TERMINAL_EVENTS = [EVENT_JOB_COMPLETE] -def push_detect_event(job_id: str, event_type: str, data: BaseModel) -> None: - """Push a typed detection event to Redis.""" +def push_detect_event(job_id: str, event_type: str, data: BaseModel | dict) -> None: + """Push a detection event to Redis. Accepts Pydantic models or plain dicts.""" + payload = data.model_dump(mode="json") if isinstance(data, BaseModel) else data push_event( job_id=job_id, event_type=event_type, - data=data.model_dump(mode="json"), + data=payload, prefix=DETECT_EVENTS_PREFIX, ) diff --git a/detect/stages/__init__.py b/detect/stages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/detect/stages/frame_extractor.py b/detect/stages/frame_extractor.py new file mode 100644 index 0000000..893354e --- /dev/null +++ b/detect/stages/frame_extractor.py @@ -0,0 +1,102 @@ +""" +Stage 1 — Frame Extraction + +Extracts frames from a video at a configurable FPS using FFmpeg. +Emits log + stats_update SSE events as it works. +""" + +from __future__ import annotations + +import subprocess +import tempfile +from pathlib import Path + +import numpy as np +from PIL import Image + +from core.ffmpeg.probe import probe_file +from detect.events import push_detect_event +from detect.models import Frame +from detect.profiles.base import FrameExtractionConfig + + +def extract_frames( + video_path: str, + config: FrameExtractionConfig, + job_id: str | None = None, +) -> list[Frame]: + """ + Extract frames from video at the configured FPS. + + Uses FFmpeg to decode frames as raw images, then loads them + as numpy arrays. Caps at config.max_frames. + """ + probe = probe_file(video_path) + duration = probe.duration or 0.0 + + if job_id: + push_detect_event(job_id, "log", { + "level": "INFO", + "stage": "FrameExtractor", + "msg": f"Starting extraction: {Path(video_path).name} " + f"({duration:.1f}s, {probe.width}x{probe.height}, fps={config.fps})", + }) + + frames: list[Frame] = [] + + with tempfile.TemporaryDirectory() as tmpdir: + pattern = str(Path(tmpdir) / "frame_%06d.jpg") + + cmd = [ + "ffmpeg", "-i", video_path, + "-vf", f"fps={config.fps}", + "-q:v", "2", + "-frames:v", str(config.max_frames), + pattern, + "-y", "-loglevel", "warning", + ] + + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode != 0: + if job_id: + push_detect_event(job_id, "log", { + "level": "ERROR", + "stage": "FrameExtractor", + "msg": f"FFmpeg failed: {result.stderr[:200]}", + }) + raise RuntimeError(f"FFmpeg failed: {result.stderr}") + + frame_files = sorted(Path(tmpdir).glob("frame_*.jpg")) + + for i, fpath in enumerate(frame_files): + img = Image.open(fpath) + arr = np.array(img) + timestamp = i / config.fps + + frames.append(Frame( + sequence=i, + chunk_id=0, + timestamp=timestamp, + image=arr, + )) + + if job_id: + push_detect_event(job_id, "log", { + "level": "INFO", + "stage": "FrameExtractor", + "msg": f"Extracted {len(frames)} frames", + }) + push_detect_event(job_id, "stats_update", { + "frames_extracted": len(frames), + "frames_after_scene_filter": 0, + "regions_detected": 0, + "regions_resolved_by_ocr": 0, + "regions_escalated_to_local_vlm": 0, + "regions_escalated_to_cloud_llm": 0, + "cloud_llm_calls": 0, + "processing_time_seconds": 0.0, + "estimated_cloud_cost_usd": 0.0, + }) + + return frames diff --git a/tests/detect/manual/push_logs.py b/tests/detect/manual/push_logs.py new file mode 100644 index 0000000..571ccd5 --- /dev/null +++ b/tests/detect/manual/push_logs.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +""" +Push a stream of log events to Redis for UI testing. + +Usage: + python tests/detect/manual/push_logs.py [--job JOB_ID] [--port PORT] [--count N] [--delay SECS] + +Opens: http://mpr.local.ar/detection/?job= +""" + +import argparse +import json +import random +import time +from datetime import datetime, timezone + +import redis + +STAGES = ["FrameExtractor", "SceneFilter", "YOLODetector", "OCRStage", "BrandResolver", "VLMLocal", "Aggregator"] +LEVELS = ["INFO", "INFO", "INFO", "INFO", "WARNING", "DEBUG", "ERROR"] # weighted toward INFO +MESSAGES = { + "FrameExtractor": [ + "Starting extraction: sample.mp4 (120.0s, 1920x1080, fps=2)", + "Extracted 240 frames", + "Frame extraction complete", + ], + "SceneFilter": [ + "Filtering duplicate scenes (hamming_threshold=8)", + "Removed 180 duplicate frames", + "Kept 60 unique frames (75% reduction)", + ], + "YOLODetector": [ + "Loading yolov8n.pt (fp16)", + "Processing batch 1/3 (20 frames)", + "Processing batch 2/3 (20 frames)", + "Processing batch 3/3 (20 frames)", + "Detected 45 regions across 60 frames", + ], + "OCRStage": [ + "Running PaddleOCR on 45 regions", + "Extracted text from 32 regions", + "Resolved 28 brands via OCR", + ], + "BrandResolver": [ + "Matching against brand dictionary (12 brands)", + "Exact matches: 20, Fuzzy matches: 8", + "Unresolved: 4 regions → escalating to VLM", + ], + "VLMLocal": [ + "Loading moondream2 (int4, 2.1GB VRAM)", + "Processing 4 unresolved crops", + "Resolved 3/4 crops, 1 → cloud escalation", + ], + "Aggregator": [ + "Compiling detection report", + "Found 6 unique brands, 31 total appearances", + "Report complete", + ], +} + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--job", default="manual-test") + parser.add_argument("--port", type=int, default=6382) + parser.add_argument("--count", type=int, default=50) + parser.add_argument("--delay", type=float, default=0.2) + args = parser.parse_args() + + r = redis.Redis(port=args.port, decode_responses=True) + key = f"detect_events:{args.job}" + + print(f"Pushing {args.count} log events to {key} (redis port {args.port})") + print(f"Open: http://mpr.local.ar/detection/?job={args.job}") + print() + + for i in range(args.count): + stage = random.choice(STAGES) + level = random.choice(LEVELS) + msg = random.choice(MESSAGES[stage]) + + event = { + "event": "log", + "level": level, + "stage": stage, + "msg": f"[{i+1}/{args.count}] {msg}", + "ts": datetime.now(timezone.utc).isoformat(), + } + + r.rpush(key, json.dumps(event)) + print(f" {level:7s} {stage:16s} {msg[:60]}") + time.sleep(args.delay) + + print(f"\nDone. {args.count} events pushed.") + + +if __name__ == "__main__": + main() diff --git a/tests/detect/manual/push_pipeline.py b/tests/detect/manual/push_pipeline.py new file mode 100644 index 0000000..ef86bba --- /dev/null +++ b/tests/detect/manual/push_pipeline.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +""" +Simulate a full pipeline run — pushes all event types in sequence. + +Usage: + python tests/detect/manual/push_pipeline.py [--job JOB_ID] [--port PORT] [--delay SECS] + +Opens: http://mpr.local.ar/detection/?job= +""" + +import argparse +import json +import time +from datetime import datetime, timezone + +import redis + + +def ts(): + return datetime.now(timezone.utc).isoformat() + + +def push(r, key, event): + event["ts"] = event.get("ts", ts()) + r.rpush(key, json.dumps(event)) + etype = event["event"] + detail = event.get("msg", event.get("stage", "")) + print(f" [{etype:14s}] {detail}") + return event + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--job", default="pipeline-test") + parser.add_argument("--port", type=int, default=6382) + parser.add_argument("--delay", type=float, default=0.5) + args = parser.parse_args() + + r = redis.Redis(port=args.port, decode_responses=True) + key = f"detect_events:{args.job}" + + # Clear previous events for this job + r.delete(key) + + print(f"Simulating pipeline run → {key}") + print(f"Open: http://mpr.local.ar/detection/?job={args.job}") + print() + + delay = args.delay + + # Stage 1: Frame extraction + push(r, key, {"event": "log", "level": "INFO", "stage": "FrameExtractor", + "msg": "Starting extraction: soccer_clip.mp4 (60.0s, 1920x1080, fps=2)"}) + time.sleep(delay) + + push(r, key, {"event": "stats_update", + "frames_extracted": 120, "frames_after_scene_filter": 0, + "regions_detected": 0, "regions_resolved_by_ocr": 0, + "regions_escalated_to_local_vlm": 0, "regions_escalated_to_cloud_llm": 0, + "cloud_llm_calls": 0, "processing_time_seconds": 3.2, "estimated_cloud_cost_usd": 0}) + time.sleep(delay) + + push(r, key, {"event": "log", "level": "INFO", "stage": "FrameExtractor", + "msg": "Extracted 120 frames"}) + time.sleep(delay) + + # Stage 2: Scene filter + push(r, key, {"event": "log", "level": "INFO", "stage": "SceneFilter", + "msg": "Filtering duplicates (hamming_threshold=8)"}) + time.sleep(delay) + + push(r, key, {"event": "stats_update", + "frames_extracted": 120, "frames_after_scene_filter": 45, + "regions_detected": 0, "regions_resolved_by_ocr": 0, + "regions_escalated_to_local_vlm": 0, "regions_escalated_to_cloud_llm": 0, + "cloud_llm_calls": 0, "processing_time_seconds": 5.1, "estimated_cloud_cost_usd": 0}) + time.sleep(delay) + + push(r, key, {"event": "log", "level": "INFO", "stage": "SceneFilter", + "msg": "Kept 45 frames (62.5% reduction)"}) + time.sleep(delay) + + # Stage 3: YOLO detection + push(r, key, {"event": "log", "level": "INFO", "stage": "YOLODetector", + "msg": "Loading yolov8n.pt (fp16, 1.2GB VRAM)"}) + time.sleep(delay) + + for batch in range(1, 4): + push(r, key, {"event": "log", "level": "DEBUG", "stage": "YOLODetector", + "msg": f"Processing batch {batch}/3 (15 frames)"}) + time.sleep(delay * 0.5) + + push(r, key, {"event": "stats_update", + "frames_extracted": 120, "frames_after_scene_filter": 45, + "regions_detected": 32, "regions_resolved_by_ocr": 0, + "regions_escalated_to_local_vlm": 0, "regions_escalated_to_cloud_llm": 0, + "cloud_llm_calls": 0, "processing_time_seconds": 12.4, "estimated_cloud_cost_usd": 0}) + time.sleep(delay) + + # Stage 4: OCR + push(r, key, {"event": "log", "level": "INFO", "stage": "OCRStage", + "msg": "Running PaddleOCR on 32 regions"}) + time.sleep(delay) + + push(r, key, {"event": "stats_update", + "frames_extracted": 120, "frames_after_scene_filter": 45, + "regions_detected": 32, "regions_resolved_by_ocr": 24, + "regions_escalated_to_local_vlm": 0, "regions_escalated_to_cloud_llm": 0, + "cloud_llm_calls": 0, "processing_time_seconds": 18.7, "estimated_cloud_cost_usd": 0}) + time.sleep(delay) + + # Stage 5: Brand resolver + push(r, key, {"event": "log", "level": "INFO", "stage": "BrandResolver", + "msg": "Matched 20 exact, 4 fuzzy. 8 unresolved → VLM"}) + time.sleep(delay) + + # Emit some detections + for brand, conf in [("Nike", 0.95), ("Emirates", 0.91), ("Adidas", 0.88), ("Coca-Cola", 0.82)]: + push(r, key, {"event": "detection", + "brand": brand, "confidence": conf, "source": "ocr", + "timestamp": 12.5, "duration": 0.5, "content_type": "soccer_broadcast", + "frame_ref": 25}) + time.sleep(delay * 0.3) + + # Stage 6: VLM escalation + push(r, key, {"event": "log", "level": "INFO", "stage": "VLMLocal", + "msg": "Processing 8 unresolved crops with moondream2"}) + time.sleep(delay) + + push(r, key, {"event": "log", "level": "WARNING", "stage": "VLMLocal", + "msg": "Low confidence on 2 crops, escalating to cloud LLM"}) + time.sleep(delay) + + push(r, key, {"event": "stats_update", + "frames_extracted": 120, "frames_after_scene_filter": 45, + "regions_detected": 32, "regions_resolved_by_ocr": 24, + "regions_escalated_to_local_vlm": 8, "regions_escalated_to_cloud_llm": 2, + "cloud_llm_calls": 2, "processing_time_seconds": 28.3, "estimated_cloud_cost_usd": 0.0042}) + time.sleep(delay) + + # More detections from VLM + push(r, key, {"event": "detection", + "brand": "Mastercard", "confidence": 0.76, "source": "local_vlm", + "timestamp": 34.0, "duration": 1.0, "content_type": "soccer_broadcast", + "frame_ref": 68}) + time.sleep(delay * 0.3) + + push(r, key, {"event": "detection", + "brand": "Heineken", "confidence": 0.71, "source": "cloud_llm", + "timestamp": 45.5, "duration": 0.5, "content_type": "soccer_broadcast", + "frame_ref": 91}) + time.sleep(delay * 0.3) + + # Final + push(r, key, {"event": "log", "level": "INFO", "stage": "Aggregator", + "msg": "Report complete: 6 brands, 26 total appearances"}) + time.sleep(delay) + + push(r, key, {"event": "job_complete", "job_id": args.job, + "report": { + "video_source": "soccer_clip.mp4", + "content_type": "soccer_broadcast", + "duration_seconds": 60.0, + "brands": { + "Nike": {"total_appearances": 8, "total_screen_time": 4.0, "avg_confidence": 0.93}, + "Emirates": {"total_appearances": 6, "total_screen_time": 3.0, "avg_confidence": 0.89}, + "Adidas": {"total_appearances": 5, "total_screen_time": 2.5, "avg_confidence": 0.85}, + "Coca-Cola": {"total_appearances": 4, "total_screen_time": 2.0, "avg_confidence": 0.80}, + "Mastercard": {"total_appearances": 2, "total_screen_time": 1.0, "avg_confidence": 0.76}, + "Heineken": {"total_appearances": 1, "total_screen_time": 0.5, "avg_confidence": 0.71}, + }, + }}) + + print(f"\nPipeline simulation complete.") + + +if __name__ == "__main__": + main() diff --git a/tests/detect/test_frame_extractor.py b/tests/detect/test_frame_extractor.py new file mode 100644 index 0000000..2d11e2b --- /dev/null +++ b/tests/detect/test_frame_extractor.py @@ -0,0 +1,77 @@ +"""Tests for FrameExtractor stage.""" + +import glob +from pathlib import Path + +import pytest + +from detect.profiles.base import FrameExtractionConfig +from detect.stages.frame_extractor import extract_frames + +SAMPLE_DIR = Path("media/out/chunks/95043d50-4df6-4ac8-bbd5-2ba873117c6e") + + +def _get_sample_video() -> str: + """Return path to first available sample chunk.""" + chunks = sorted(SAMPLE_DIR.glob("chunk_*.mp4")) + if not chunks: + pytest.skip("No sample video found in media/out/chunks/") + return str(chunks[0]) + + +def test_extract_frames_basic(): + video = _get_sample_video() + config = FrameExtractionConfig(fps=1, max_frames=10) + frames = extract_frames(video, config) + + assert len(frames) > 0 + assert len(frames) <= 10 + + for f in frames: + assert f.image.ndim == 3 # H x W x C + assert f.image.shape[2] == 3 # RGB + assert f.sequence >= 0 + assert f.timestamp >= 0.0 + + +def test_extract_frames_respects_fps(): + video = _get_sample_video() + config_1fps = FrameExtractionConfig(fps=1, max_frames=100) + config_2fps = FrameExtractionConfig(fps=2, max_frames=100) + + frames_1 = extract_frames(video, config_1fps) + frames_2 = extract_frames(video, config_2fps) + + # 2fps should produce roughly 2x as many frames + assert len(frames_2) >= len(frames_1) + + +def test_extract_frames_respects_max(): + video = _get_sample_video() + config = FrameExtractionConfig(fps=10, max_frames=3) + frames = extract_frames(video, config) + + assert len(frames) <= 3 + + +def test_extract_frames_with_events(monkeypatch): + """Verify SSE events are emitted when job_id is provided.""" + events = [] + + def mock_push(job_id, event_type, data): + events.append((job_id, event_type, data)) + + monkeypatch.setattr("detect.stages.frame_extractor.push_detect_event", mock_push) + + video = _get_sample_video() + config = FrameExtractionConfig(fps=1, max_frames=5) + frames = extract_frames(video, config, job_id="test-123") + + assert len(frames) > 0 + + event_types = [e[1] for e in events] + assert "log" in event_types + assert "stats_update" in event_types + + # All events targeted the right job + assert all(e[0] == "test-123" for e in events) diff --git a/tests/detect/test_frame_extractor_e2e.py b/tests/detect/test_frame_extractor_e2e.py new file mode 100644 index 0000000..30994e9 --- /dev/null +++ b/tests/detect/test_frame_extractor_e2e.py @@ -0,0 +1,27 @@ +""" +End-to-end test: run FrameExtractor and verify SSE events are emitted. + +Usage (manual): + python tests/detect/test_frame_extractor_e2e.py + +Requires Redis running on localhost:6381. +Push events will appear at: http://mpr.local.ar/detection/?job=e2e-test +""" + +import sys +sys.path.insert(0, ".") + +from detect.profiles.soccer import SoccerBroadcastProfile +from detect.stages.frame_extractor import extract_frames + +VIDEO = "media/out/chunks/95043d50-4df6-4ac8-bbd5-2ba873117c6e/chunk_0000.mp4" +JOB_ID = "e2e-test" + +profile = SoccerBroadcastProfile() +config = profile.frame_extraction_config() +config.max_frames = 20 # keep it quick + +print(f"Extracting frames from {VIDEO} (fps={config.fps}, max={config.max_frames})") +frames = extract_frames(VIDEO, config, job_id=JOB_ID) +print(f"Done: {len(frames)} frames extracted") +print(f"Open http://mpr.local.ar/detection/?job={JOB_ID} to see the events") diff --git a/ui/detection-app/Dockerfile b/ui/detection-app/Dockerfile index cc38555..ab51a50 100644 --- a/ui/detection-app/Dockerfile +++ b/ui/detection-app/Dockerfile @@ -1,13 +1,15 @@ FROM node:20-alpine -WORKDIR /app +WORKDIR /ui RUN npm install -g pnpm -COPY package.json ./ -RUN pnpm install +# Copy both framework and detection-app (preserves relative link structure) +COPY framework/ ./framework/ +COPY detection-app/ ./detection-app/ -COPY . . +WORKDIR /ui/detection-app +RUN pnpm install EXPOSE 5175 diff --git a/ui/detection-app/src/App.vue b/ui/detection-app/src/App.vue index bf64bc3..a18130a 100644 --- a/ui/detection-app/src/App.vue +++ b/ui/detection-app/src/App.vue @@ -2,10 +2,10 @@ import { ref } from 'vue' import { SSEDataSource, Panel, LayoutGrid } from 'mpr-ui-framework' import 'mpr-ui-framework/src/tokens.css' -import type { LogEvent, StatsUpdate } from './types/sse-contract' +import LogPanel from './panels/LogPanel.vue' +import type { StatsUpdate } from './types/sse-contract' const jobId = ref(new URLSearchParams(window.location.search).get('job') || 'test-job') -const logs = ref([]) const stats = ref(null) const status = ref<'idle' | 'live' | 'processing' | 'error'>('idle') @@ -15,11 +15,6 @@ const source = new SSEDataSource({ eventTypes: ['graph_update', 'stats_update', 'frame_update', 'detection', 'log', 'job_complete', 'waiting'], }) -source.on('log', (e) => { - logs.value.push(e) - if (logs.value.length > 200) logs.value.shift() -}) - source.on('stats_update', (e) => { stats.value = e }) @@ -62,17 +57,7 @@ source.connect()
Waiting for stats...
- -
-
- {{ log.ts }} - {{ log.level }} - {{ log.stage }} - {{ log.msg }} -
-
Waiting for events...
-
-
+ @@ -133,25 +118,5 @@ header h1 { font-size: var(--font-size-lg); font-weight: 600; } .stat .label { display: block; color: var(--text-dim); font-size: var(--font-size-sm); margin-bottom: var(--space-1); } .stat .value { font-size: 20px; font-weight: 600; } -.log-scroll { - max-height: 500px; - overflow-y: auto; -} - -.log-line { - display: flex; - gap: var(--space-2); - padding: 2px 0; - font-size: 12px; - line-height: 1.5; -} -.log-line .ts { color: var(--text-dim); min-width: 80px; } -.log-line .level { min-width: 56px; font-weight: 600; } -.log-line .stage { color: var(--status-processing); min-width: 120px; } -.log-line.info .level { color: var(--status-live); } -.log-line.warning .level { color: var(--status-escalating); } -.log-line.error .level { color: var(--status-error); } -.log-line.debug .level { color: var(--text-dim); } - .empty { color: var(--text-dim); padding: var(--space-6); text-align: center; } diff --git a/ui/detection-app/src/panels/LogPanel.vue b/ui/detection-app/src/panels/LogPanel.vue new file mode 100644 index 0000000..f881577 --- /dev/null +++ b/ui/detection-app/src/panels/LogPanel.vue @@ -0,0 +1,30 @@ + + + diff --git a/ui/detection-app/vite.config.ts b/ui/detection-app/vite.config.ts index 735387f..566e67d 100644 --- a/ui/detection-app/vite.config.ts +++ b/ui/detection-app/vite.config.ts @@ -12,7 +12,11 @@ export default defineConfig({ }, server: { port: 5175, - allowedHosts: ['mpr.local.ar'], + allowedHosts: ['mpr.local.ar', 'k8s.mpr.local.ar'], + hmr: { + // When behind a reverse proxy, connect WebSocket to the same host the page was loaded from + clientPort: 80, + }, proxy: { '/api': { target: 'http://localhost:8702', diff --git a/ui/framework/src/index.ts b/ui/framework/src/index.ts index 1906aff..3c981c3 100644 --- a/ui/framework/src/index.ts +++ b/ui/framework/src/index.ts @@ -7,3 +7,6 @@ export { useDataSource } from './composables/useDataSource' // Components export { default as Panel } from './components/Panel.vue' export { default as LayoutGrid } from './components/LayoutGrid.vue' + +// Renderers +export { default as LogRenderer } from './renderers/LogRenderer.vue' diff --git a/ui/framework/src/renderers/LogRenderer.vue b/ui/framework/src/renderers/LogRenderer.vue new file mode 100644 index 0000000..2974909 --- /dev/null +++ b/ui/framework/src/renderers/LogRenderer.vue @@ -0,0 +1,143 @@ + + + + +