phase 3
This commit is contained in:
@@ -19,21 +19,19 @@ docker_build(
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Detection UI (Vue 3)
|
# Detection UI (Vue 3) — context is ui/ so framework link resolves
|
||||||
docker_build(
|
docker_build(
|
||||||
'mpr-detection',
|
'mpr-detection',
|
||||||
context='../ui/detection-app',
|
context='../ui',
|
||||||
dockerfile='../ui/detection-app/Dockerfile',
|
dockerfile='../ui/detection-app/Dockerfile',
|
||||||
live_update=[
|
live_update=[
|
||||||
sync('../ui/detection-app/src', '/app/src'),
|
sync('../ui/detection-app/src', '/ui/detection-app/src'),
|
||||||
sync('../ui/detection-app/index.html', '/app/index.html'),
|
sync('../ui/detection-app/index.html', '/ui/detection-app/index.html'),
|
||||||
sync('../ui/detection-app/vite.config.ts', '/app/vite.config.ts'),
|
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 ---
|
# --- Resources ---
|
||||||
|
|
||||||
k8s_resource('redis')
|
k8s_resource('redis')
|
||||||
|
|||||||
78
ctrl/k8s/base/envoy.yaml
Normal file
78
ctrl/k8s/base/envoy.yaml
Normal file
@@ -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
|
||||||
@@ -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
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
|
|||||||
@@ -10,3 +10,10 @@ resources:
|
|||||||
- fastapi.yaml
|
- fastapi.yaml
|
||||||
- detection-ui.yaml
|
- detection-ui.yaml
|
||||||
- gateway.yaml
|
- gateway.yaml
|
||||||
|
|
||||||
|
configMapGenerator:
|
||||||
|
- name: envoy-gateway-config
|
||||||
|
files:
|
||||||
|
- envoy.yaml
|
||||||
|
options:
|
||||||
|
disableNameSuffixHash: true
|
||||||
|
|||||||
@@ -31,11 +31,12 @@ ALL_EVENT_TYPES = [
|
|||||||
TERMINAL_EVENTS = [EVENT_JOB_COMPLETE]
|
TERMINAL_EVENTS = [EVENT_JOB_COMPLETE]
|
||||||
|
|
||||||
|
|
||||||
def push_detect_event(job_id: str, event_type: str, data: BaseModel) -> None:
|
def push_detect_event(job_id: str, event_type: str, data: BaseModel | dict) -> None:
|
||||||
"""Push a typed detection event to Redis."""
|
"""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(
|
push_event(
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
data=data.model_dump(mode="json"),
|
data=payload,
|
||||||
prefix=DETECT_EVENTS_PREFIX,
|
prefix=DETECT_EVENTS_PREFIX,
|
||||||
)
|
)
|
||||||
|
|||||||
0
detect/stages/__init__.py
Normal file
0
detect/stages/__init__.py
Normal file
102
detect/stages/frame_extractor.py
Normal file
102
detect/stages/frame_extractor.py
Normal file
@@ -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
|
||||||
98
tests/detect/manual/push_logs.py
Normal file
98
tests/detect/manual/push_logs.py
Normal file
@@ -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=<JOB_ID>
|
||||||
|
"""
|
||||||
|
|
||||||
|
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()
|
||||||
178
tests/detect/manual/push_pipeline.py
Normal file
178
tests/detect/manual/push_pipeline.py
Normal file
@@ -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=<JOB_ID>
|
||||||
|
"""
|
||||||
|
|
||||||
|
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()
|
||||||
77
tests/detect/test_frame_extractor.py
Normal file
77
tests/detect/test_frame_extractor.py
Normal file
@@ -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)
|
||||||
27
tests/detect/test_frame_extractor_e2e.py
Normal file
27
tests/detect/test_frame_extractor_e2e.py
Normal file
@@ -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")
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /ui
|
||||||
|
|
||||||
RUN npm install -g pnpm
|
RUN npm install -g pnpm
|
||||||
|
|
||||||
COPY package.json ./
|
# Copy both framework and detection-app (preserves relative link structure)
|
||||||
RUN pnpm install
|
COPY framework/ ./framework/
|
||||||
|
COPY detection-app/ ./detection-app/
|
||||||
|
|
||||||
COPY . .
|
WORKDIR /ui/detection-app
|
||||||
|
RUN pnpm install
|
||||||
|
|
||||||
EXPOSE 5175
|
EXPOSE 5175
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { SSEDataSource, Panel, LayoutGrid } from 'mpr-ui-framework'
|
import { SSEDataSource, Panel, LayoutGrid } from 'mpr-ui-framework'
|
||||||
import 'mpr-ui-framework/src/tokens.css'
|
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 jobId = ref(new URLSearchParams(window.location.search).get('job') || 'test-job')
|
||||||
const logs = ref<LogEvent[]>([])
|
|
||||||
const stats = ref<StatsUpdate | null>(null)
|
const stats = ref<StatsUpdate | null>(null)
|
||||||
const status = ref<'idle' | 'live' | 'processing' | 'error'>('idle')
|
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'],
|
eventTypes: ['graph_update', 'stats_update', 'frame_update', 'detection', 'log', 'job_complete', 'waiting'],
|
||||||
})
|
})
|
||||||
|
|
||||||
source.on<LogEvent>('log', (e) => {
|
|
||||||
logs.value.push(e)
|
|
||||||
if (logs.value.length > 200) logs.value.shift()
|
|
||||||
})
|
|
||||||
|
|
||||||
source.on<StatsUpdate>('stats_update', (e) => {
|
source.on<StatsUpdate>('stats_update', (e) => {
|
||||||
stats.value = e
|
stats.value = e
|
||||||
})
|
})
|
||||||
@@ -62,17 +57,7 @@ source.connect()
|
|||||||
<div v-else class="empty">Waiting for stats...</div>
|
<div v-else class="empty">Waiting for stats...</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<Panel title="Log" :status="status">
|
<LogPanel :source="source" :status="status" />
|
||||||
<div class="log-scroll">
|
|
||||||
<div v-for="(log, i) in logs" :key="i" class="log-line" :class="log.level.toLowerCase()">
|
|
||||||
<span class="ts">{{ log.ts }}</span>
|
|
||||||
<span class="level">{{ log.level }}</span>
|
|
||||||
<span class="stage">{{ log.stage }}</span>
|
|
||||||
<span class="msg">{{ log.msg }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="logs.length === 0" class="empty">Waiting for events...</div>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
</LayoutGrid>
|
</LayoutGrid>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -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 .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; }
|
.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; }
|
.empty { color: var(--text-dim); padding: var(--space-6); text-align: center; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
30
ui/detection-app/src/panels/LogPanel.vue
Normal file
30
ui/detection-app/src/panels/LogPanel.vue
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { Panel } from 'mpr-ui-framework'
|
||||||
|
import LogRenderer from 'mpr-ui-framework/src/renderers/LogRenderer.vue'
|
||||||
|
import type { LogEntry } from 'mpr-ui-framework/src/renderers/LogRenderer.vue'
|
||||||
|
import type { DataSource } from 'mpr-ui-framework'
|
||||||
|
import type { LogEvent } from '../types/sse-contract'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
source: DataSource
|
||||||
|
status?: 'idle' | 'live' | 'processing' | 'error'
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const entries = ref<LogEntry[]>([])
|
||||||
|
|
||||||
|
props.source.on<LogEvent>('log', (e) => {
|
||||||
|
entries.value.push({
|
||||||
|
level: e.level,
|
||||||
|
stage: e.stage,
|
||||||
|
msg: e.msg,
|
||||||
|
ts: e.ts,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Panel title="Log" :status="status">
|
||||||
|
<LogRenderer :entries="entries" />
|
||||||
|
</Panel>
|
||||||
|
</template>
|
||||||
@@ -12,7 +12,11 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5175,
|
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: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8702',
|
target: 'http://localhost:8702',
|
||||||
|
|||||||
@@ -7,3 +7,6 @@ export { useDataSource } from './composables/useDataSource'
|
|||||||
// Components
|
// Components
|
||||||
export { default as Panel } from './components/Panel.vue'
|
export { default as Panel } from './components/Panel.vue'
|
||||||
export { default as LayoutGrid } from './components/LayoutGrid.vue'
|
export { default as LayoutGrid } from './components/LayoutGrid.vue'
|
||||||
|
|
||||||
|
// Renderers
|
||||||
|
export { default as LogRenderer } from './renderers/LogRenderer.vue'
|
||||||
|
|||||||
143
ui/framework/src/renderers/LogRenderer.vue
Normal file
143
ui/framework/src/renderers/LogRenderer.vue
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
level: string
|
||||||
|
stage: string
|
||||||
|
msg: string
|
||||||
|
ts: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
entries: LogEntry[]
|
||||||
|
rowHeight?: number
|
||||||
|
autoScroll?: boolean
|
||||||
|
}>(), {
|
||||||
|
rowHeight: 24,
|
||||||
|
autoScroll: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const container = ref<HTMLElement | null>(null)
|
||||||
|
const scrollTop = ref(0)
|
||||||
|
const containerHeight = ref(0)
|
||||||
|
const userScrolled = ref(false)
|
||||||
|
|
||||||
|
const visibleRange = computed(() => {
|
||||||
|
const start = Math.floor(scrollTop.value / props.rowHeight)
|
||||||
|
const visible = Math.ceil(containerHeight.value / props.rowHeight) + 2
|
||||||
|
return {
|
||||||
|
start: Math.max(0, start - 1),
|
||||||
|
end: Math.min(props.entries.length, start + visible),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalHeight = computed(() => props.entries.length * props.rowHeight)
|
||||||
|
|
||||||
|
const visibleEntries = computed(() =>
|
||||||
|
props.entries.slice(visibleRange.value.start, visibleRange.value.end).map((entry, i) => ({
|
||||||
|
...entry,
|
||||||
|
index: visibleRange.value.start + i,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
function onScroll(e: Event) {
|
||||||
|
const el = e.target as HTMLElement
|
||||||
|
scrollTop.value = el.scrollTop
|
||||||
|
// If user scrolled away from bottom, pause auto-scroll
|
||||||
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < props.rowHeight * 2
|
||||||
|
userScrolled.value = !atBottom
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
if (container.value && props.autoScroll && !userScrolled.value) {
|
||||||
|
container.value.scrollTop = container.value.scrollHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.entries.length, () => {
|
||||||
|
nextTick(scrollToBottom)
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (container.value) {
|
||||||
|
containerHeight.value = container.value.clientHeight
|
||||||
|
const observer = new ResizeObserver(([entry]) => {
|
||||||
|
containerHeight.value = entry.contentRect.height
|
||||||
|
})
|
||||||
|
observer.observe(container.value)
|
||||||
|
onUnmounted(() => observer.disconnect())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const levelClass = (level: string) => level.toLowerCase()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="log-renderer" ref="container" @scroll="onScroll">
|
||||||
|
<div class="log-spacer" :style="{ height: totalHeight + 'px' }">
|
||||||
|
<div
|
||||||
|
class="log-viewport"
|
||||||
|
:style="{ transform: `translateY(${visibleRange.start * rowHeight}px)` }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="entry in visibleEntries"
|
||||||
|
:key="entry.index"
|
||||||
|
class="log-row"
|
||||||
|
:class="levelClass(entry.level)"
|
||||||
|
:style="{ height: rowHeight + 'px' }"
|
||||||
|
>
|
||||||
|
<span class="log-ts">{{ entry.ts }}</span>
|
||||||
|
<span class="log-level">{{ entry.level }}</span>
|
||||||
|
<span class="log-stage">{{ entry.stage }}</span>
|
||||||
|
<span class="log-msg">{{ entry.msg }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="entries.length === 0" class="log-empty">
|
||||||
|
Waiting for log events...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.log-renderer {
|
||||||
|
overflow-y: auto;
|
||||||
|
height: 100%;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-spacer {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-viewport {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-ts { color: var(--text-dim); min-width: 80px; flex-shrink: 0; }
|
||||||
|
.log-level { min-width: 56px; font-weight: 600; flex-shrink: 0; }
|
||||||
|
.log-stage { color: var(--status-processing); min-width: 120px; flex-shrink: 0; }
|
||||||
|
.log-msg { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
||||||
|
.log-row.info .log-level { color: var(--status-live); }
|
||||||
|
.log-row.warning .log-level { color: var(--status-escalating); }
|
||||||
|
.log-row.error .log-level { color: var(--status-error); }
|
||||||
|
.log-row.debug .log-level { color: var(--text-dim); }
|
||||||
|
|
||||||
|
.log-empty {
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: var(--space-6);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user