phase 1
This commit is contained in:
72
core/api/detect_sse.py
Normal file
72
core/api/detect_sse.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""
|
||||||
|
SSE endpoint for detection pipeline events.
|
||||||
|
|
||||||
|
Uses Redis as the event bus between pipeline workers and the SSE stream.
|
||||||
|
Mirrors chunker_sse.py but polls detect_events:{job_id}.
|
||||||
|
|
||||||
|
GET /detect/stream/{job_id} → text/event-stream
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from starlette.responses import StreamingResponse
|
||||||
|
|
||||||
|
from core.events import poll_events
|
||||||
|
from detect.events import DETECT_EVENTS_PREFIX, TERMINAL_EVENTS
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/detect", tags=["detect"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _event_generator(job_id: str) -> AsyncGenerator[str, None]:
|
||||||
|
cursor = 0
|
||||||
|
timeout = time.monotonic() + 3600 # 1 hour max (detection jobs are long)
|
||||||
|
|
||||||
|
while time.monotonic() < timeout:
|
||||||
|
events, cursor = poll_events(job_id, cursor, prefix=DETECT_EVENTS_PREFIX)
|
||||||
|
|
||||||
|
if not events:
|
||||||
|
yield f"event: waiting\ndata: {json.dumps({'job_id': job_id})}\n\n"
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for data in events:
|
||||||
|
event_type = data.pop("event", "update")
|
||||||
|
payload = {**data, "job_id": job_id}
|
||||||
|
|
||||||
|
yield f"event: {event_type}\ndata: {json.dumps(payload)}\n\n"
|
||||||
|
|
||||||
|
if event_type in TERMINAL_EVENTS:
|
||||||
|
yield f"event: done\ndata: {json.dumps({'job_id': job_id})}\n\n"
|
||||||
|
return
|
||||||
|
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
yield f"event: timeout\ndata: {json.dumps({'job_id': job_id})}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stream/{job_id}")
|
||||||
|
async def stream_detect_job(job_id: str):
|
||||||
|
"""
|
||||||
|
SSE stream for a detection pipeline job.
|
||||||
|
|
||||||
|
The UI connects via native EventSource:
|
||||||
|
const es = new EventSource('/api/detect/stream/<job_id>');
|
||||||
|
es.addEventListener('graph_update', (e) => { ... });
|
||||||
|
es.addEventListener('detection', (e) => { ... });
|
||||||
|
"""
|
||||||
|
return StreamingResponse(
|
||||||
|
_event_generator(job_id),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -24,6 +24,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from strawberry.fastapi import GraphQLRouter
|
from strawberry.fastapi import GraphQLRouter
|
||||||
|
|
||||||
from core.api.chunker_sse import router as chunker_router
|
from core.api.chunker_sse import router as chunker_router
|
||||||
|
from core.api.detect_sse import router as detect_router
|
||||||
from core.api.graphql import schema as graphql_schema
|
from core.api.graphql import schema as graphql_schema
|
||||||
|
|
||||||
CALLBACK_API_KEY = os.environ.get("CALLBACK_API_KEY", "")
|
CALLBACK_API_KEY = os.environ.get("CALLBACK_API_KEY", "")
|
||||||
@@ -52,6 +53,9 @@ app.include_router(graphql_router, prefix="/graphql")
|
|||||||
# Chunker SSE
|
# Chunker SSE
|
||||||
app.include_router(chunker_router)
|
app.include_router(chunker_router)
|
||||||
|
|
||||||
|
# Detection SSE
|
||||||
|
app.include_router(detect_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def root():
|
def root():
|
||||||
|
|||||||
@@ -17,19 +17,23 @@ def _get_redis():
|
|||||||
return redis.from_url(REDIS_URL, decode_responses=True)
|
return redis.from_url(REDIS_URL, decode_responses=True)
|
||||||
|
|
||||||
|
|
||||||
def push_event(job_id: str, event_type: str, data: dict) -> None:
|
def push_event(
|
||||||
|
job_id: str, event_type: str, data: dict, prefix: str = "chunk_events"
|
||||||
|
) -> None:
|
||||||
"""Push an event to the Redis list for a job."""
|
"""Push an event to the Redis list for a job."""
|
||||||
r = _get_redis()
|
r = _get_redis()
|
||||||
key = f"chunk_events:{job_id}"
|
key = f"{prefix}:{job_id}"
|
||||||
event = json.dumps({"event": event_type, **data})
|
event = json.dumps({"event": event_type, **data})
|
||||||
r.rpush(key, event)
|
r.rpush(key, event)
|
||||||
r.expire(key, 3600)
|
r.expire(key, 3600)
|
||||||
|
|
||||||
|
|
||||||
def poll_events(job_id: str, cursor: int = 0) -> tuple[list[dict], int]:
|
def poll_events(
|
||||||
|
job_id: str, cursor: int = 0, prefix: str = "chunk_events"
|
||||||
|
) -> tuple[list[dict], int]:
|
||||||
"""Poll new events from Redis. Returns (events, new_cursor)."""
|
"""Poll new events from Redis. Returns (events, new_cursor)."""
|
||||||
r = _get_redis()
|
r = _get_redis()
|
||||||
key = f"chunk_events:{job_id}"
|
key = f"{prefix}:{job_id}"
|
||||||
raw_events = r.lrange(key, cursor, -1)
|
raw_events = r.lrange(key, cursor, -1)
|
||||||
parsed = []
|
parsed = []
|
||||||
for raw in raw_events:
|
for raw in raw_events:
|
||||||
|
|||||||
@@ -20,6 +20,16 @@
|
|||||||
"target": "protobuf",
|
"target": "protobuf",
|
||||||
"output": "core/rpc/protos/worker.proto",
|
"output": "core/rpc/protos/worker.proto",
|
||||||
"include": ["grpc"]
|
"include": ["grpc"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target": "pydantic",
|
||||||
|
"output": "detect/sse_contract.py",
|
||||||
|
"include": ["detect_views"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target": "typescript",
|
||||||
|
"output": "ui/detection-app/src/types/sse-contract.ts",
|
||||||
|
"include": ["detect_views"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from .grpc import (
|
|||||||
from .jobs import ChunkJob, ChunkJobStatus, JobStatus, TranscodeJob
|
from .jobs import ChunkJob, ChunkJobStatus, JobStatus, TranscodeJob
|
||||||
from .media import AssetStatus, MediaAsset
|
from .media import AssetStatus, MediaAsset
|
||||||
from .presets import BUILTIN_PRESETS, TranscodePreset
|
from .presets import BUILTIN_PRESETS, TranscodePreset
|
||||||
|
from .detect import DETECT_VIEWS # noqa: F401 — discovered by modelgen generic loader
|
||||||
from .views import ChunkEvent, ChunkOutputFile, PipelineStats, WorkerEvent
|
from .views import ChunkEvent, ChunkOutputFile, PipelineStats, WorkerEvent
|
||||||
|
|
||||||
# Core domain models - generates Django, Pydantic, TypeScript
|
# Core domain models - generates Django, Pydantic, TypeScript
|
||||||
@@ -50,6 +51,7 @@ ENUMS = [AssetStatus, JobStatus, ChunkJobStatus]
|
|||||||
# View/event models - generates TypeScript for UI consumption
|
# View/event models - generates TypeScript for UI consumption
|
||||||
VIEWS = [ChunkEvent, WorkerEvent, PipelineStats, ChunkOutputFile]
|
VIEWS = [ChunkEvent, WorkerEvent, PipelineStats, ChunkOutputFile]
|
||||||
|
|
||||||
|
|
||||||
# gRPC messages - generates Proto
|
# gRPC messages - generates Proto
|
||||||
GRPC_MESSAGES = [
|
GRPC_MESSAGES = [
|
||||||
JobRequest,
|
JobRequest,
|
||||||
|
|||||||
166
core/schema/models/detect.py
Normal file
166
core/schema/models/detect.py
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
"""
|
||||||
|
Detection Pipeline Schema Definitions
|
||||||
|
|
||||||
|
Source of truth for all detection SSE events and wire-format models.
|
||||||
|
Generates: Pydantic (detect/sse_contract.py), TypeScript (ui/detection-app/src/types/sse-contract.ts)
|
||||||
|
|
||||||
|
Pipeline-internal models that never cross the wire (e.g. Frame with np.ndarray)
|
||||||
|
live in detect/models.py and are NOT generated.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List, Literal, Optional
|
||||||
|
|
||||||
|
|
||||||
|
# --- Enums as Literal unions (wire format, not Python Enum) ---
|
||||||
|
|
||||||
|
NodeStatus = Literal["idle", "processing", "completed", "error"]
|
||||||
|
DetectionSource = Literal["ocr", "local_vlm", "cloud_llm", "logo_match", "auxiliary"]
|
||||||
|
LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Nested components ---
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GraphNode:
|
||||||
|
"""A pipeline stage node."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
status: str = "idle" # NodeStatus
|
||||||
|
items_in: int = 0
|
||||||
|
items_out: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GraphEdge:
|
||||||
|
"""An edge between pipeline stages."""
|
||||||
|
|
||||||
|
source: str
|
||||||
|
target: str
|
||||||
|
throughput: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BoundingBoxEvent:
|
||||||
|
"""Bounding box in SSE event payloads."""
|
||||||
|
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
w: int
|
||||||
|
h: int
|
||||||
|
confidence: float
|
||||||
|
label: str
|
||||||
|
resolved_brand: Optional[str] = None
|
||||||
|
source: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BrandSummary:
|
||||||
|
"""Per-brand stats in the final report."""
|
||||||
|
|
||||||
|
brand: str
|
||||||
|
total_appearances: int = 0
|
||||||
|
total_screen_time: float = 0.0
|
||||||
|
avg_confidence: float = 0.0
|
||||||
|
first_seen: float = 0.0
|
||||||
|
last_seen: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# --- SSE event payloads ---
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GraphUpdate:
|
||||||
|
"""Pipeline node state transition. SSE event: graph_update"""
|
||||||
|
|
||||||
|
nodes: List[GraphNode] = field(default_factory=list)
|
||||||
|
edges: List[GraphEdge] = field(default_factory=list)
|
||||||
|
active_path: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StatsUpdate:
|
||||||
|
"""Funnel statistics snapshot. SSE event: stats_update"""
|
||||||
|
|
||||||
|
frames_extracted: int = 0
|
||||||
|
frames_after_scene_filter: int = 0
|
||||||
|
regions_detected: int = 0
|
||||||
|
regions_resolved_by_ocr: int = 0
|
||||||
|
regions_escalated_to_local_vlm: int = 0
|
||||||
|
regions_escalated_to_cloud_llm: int = 0
|
||||||
|
cloud_llm_calls: int = 0
|
||||||
|
processing_time_seconds: float = 0.0
|
||||||
|
estimated_cloud_cost_usd: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FrameUpdate:
|
||||||
|
"""Current frame being processed. SSE event: frame_update"""
|
||||||
|
|
||||||
|
frame_ref: int
|
||||||
|
timestamp: float
|
||||||
|
jpeg_b64: str
|
||||||
|
boxes: List[BoundingBoxEvent] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Detection:
|
||||||
|
"""A confirmed brand detection. SSE event: detection"""
|
||||||
|
|
||||||
|
brand: str
|
||||||
|
timestamp: float
|
||||||
|
duration: float
|
||||||
|
confidence: float
|
||||||
|
source: str # DetectionSource
|
||||||
|
content_type: str
|
||||||
|
bbox: Optional[BoundingBoxEvent] = None
|
||||||
|
frame_ref: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LogEvent:
|
||||||
|
"""Pipeline log line. SSE event: log"""
|
||||||
|
|
||||||
|
level: str # LogLevel
|
||||||
|
stage: str
|
||||||
|
msg: str
|
||||||
|
ts: str
|
||||||
|
trace_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DetectionReportSummary:
|
||||||
|
"""Final detection report summary."""
|
||||||
|
|
||||||
|
video_source: str
|
||||||
|
content_type: str
|
||||||
|
duration_seconds: float
|
||||||
|
total_detections: int = 0
|
||||||
|
brands: List[BrandSummary] = field(default_factory=list)
|
||||||
|
stats: Optional[StatsUpdate] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class JobComplete:
|
||||||
|
"""Final report when pipeline finishes. SSE event: job_complete"""
|
||||||
|
|
||||||
|
job_id: str
|
||||||
|
report: Optional[DetectionReportSummary] = None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Export lists for modelgen ---
|
||||||
|
|
||||||
|
DETECT_VIEWS = [
|
||||||
|
GraphNode,
|
||||||
|
GraphEdge,
|
||||||
|
BoundingBoxEvent,
|
||||||
|
BrandSummary,
|
||||||
|
GraphUpdate,
|
||||||
|
StatsUpdate,
|
||||||
|
FrameUpdate,
|
||||||
|
Detection,
|
||||||
|
LogEvent,
|
||||||
|
DetectionReportSummary,
|
||||||
|
JobComplete,
|
||||||
|
]
|
||||||
@@ -190,6 +190,20 @@ services:
|
|||||||
- ../ui/chunker/vite.config.ts:/app/vite.config.ts
|
- ../ui/chunker/vite.config.ts:/app/vite.config.ts
|
||||||
- ../ui/common:/common
|
- ../ui/common:/common
|
||||||
|
|
||||||
|
detection:
|
||||||
|
build:
|
||||||
|
context: ../ui/detection-app
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "5175:5175"
|
||||||
|
environment:
|
||||||
|
VITE_ALLOWED_HOSTS: ${VITE_ALLOWED_HOSTS:-}
|
||||||
|
volumes:
|
||||||
|
- ../ui/detection-app/src:/app/src
|
||||||
|
- ../ui/detection-app/vite.config.ts:/app/vite.config.ts
|
||||||
|
- ../ui/detection-app/index.html:/app/index.html
|
||||||
|
- ../ui/framework:/app/node_modules/mpr-ui-framework
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
redis-data:
|
redis-data:
|
||||||
|
|||||||
@@ -97,6 +97,11 @@
|
|||||||
<div class="card-title">Chunker</div>
|
<div class="card-title">Chunker</div>
|
||||||
<div class="card-desc">Split media into segments, pipeline visualization</div>
|
<div class="card-desc">Split media into segments, pipeline visualization</div>
|
||||||
</a>
|
</a>
|
||||||
|
<a class="card" href="/detection/">
|
||||||
|
<div class="card-icon">◎</div>
|
||||||
|
<div class="card-title">Detection</div>
|
||||||
|
<div class="card-desc">Media brand detection, realtime observability</div>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="links">
|
<div class="links">
|
||||||
<a href="/admin/">Admin</a>
|
<a href="/admin/">Admin</a>
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ http {
|
|||||||
server minio:9000;
|
server minio:9000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
upstream detection {
|
||||||
|
server detection:5175;
|
||||||
|
}
|
||||||
|
|
||||||
upstream envoy {
|
upstream envoy {
|
||||||
server envoy:8090;
|
server envoy:8090;
|
||||||
}
|
}
|
||||||
@@ -76,6 +80,35 @@ http {
|
|||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Detection UI
|
||||||
|
location /detection/ {
|
||||||
|
proxy_pass http://detection;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Vite HMR websocket (detection)
|
||||||
|
location /detection/@vite {
|
||||||
|
proxy_pass http://detection;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SSE streams — disable buffering for realtime delivery
|
||||||
|
location /api/detect/stream/ {
|
||||||
|
proxy_pass http://fastapi/detect/stream/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
chunked_transfer_encoding on;
|
||||||
|
}
|
||||||
|
|
||||||
# Chunker UI
|
# Chunker UI
|
||||||
location /chunker/ {
|
location /chunker/ {
|
||||||
proxy_pass http://chunker;
|
proxy_pass http://chunker;
|
||||||
|
|||||||
21
ctrl/run.sh
21
ctrl/run.sh
@@ -3,14 +3,15 @@
|
|||||||
# Usage: ./run.sh [OPTIONS] [docker-compose args]
|
# Usage: ./run.sh [OPTIONS] [docker-compose args]
|
||||||
#
|
#
|
||||||
# Options:
|
# Options:
|
||||||
# -f, --foreground Run in foreground (don't detach)
|
# -d, --detach Run detached (background)
|
||||||
# --build Rebuild images before starting
|
# --build Rebuild images before starting
|
||||||
|
# stop Stop all services
|
||||||
#
|
#
|
||||||
# Examples:
|
# Examples:
|
||||||
# ./run.sh # Start detached
|
# ./run.sh # Start attached (see logs)
|
||||||
# ./run.sh -f # Start in foreground (see logs)
|
# ./run.sh -d # Start detached
|
||||||
# ./run.sh --build # Rebuild and start
|
# ./run.sh --build # Rebuild and start
|
||||||
# ./run.sh logs -f # Follow logs
|
# ./run.sh stop # Stop all services
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -35,19 +36,23 @@ if ! grep -q "mpr.local.ar" /etc/hosts 2>/dev/null; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Parse options
|
# Parse options
|
||||||
DETACH="-d"
|
DETACH=""
|
||||||
BUILD=""
|
BUILD=""
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
-f|--foreground)
|
-d|--detach)
|
||||||
DETACH=""
|
DETACH="-d"
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
--build)
|
--build)
|
||||||
BUILD="--build"
|
BUILD="--build"
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
stop)
|
||||||
|
docker compose down
|
||||||
|
exit $?
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
# Pass remaining args to docker compose
|
# Pass remaining args to docker compose
|
||||||
docker compose "$@"
|
docker compose "$@"
|
||||||
@@ -56,5 +61,5 @@ while [[ $# -gt 0 ]]; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
# Default: up with options
|
# Default: up attached
|
||||||
docker compose up $DETACH $BUILD
|
docker compose up $DETACH $BUILD
|
||||||
|
|||||||
0
detect/__init__.py
Normal file
0
detect/__init__.py
Normal file
41
detect/events.py
Normal file
41
detect/events.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""
|
||||||
|
Detection pipeline event helpers.
|
||||||
|
|
||||||
|
Non-generated runtime code for pushing SSE events.
|
||||||
|
The event payload types are in sse_contract.py (generated by modelgen).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from core.events import push_event
|
||||||
|
|
||||||
|
DETECT_EVENTS_PREFIX = "detect_events"
|
||||||
|
|
||||||
|
# SSE event type names
|
||||||
|
EVENT_GRAPH_UPDATE = "graph_update"
|
||||||
|
EVENT_STATS_UPDATE = "stats_update"
|
||||||
|
EVENT_FRAME_UPDATE = "frame_update"
|
||||||
|
EVENT_DETECTION = "detection"
|
||||||
|
EVENT_LOG = "log"
|
||||||
|
EVENT_JOB_COMPLETE = "job_complete"
|
||||||
|
|
||||||
|
ALL_EVENT_TYPES = [
|
||||||
|
EVENT_GRAPH_UPDATE,
|
||||||
|
EVENT_STATS_UPDATE,
|
||||||
|
EVENT_FRAME_UPDATE,
|
||||||
|
EVENT_DETECTION,
|
||||||
|
EVENT_LOG,
|
||||||
|
EVENT_JOB_COMPLETE,
|
||||||
|
]
|
||||||
|
|
||||||
|
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."""
|
||||||
|
push_event(
|
||||||
|
job_id=job_id,
|
||||||
|
event_type=event_type,
|
||||||
|
data=data.model_dump(mode="json"),
|
||||||
|
prefix=DETECT_EVENTS_PREFIX,
|
||||||
|
)
|
||||||
86
detect/models.py
Normal file
86
detect/models.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""
|
||||||
|
Core domain models for the detection pipeline.
|
||||||
|
|
||||||
|
These are pipeline-internal models — the data structures that flow
|
||||||
|
between LangGraph nodes. SSE event payloads (sse_contract.py) are
|
||||||
|
derived from these when emitting to the UI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Frame:
|
||||||
|
sequence: int
|
||||||
|
chunk_id: int
|
||||||
|
timestamp: float # position in video (seconds)
|
||||||
|
image: np.ndarray
|
||||||
|
perceptual_hash: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BoundingBox:
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
w: int
|
||||||
|
h: int
|
||||||
|
confidence: float
|
||||||
|
label: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TextCandidate:
|
||||||
|
frame: Frame
|
||||||
|
bbox: BoundingBox
|
||||||
|
text: str
|
||||||
|
ocr_confidence: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BrandDetection:
|
||||||
|
brand: str
|
||||||
|
timestamp: float
|
||||||
|
duration: float
|
||||||
|
confidence: float
|
||||||
|
source: Literal["ocr", "local_vlm", "cloud_llm", "logo_match", "auxiliary"]
|
||||||
|
bbox: BoundingBox | None = None
|
||||||
|
frame_ref: int | None = None
|
||||||
|
content_type: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BrandStats:
|
||||||
|
total_appearances: int = 0
|
||||||
|
total_screen_time: float = 0.0
|
||||||
|
avg_confidence: float = 0.0
|
||||||
|
first_seen: float = 0.0
|
||||||
|
last_seen: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PipelineStats:
|
||||||
|
frames_extracted: int = 0
|
||||||
|
frames_after_scene_filter: int = 0
|
||||||
|
regions_detected: int = 0
|
||||||
|
regions_resolved_by_ocr: int = 0
|
||||||
|
regions_escalated_to_local_vlm: int = 0
|
||||||
|
regions_escalated_to_cloud_llm: int = 0
|
||||||
|
auxiliary_detections: int = 0
|
||||||
|
cloud_llm_calls: int = 0
|
||||||
|
processing_time_seconds: float = 0.0
|
||||||
|
estimated_cloud_cost_usd: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DetectionReport:
|
||||||
|
video_source: str
|
||||||
|
content_type: str
|
||||||
|
duration_seconds: float
|
||||||
|
brands: dict[str, BrandStats] = field(default_factory=dict)
|
||||||
|
timeline: list[BrandDetection] = field(default_factory=list)
|
||||||
|
pipeline_stats: PipelineStats = field(default_factory=PipelineStats)
|
||||||
103
detect/sse_contract.py
Normal file
103
detect/sse_contract.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"""
|
||||||
|
Pydantic Models - GENERATED FILE
|
||||||
|
|
||||||
|
Do not edit directly. Regenerate using modelgen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
class GraphNode(BaseModel):
|
||||||
|
"""A pipeline stage node."""
|
||||||
|
id: str
|
||||||
|
status: str = "idle"
|
||||||
|
items_in: int = 0
|
||||||
|
items_out: int = 0
|
||||||
|
|
||||||
|
class GraphEdge(BaseModel):
|
||||||
|
"""An edge between pipeline stages."""
|
||||||
|
source: str
|
||||||
|
target: str
|
||||||
|
throughput: int = 0
|
||||||
|
|
||||||
|
class BoundingBoxEvent(BaseModel):
|
||||||
|
"""Bounding box in SSE event payloads."""
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
w: int
|
||||||
|
h: int
|
||||||
|
confidence: float
|
||||||
|
label: str
|
||||||
|
resolved_brand: Optional[str] = None
|
||||||
|
source: Optional[str] = None
|
||||||
|
|
||||||
|
class BrandSummary(BaseModel):
|
||||||
|
"""Per-brand stats in the final report."""
|
||||||
|
brand: str
|
||||||
|
total_appearances: int = 0
|
||||||
|
total_screen_time: float = 0.0
|
||||||
|
avg_confidence: float = 0.0
|
||||||
|
first_seen: float = 0.0
|
||||||
|
last_seen: float = 0.0
|
||||||
|
|
||||||
|
class GraphUpdate(BaseModel):
|
||||||
|
"""Pipeline node state transition. SSE event: graph_update"""
|
||||||
|
nodes: List[GraphNode] = Field(default_factory=list)
|
||||||
|
edges: List[GraphEdge] = Field(default_factory=list)
|
||||||
|
active_path: List[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
class StatsUpdate(BaseModel):
|
||||||
|
"""Funnel statistics snapshot. SSE event: stats_update"""
|
||||||
|
frames_extracted: int = 0
|
||||||
|
frames_after_scene_filter: int = 0
|
||||||
|
regions_detected: int = 0
|
||||||
|
regions_resolved_by_ocr: int = 0
|
||||||
|
regions_escalated_to_local_vlm: int = 0
|
||||||
|
regions_escalated_to_cloud_llm: int = 0
|
||||||
|
cloud_llm_calls: int = 0
|
||||||
|
processing_time_seconds: float = 0.0
|
||||||
|
estimated_cloud_cost_usd: float = 0.0
|
||||||
|
|
||||||
|
class FrameUpdate(BaseModel):
|
||||||
|
"""Current frame being processed. SSE event: frame_update"""
|
||||||
|
frame_ref: int
|
||||||
|
timestamp: float
|
||||||
|
jpeg_b64: str
|
||||||
|
boxes: List[BoundingBoxEvent] = Field(default_factory=list)
|
||||||
|
|
||||||
|
class Detection(BaseModel):
|
||||||
|
"""A confirmed brand detection. SSE event: detection"""
|
||||||
|
brand: str
|
||||||
|
timestamp: float
|
||||||
|
duration: float
|
||||||
|
confidence: float
|
||||||
|
source: str
|
||||||
|
content_type: str
|
||||||
|
bbox: Optional[BoundingBoxEvent] = None
|
||||||
|
frame_ref: Optional[int] = None
|
||||||
|
|
||||||
|
class LogEvent(BaseModel):
|
||||||
|
"""Pipeline log line. SSE event: log"""
|
||||||
|
level: str
|
||||||
|
stage: str
|
||||||
|
msg: str
|
||||||
|
ts: str
|
||||||
|
trace_id: Optional[str] = None
|
||||||
|
|
||||||
|
class DetectionReportSummary(BaseModel):
|
||||||
|
"""Final detection report summary."""
|
||||||
|
video_source: str
|
||||||
|
content_type: str
|
||||||
|
duration_seconds: float
|
||||||
|
total_detections: int = 0
|
||||||
|
brands: List[BrandSummary] = Field(default_factory=list)
|
||||||
|
stats: Optional[StatsUpdate] = None
|
||||||
|
|
||||||
|
class JobComplete(BaseModel):
|
||||||
|
"""Final report when pipeline finishes. SSE event: job_complete"""
|
||||||
|
job_id: str
|
||||||
|
report: Optional[DetectionReportSummary] = None
|
||||||
@@ -12,7 +12,7 @@ from enum import Enum
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, List, get_type_hints
|
from typing import Any, List, get_type_hints
|
||||||
|
|
||||||
from ..helpers import get_origin_name, get_type_name, unwrap_optional
|
from ..helpers import get_origin_name, get_type_name, is_dataclass_type, unwrap_optional
|
||||||
from ..loader.schema import EnumDefinition, FieldDefinition, ModelDefinition
|
from ..loader.schema import EnumDefinition, FieldDefinition, ModelDefinition
|
||||||
from ..types import PYDANTIC_RESOLVERS
|
from ..types import PYDANTIC_RESOLVERS
|
||||||
from .base import BaseGenerator
|
from .base import BaseGenerator
|
||||||
@@ -54,8 +54,9 @@ class PydanticGenerator(BaseGenerator):
|
|||||||
if hasattr(models, "get_shared_component"):
|
if hasattr(models, "get_shared_component"):
|
||||||
content = self._generate_from_config(models)
|
content = self._generate_from_config(models)
|
||||||
elif hasattr(models, "models"):
|
elif hasattr(models, "models"):
|
||||||
|
all_models = models.models + getattr(models, "api_models", [])
|
||||||
content = self._generate_from_definitions(
|
content = self._generate_from_definitions(
|
||||||
models.models, getattr(models, "enums", [])
|
all_models, getattr(models, "enums", [])
|
||||||
)
|
)
|
||||||
elif isinstance(models, tuple):
|
elif isinstance(models, tuple):
|
||||||
content = self._generate_from_definitions(models[0], models[1])
|
content = self._generate_from_definitions(models[0], models[1])
|
||||||
@@ -307,6 +308,11 @@ class PydanticGenerator(BaseGenerator):
|
|||||||
if isinstance(base, type) and issubclass(base, Enum)
|
if isinstance(base, type) and issubclass(base, Enum)
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
or (
|
||||||
|
PYDANTIC_RESOLVERS["dataclass"]
|
||||||
|
if is_dataclass_type(base)
|
||||||
|
else None
|
||||||
|
)
|
||||||
)
|
)
|
||||||
result = resolver(base) if resolver else "str"
|
result = resolver(base) if resolver else "str"
|
||||||
return f"Optional[{result}]" if optional else result
|
return f"Optional[{result}]" if optional else result
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from enum import Enum
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, List, get_type_hints
|
from typing import Any, List, get_type_hints
|
||||||
|
|
||||||
from ..helpers import get_origin_name, get_type_name, unwrap_optional
|
from ..helpers import get_origin_name, get_type_name, is_dataclass_type, unwrap_optional
|
||||||
from ..loader.schema import EnumDefinition, FieldDefinition, ModelDefinition
|
from ..loader.schema import EnumDefinition, FieldDefinition, ModelDefinition
|
||||||
from ..types import TS_RESOLVERS
|
from ..types import TS_RESOLVERS
|
||||||
from .base import BaseGenerator
|
from .base import BaseGenerator
|
||||||
@@ -139,6 +139,11 @@ class TypeScriptGenerator(BaseGenerator):
|
|||||||
if isinstance(base, type) and issubclass(base, Enum)
|
if isinstance(base, type) and issubclass(base, Enum)
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
or (
|
||||||
|
TS_RESOLVERS["dataclass"]
|
||||||
|
if is_dataclass_type(base)
|
||||||
|
else None
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
result = resolver(base) if resolver else "string"
|
result = resolver(base) if resolver else "string"
|
||||||
|
|||||||
@@ -44,6 +44,17 @@ def get_list_inner(type_hint: Any) -> str:
|
|||||||
return "str"
|
return "str"
|
||||||
|
|
||||||
|
|
||||||
|
def is_dataclass_type(type_hint: Any) -> bool:
|
||||||
|
"""Check if type is a dataclass (nested model reference)."""
|
||||||
|
return isinstance(type_hint, type) and dc.is_dataclass(type_hint)
|
||||||
|
|
||||||
|
|
||||||
|
def get_list_inner_type(type_hint: Any) -> Any:
|
||||||
|
"""Get the raw inner type of List[T] (not stringified)."""
|
||||||
|
args = get_args(type_hint)
|
||||||
|
return args[0] if args else None
|
||||||
|
|
||||||
|
|
||||||
def get_field_default(field: dc.Field) -> Any:
|
def get_field_default(field: dc.Field) -> Any:
|
||||||
"""Get default value from dataclass field."""
|
"""Get default value from dataclass field."""
|
||||||
if field.default is not dc.MISSING:
|
if field.default is not dc.MISSING:
|
||||||
|
|||||||
@@ -123,6 +123,20 @@ class SchemaLoader:
|
|||||||
methods=grpc_service.get("methods", []),
|
methods=grpc_service.get("methods", []),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Generic group loader: any include group not handled above
|
||||||
|
# is looked up as UPPER_CASE attribute on the module.
|
||||||
|
# e.g. include "detect_views" → module.DETECT_VIEWS
|
||||||
|
if include:
|
||||||
|
known_groups = {"dataclasses", "enums", "api", "views", "grpc"}
|
||||||
|
for group in include - known_groups:
|
||||||
|
attr_name = group.upper()
|
||||||
|
items = getattr(module, attr_name, [])
|
||||||
|
for cls in items:
|
||||||
|
if isinstance(cls, type) and dc.is_dataclass(cls):
|
||||||
|
self.api_models.append(self._parse_dataclass(cls))
|
||||||
|
elif isinstance(cls, type) and issubclass(cls, Enum):
|
||||||
|
self.enums.append(self._parse_enum(cls))
|
||||||
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def _import_module(self, path: Path):
|
def _import_module(self, path: Path):
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Type mappings for each output format.
|
|||||||
Used by generators to convert Python types to target framework types.
|
Used by generators to convert Python types to target framework types.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import dataclasses as dc
|
||||||
from typing import Any, Callable, get_args
|
from typing import Any, Callable, get_args
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -39,8 +40,12 @@ DJANGO_SPECIAL: dict[str, str] = {
|
|||||||
def _get_list_inner(type_hint: Any) -> str:
|
def _get_list_inner(type_hint: Any) -> str:
|
||||||
"""Get inner type of List[T] for Pydantic."""
|
"""Get inner type of List[T] for Pydantic."""
|
||||||
args = get_args(type_hint)
|
args = get_args(type_hint)
|
||||||
if args and args[0] in (str, int, float, bool):
|
if args:
|
||||||
return {str: "str", int: "int", float: "float", bool: "bool"}[args[0]]
|
inner = args[0]
|
||||||
|
if inner in (str, int, float, bool):
|
||||||
|
return {str: "str", int: "int", float: "float", bool: "bool"}[inner]
|
||||||
|
if isinstance(inner, type) and dc.is_dataclass(inner):
|
||||||
|
return inner.__name__
|
||||||
return "str"
|
return "str"
|
||||||
|
|
||||||
|
|
||||||
@@ -54,6 +59,7 @@ PYDANTIC_RESOLVERS: dict[Any, Callable[[Any], str]] = {
|
|||||||
"dict": lambda _: "Dict[str, Any]",
|
"dict": lambda _: "Dict[str, Any]",
|
||||||
"list": lambda base: f"List[{_get_list_inner(base)}]",
|
"list": lambda base: f"List[{_get_list_inner(base)}]",
|
||||||
"enum": lambda base: base.__name__,
|
"enum": lambda base: base.__name__,
|
||||||
|
"dataclass": lambda base: base.__name__,
|
||||||
}
|
}
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -72,6 +78,8 @@ def _resolve_ts_list(base: Any) -> str:
|
|||||||
return "number[]"
|
return "number[]"
|
||||||
elif inner is bool:
|
elif inner is bool:
|
||||||
return "boolean[]"
|
return "boolean[]"
|
||||||
|
elif isinstance(inner, type) and dc.is_dataclass(inner):
|
||||||
|
return f"{inner.__name__}[]"
|
||||||
return "string[]"
|
return "string[]"
|
||||||
|
|
||||||
|
|
||||||
@@ -85,6 +93,7 @@ TS_RESOLVERS: dict[Any, Callable[[Any], str]] = {
|
|||||||
"dict": lambda _: "Record<string, unknown>",
|
"dict": lambda _: "Record<string, unknown>",
|
||||||
"list": _resolve_ts_list,
|
"list": _resolve_ts_list,
|
||||||
"enum": lambda base: base.__name__,
|
"enum": lambda base: base.__name__,
|
||||||
|
"dataclass": lambda base: base.__name__,
|
||||||
}
|
}
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
0
tests/detect/__init__.py
Normal file
0
tests/detect/__init__.py
Normal file
114
tests/detect/test_sse_contract.py
Normal file
114
tests/detect/test_sse_contract.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"""Round-trip serialization tests for SSE contract models."""
|
||||||
|
|
||||||
|
from detect.sse_contract import (
|
||||||
|
BoundingBoxEvent,
|
||||||
|
BrandSummary,
|
||||||
|
Detection,
|
||||||
|
DetectionReportSummary,
|
||||||
|
FrameUpdate,
|
||||||
|
GraphEdge,
|
||||||
|
GraphNode,
|
||||||
|
GraphUpdate,
|
||||||
|
JobComplete,
|
||||||
|
LogEvent,
|
||||||
|
StatsUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_graph_update_roundtrip():
|
||||||
|
obj = GraphUpdate(
|
||||||
|
nodes=[
|
||||||
|
GraphNode(id="extract_frames", status="completed", items_in=100, items_out=100),
|
||||||
|
GraphNode(id="filter_scenes", status="processing", items_in=100, items_out=0),
|
||||||
|
],
|
||||||
|
edges=[GraphEdge(source="extract_frames", target="filter_scenes", throughput=50)],
|
||||||
|
active_path=["filter_scenes"],
|
||||||
|
)
|
||||||
|
data = obj.model_dump(mode="json")
|
||||||
|
restored = GraphUpdate.model_validate(data)
|
||||||
|
assert restored == obj
|
||||||
|
|
||||||
|
|
||||||
|
def test_stats_update_defaults():
|
||||||
|
obj = StatsUpdate()
|
||||||
|
data = obj.model_dump(mode="json")
|
||||||
|
assert data["frames_extracted"] == 0
|
||||||
|
assert data["estimated_cloud_cost_usd"] == 0.0
|
||||||
|
restored = StatsUpdate.model_validate(data)
|
||||||
|
assert restored == obj
|
||||||
|
|
||||||
|
|
||||||
|
def test_frame_update_with_boxes():
|
||||||
|
obj = FrameUpdate(
|
||||||
|
frame_ref=42,
|
||||||
|
timestamp=71.5,
|
||||||
|
jpeg_b64="abc123==",
|
||||||
|
boxes=[
|
||||||
|
BoundingBoxEvent(x=10, y=20, w=100, h=50, confidence=0.91, label="text_region",
|
||||||
|
resolved_brand="Adidas", source="ocr"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
data = obj.model_dump(mode="json")
|
||||||
|
restored = FrameUpdate.model_validate(data)
|
||||||
|
assert restored.boxes[0].resolved_brand == "Adidas"
|
||||||
|
|
||||||
|
|
||||||
|
def test_detection():
|
||||||
|
obj = Detection(
|
||||||
|
brand="Emirates",
|
||||||
|
timestamp=68.5,
|
||||||
|
duration=3.0,
|
||||||
|
confidence=0.93,
|
||||||
|
source="ocr",
|
||||||
|
content_type="soccer_broadcast",
|
||||||
|
)
|
||||||
|
data = obj.model_dump(mode="json")
|
||||||
|
assert data["source"] == "ocr"
|
||||||
|
assert data["bbox"] is None
|
||||||
|
restored = Detection.model_validate(data)
|
||||||
|
assert restored == obj
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_event():
|
||||||
|
obj = LogEvent(
|
||||||
|
level="INFO",
|
||||||
|
stage="BrandResolver",
|
||||||
|
msg="Exact match: Emirates",
|
||||||
|
trace_id="lf-abc123",
|
||||||
|
ts="2024-03-01T12:00:01Z",
|
||||||
|
)
|
||||||
|
data = obj.model_dump(mode="json")
|
||||||
|
restored = LogEvent.model_validate(data)
|
||||||
|
assert restored.trace_id == "lf-abc123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_complete():
|
||||||
|
obj = JobComplete(
|
||||||
|
job_id="test-123",
|
||||||
|
report=DetectionReportSummary(
|
||||||
|
video_source="match.mp4",
|
||||||
|
content_type="soccer_broadcast",
|
||||||
|
duration_seconds=5400.0,
|
||||||
|
total_detections=142,
|
||||||
|
brands=[
|
||||||
|
BrandSummary(
|
||||||
|
brand="Adidas",
|
||||||
|
total_appearances=45,
|
||||||
|
total_screen_time=120.5,
|
||||||
|
avg_confidence=0.89,
|
||||||
|
first_seen=12.0,
|
||||||
|
last_seen=5380.0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
stats=StatsUpdate(
|
||||||
|
frames_extracted=10800,
|
||||||
|
frames_after_scene_filter=3200,
|
||||||
|
regions_detected=1500,
|
||||||
|
regions_resolved_by_ocr=1200,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
data = obj.model_dump(mode="json")
|
||||||
|
restored = JobComplete.model_validate(data)
|
||||||
|
assert restored.report.brands[0].brand == "Adidas"
|
||||||
|
assert restored.report.stats.frames_extracted == 10800
|
||||||
14
ui/detection-app/Dockerfile
Normal file
14
ui/detection-app/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN npm install -g pnpm
|
||||||
|
|
||||||
|
COPY package.json ./
|
||||||
|
RUN pnpm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 5175
|
||||||
|
|
||||||
|
CMD ["pnpm", "dev", "--host"]
|
||||||
12
ui/detection-app/index.html
Normal file
12
ui/detection-app/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>MPR Detection</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
23
ui/detection-app/package.json
Normal file
23
ui/detection-app/package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "mpr-detection-app",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "vue-tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5",
|
||||||
|
"pinia": "^2.2",
|
||||||
|
"mpr-ui-framework": "link:../framework"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.6",
|
||||||
|
"vite": "^6",
|
||||||
|
"@vitejs/plugin-vue": "^5",
|
||||||
|
"vue-tsc": "^2"
|
||||||
|
}
|
||||||
|
}
|
||||||
990
ui/detection-app/pnpm-lock.yaml
generated
Normal file
990
ui/detection-app/pnpm-lock.yaml
generated
Normal file
@@ -0,0 +1,990 @@
|
|||||||
|
lockfileVersion: '9.0'
|
||||||
|
|
||||||
|
settings:
|
||||||
|
autoInstallPeers: true
|
||||||
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
importers:
|
||||||
|
|
||||||
|
.:
|
||||||
|
dependencies:
|
||||||
|
mpr-ui-framework:
|
||||||
|
specifier: link:../framework
|
||||||
|
version: link:../framework
|
||||||
|
pinia:
|
||||||
|
specifier: ^2.2
|
||||||
|
version: 2.3.1(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))
|
||||||
|
vue:
|
||||||
|
specifier: ^3.5
|
||||||
|
version: 3.5.30(typescript@5.9.3)
|
||||||
|
devDependencies:
|
||||||
|
'@vitejs/plugin-vue':
|
||||||
|
specifier: ^5
|
||||||
|
version: 5.2.4(vite@6.4.1)(vue@3.5.30(typescript@5.9.3))
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.6
|
||||||
|
version: 5.9.3
|
||||||
|
vite:
|
||||||
|
specifier: ^6
|
||||||
|
version: 6.4.1
|
||||||
|
vue-tsc:
|
||||||
|
specifier: ^2
|
||||||
|
version: 2.2.12(typescript@5.9.3)
|
||||||
|
|
||||||
|
packages:
|
||||||
|
|
||||||
|
'@babel/helper-string-parser@7.27.1':
|
||||||
|
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/helper-validator-identifier@7.28.5':
|
||||||
|
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/parser@7.29.2':
|
||||||
|
resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==}
|
||||||
|
engines: {node: '>=6.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
'@babel/types@7.29.0':
|
||||||
|
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@esbuild/aix-ppc64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [aix]
|
||||||
|
|
||||||
|
'@esbuild/android-arm64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/android-arm@0.25.12':
|
||||||
|
resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/android-x64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/darwin-arm64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@esbuild/darwin-x64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@esbuild/freebsd-arm64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@esbuild/freebsd-x64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@esbuild/linux-arm64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-arm@0.25.12':
|
||||||
|
resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-ia32@0.25.12':
|
||||||
|
resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-loong64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [loong64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-mips64el@0.25.12':
|
||||||
|
resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [mips64el]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-ppc64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-riscv64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-s390x@0.25.12':
|
||||||
|
resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-x64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/netbsd-arm64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [netbsd]
|
||||||
|
|
||||||
|
'@esbuild/netbsd-x64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [netbsd]
|
||||||
|
|
||||||
|
'@esbuild/openbsd-arm64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openbsd]
|
||||||
|
|
||||||
|
'@esbuild/openbsd-x64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [openbsd]
|
||||||
|
|
||||||
|
'@esbuild/openharmony-arm64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openharmony]
|
||||||
|
|
||||||
|
'@esbuild/sunos-x64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [sunos]
|
||||||
|
|
||||||
|
'@esbuild/win32-arm64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@esbuild/win32-ia32@0.25.12':
|
||||||
|
resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@esbuild/win32-x64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@jridgewell/sourcemap-codec@1.5.5':
|
||||||
|
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||||
|
|
||||||
|
'@rollup/rollup-android-arm-eabi@4.60.0':
|
||||||
|
resolution: {integrity: sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@rollup/rollup-android-arm64@4.60.0':
|
||||||
|
resolution: {integrity: sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@rollup/rollup-darwin-arm64@4.60.0':
|
||||||
|
resolution: {integrity: sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@rollup/rollup-darwin-x64@4.60.0':
|
||||||
|
resolution: {integrity: sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@rollup/rollup-freebsd-arm64@4.60.0':
|
||||||
|
resolution: {integrity: sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@rollup/rollup-freebsd-x64@4.60.0':
|
||||||
|
resolution: {integrity: sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm-gnueabihf@4.60.0':
|
||||||
|
resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm-musleabihf@4.60.0':
|
||||||
|
resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm64-gnu@4.60.0':
|
||||||
|
resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm64-musl@4.60.0':
|
||||||
|
resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-loong64-gnu@4.60.0':
|
||||||
|
resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==}
|
||||||
|
cpu: [loong64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-loong64-musl@4.60.0':
|
||||||
|
resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==}
|
||||||
|
cpu: [loong64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-ppc64-gnu@4.60.0':
|
||||||
|
resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-ppc64-musl@4.60.0':
|
||||||
|
resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-riscv64-gnu@4.60.0':
|
||||||
|
resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-riscv64-musl@4.60.0':
|
||||||
|
resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-s390x-gnu@4.60.0':
|
||||||
|
resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-x64-gnu@4.60.0':
|
||||||
|
resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-x64-musl@4.60.0':
|
||||||
|
resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-openbsd-x64@4.60.0':
|
||||||
|
resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [openbsd]
|
||||||
|
|
||||||
|
'@rollup/rollup-openharmony-arm64@4.60.0':
|
||||||
|
resolution: {integrity: sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openharmony]
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-arm64-msvc@4.60.0':
|
||||||
|
resolution: {integrity: sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-ia32-msvc@4.60.0':
|
||||||
|
resolution: {integrity: sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-x64-gnu@4.60.0':
|
||||||
|
resolution: {integrity: sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-x64-msvc@4.60.0':
|
||||||
|
resolution: {integrity: sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@types/estree@1.0.8':
|
||||||
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
|
'@vitejs/plugin-vue@5.2.4':
|
||||||
|
resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
|
||||||
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
|
peerDependencies:
|
||||||
|
vite: ^5.0.0 || ^6.0.0
|
||||||
|
vue: ^3.2.25
|
||||||
|
|
||||||
|
'@volar/language-core@2.4.15':
|
||||||
|
resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==}
|
||||||
|
|
||||||
|
'@volar/source-map@2.4.15':
|
||||||
|
resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==}
|
||||||
|
|
||||||
|
'@volar/typescript@2.4.15':
|
||||||
|
resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==}
|
||||||
|
|
||||||
|
'@vue/compiler-core@3.5.30':
|
||||||
|
resolution: {integrity: sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==}
|
||||||
|
|
||||||
|
'@vue/compiler-dom@3.5.30':
|
||||||
|
resolution: {integrity: sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==}
|
||||||
|
|
||||||
|
'@vue/compiler-sfc@3.5.30':
|
||||||
|
resolution: {integrity: sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==}
|
||||||
|
|
||||||
|
'@vue/compiler-ssr@3.5.30':
|
||||||
|
resolution: {integrity: sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==}
|
||||||
|
|
||||||
|
'@vue/compiler-vue2@2.7.16':
|
||||||
|
resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==}
|
||||||
|
|
||||||
|
'@vue/devtools-api@6.6.4':
|
||||||
|
resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
|
||||||
|
|
||||||
|
'@vue/language-core@2.2.12':
|
||||||
|
resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==}
|
||||||
|
peerDependencies:
|
||||||
|
typescript: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
typescript:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@vue/reactivity@3.5.30':
|
||||||
|
resolution: {integrity: sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==}
|
||||||
|
|
||||||
|
'@vue/runtime-core@3.5.30':
|
||||||
|
resolution: {integrity: sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==}
|
||||||
|
|
||||||
|
'@vue/runtime-dom@3.5.30':
|
||||||
|
resolution: {integrity: sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==}
|
||||||
|
|
||||||
|
'@vue/server-renderer@3.5.30':
|
||||||
|
resolution: {integrity: sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==}
|
||||||
|
peerDependencies:
|
||||||
|
vue: 3.5.30
|
||||||
|
|
||||||
|
'@vue/shared@3.5.30':
|
||||||
|
resolution: {integrity: sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==}
|
||||||
|
|
||||||
|
alien-signals@1.0.13:
|
||||||
|
resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==}
|
||||||
|
|
||||||
|
balanced-match@1.0.2:
|
||||||
|
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||||
|
|
||||||
|
brace-expansion@2.0.2:
|
||||||
|
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
|
||||||
|
|
||||||
|
csstype@3.2.3:
|
||||||
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
|
de-indent@1.0.2:
|
||||||
|
resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
|
||||||
|
|
||||||
|
entities@7.0.1:
|
||||||
|
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||||
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
|
esbuild@0.25.12:
|
||||||
|
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
estree-walker@2.0.2:
|
||||||
|
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||||
|
|
||||||
|
fdir@6.5.0:
|
||||||
|
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
picomatch: ^3 || ^4
|
||||||
|
peerDependenciesMeta:
|
||||||
|
picomatch:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
fsevents@2.3.3:
|
||||||
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
he@1.2.0:
|
||||||
|
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
magic-string@0.30.21:
|
||||||
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
|
minimatch@9.0.9:
|
||||||
|
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
|
||||||
|
engines: {node: '>=16 || 14 >=14.17'}
|
||||||
|
|
||||||
|
muggle-string@0.4.1:
|
||||||
|
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
|
||||||
|
|
||||||
|
nanoid@3.3.11:
|
||||||
|
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||||
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
path-browserify@1.0.1:
|
||||||
|
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
|
||||||
|
|
||||||
|
picocolors@1.1.1:
|
||||||
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
|
picomatch@4.0.3:
|
||||||
|
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
pinia@2.3.1:
|
||||||
|
resolution: {integrity: sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==}
|
||||||
|
peerDependencies:
|
||||||
|
typescript: '>=4.4.4'
|
||||||
|
vue: ^2.7.0 || ^3.5.11
|
||||||
|
peerDependenciesMeta:
|
||||||
|
typescript:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
postcss@8.5.8:
|
||||||
|
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
|
||||||
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
|
rollup@4.60.0:
|
||||||
|
resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==}
|
||||||
|
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
source-map-js@1.2.1:
|
||||||
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
tinyglobby@0.2.15:
|
||||||
|
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
typescript@5.9.3:
|
||||||
|
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||||
|
engines: {node: '>=14.17'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
vite@6.4.1:
|
||||||
|
resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==}
|
||||||
|
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
|
||||||
|
jiti: '>=1.21.0'
|
||||||
|
less: '*'
|
||||||
|
lightningcss: ^1.21.0
|
||||||
|
sass: '*'
|
||||||
|
sass-embedded: '*'
|
||||||
|
stylus: '*'
|
||||||
|
sugarss: '*'
|
||||||
|
terser: ^5.16.0
|
||||||
|
tsx: ^4.8.1
|
||||||
|
yaml: ^2.4.2
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
jiti:
|
||||||
|
optional: true
|
||||||
|
less:
|
||||||
|
optional: true
|
||||||
|
lightningcss:
|
||||||
|
optional: true
|
||||||
|
sass:
|
||||||
|
optional: true
|
||||||
|
sass-embedded:
|
||||||
|
optional: true
|
||||||
|
stylus:
|
||||||
|
optional: true
|
||||||
|
sugarss:
|
||||||
|
optional: true
|
||||||
|
terser:
|
||||||
|
optional: true
|
||||||
|
tsx:
|
||||||
|
optional: true
|
||||||
|
yaml:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
vscode-uri@3.1.0:
|
||||||
|
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
||||||
|
|
||||||
|
vue-demi@0.14.10:
|
||||||
|
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@vue/composition-api': ^1.0.0-rc.1
|
||||||
|
vue: ^3.0.0-0 || ^2.6.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@vue/composition-api':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
vue-tsc@2.2.12:
|
||||||
|
resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
typescript: '>=5.0.0'
|
||||||
|
|
||||||
|
vue@3.5.30:
|
||||||
|
resolution: {integrity: sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==}
|
||||||
|
peerDependencies:
|
||||||
|
typescript: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
typescript:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
snapshots:
|
||||||
|
|
||||||
|
'@babel/helper-string-parser@7.27.1': {}
|
||||||
|
|
||||||
|
'@babel/helper-validator-identifier@7.28.5': {}
|
||||||
|
|
||||||
|
'@babel/parser@7.29.2':
|
||||||
|
dependencies:
|
||||||
|
'@babel/types': 7.29.0
|
||||||
|
|
||||||
|
'@babel/types@7.29.0':
|
||||||
|
dependencies:
|
||||||
|
'@babel/helper-string-parser': 7.27.1
|
||||||
|
'@babel/helper-validator-identifier': 7.28.5
|
||||||
|
|
||||||
|
'@esbuild/aix-ppc64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-arm64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-arm@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-x64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/darwin-arm64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/darwin-x64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/freebsd-arm64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/freebsd-x64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-arm64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-arm@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-ia32@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-loong64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-mips64el@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-ppc64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-riscv64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-s390x@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-x64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/netbsd-arm64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/netbsd-x64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/openbsd-arm64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/openbsd-x64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/openharmony-arm64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/sunos-x64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-arm64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-ia32@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-x64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||||
|
|
||||||
|
'@rollup/rollup-android-arm-eabi@4.60.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-android-arm64@4.60.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-darwin-arm64@4.60.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-darwin-x64@4.60.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-freebsd-arm64@4.60.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-freebsd-x64@4.60.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm-gnueabihf@4.60.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm-musleabihf@4.60.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm64-gnu@4.60.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm64-musl@4.60.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-loong64-gnu@4.60.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-loong64-musl@4.60.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-ppc64-gnu@4.60.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-ppc64-musl@4.60.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-riscv64-gnu@4.60.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-riscv64-musl@4.60.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-s390x-gnu@4.60.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-x64-gnu@4.60.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-x64-musl@4.60.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-openbsd-x64@4.60.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-openharmony-arm64@4.60.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-arm64-msvc@4.60.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-ia32-msvc@4.60.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-x64-gnu@4.60.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-x64-msvc@4.60.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
|
'@vitejs/plugin-vue@5.2.4(vite@6.4.1)(vue@3.5.30(typescript@5.9.3))':
|
||||||
|
dependencies:
|
||||||
|
vite: 6.4.1
|
||||||
|
vue: 3.5.30(typescript@5.9.3)
|
||||||
|
|
||||||
|
'@volar/language-core@2.4.15':
|
||||||
|
dependencies:
|
||||||
|
'@volar/source-map': 2.4.15
|
||||||
|
|
||||||
|
'@volar/source-map@2.4.15': {}
|
||||||
|
|
||||||
|
'@volar/typescript@2.4.15':
|
||||||
|
dependencies:
|
||||||
|
'@volar/language-core': 2.4.15
|
||||||
|
path-browserify: 1.0.1
|
||||||
|
vscode-uri: 3.1.0
|
||||||
|
|
||||||
|
'@vue/compiler-core@3.5.30':
|
||||||
|
dependencies:
|
||||||
|
'@babel/parser': 7.29.2
|
||||||
|
'@vue/shared': 3.5.30
|
||||||
|
entities: 7.0.1
|
||||||
|
estree-walker: 2.0.2
|
||||||
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
'@vue/compiler-dom@3.5.30':
|
||||||
|
dependencies:
|
||||||
|
'@vue/compiler-core': 3.5.30
|
||||||
|
'@vue/shared': 3.5.30
|
||||||
|
|
||||||
|
'@vue/compiler-sfc@3.5.30':
|
||||||
|
dependencies:
|
||||||
|
'@babel/parser': 7.29.2
|
||||||
|
'@vue/compiler-core': 3.5.30
|
||||||
|
'@vue/compiler-dom': 3.5.30
|
||||||
|
'@vue/compiler-ssr': 3.5.30
|
||||||
|
'@vue/shared': 3.5.30
|
||||||
|
estree-walker: 2.0.2
|
||||||
|
magic-string: 0.30.21
|
||||||
|
postcss: 8.5.8
|
||||||
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
'@vue/compiler-ssr@3.5.30':
|
||||||
|
dependencies:
|
||||||
|
'@vue/compiler-dom': 3.5.30
|
||||||
|
'@vue/shared': 3.5.30
|
||||||
|
|
||||||
|
'@vue/compiler-vue2@2.7.16':
|
||||||
|
dependencies:
|
||||||
|
de-indent: 1.0.2
|
||||||
|
he: 1.2.0
|
||||||
|
|
||||||
|
'@vue/devtools-api@6.6.4': {}
|
||||||
|
|
||||||
|
'@vue/language-core@2.2.12(typescript@5.9.3)':
|
||||||
|
dependencies:
|
||||||
|
'@volar/language-core': 2.4.15
|
||||||
|
'@vue/compiler-dom': 3.5.30
|
||||||
|
'@vue/compiler-vue2': 2.7.16
|
||||||
|
'@vue/shared': 3.5.30
|
||||||
|
alien-signals: 1.0.13
|
||||||
|
minimatch: 9.0.9
|
||||||
|
muggle-string: 0.4.1
|
||||||
|
path-browserify: 1.0.1
|
||||||
|
optionalDependencies:
|
||||||
|
typescript: 5.9.3
|
||||||
|
|
||||||
|
'@vue/reactivity@3.5.30':
|
||||||
|
dependencies:
|
||||||
|
'@vue/shared': 3.5.30
|
||||||
|
|
||||||
|
'@vue/runtime-core@3.5.30':
|
||||||
|
dependencies:
|
||||||
|
'@vue/reactivity': 3.5.30
|
||||||
|
'@vue/shared': 3.5.30
|
||||||
|
|
||||||
|
'@vue/runtime-dom@3.5.30':
|
||||||
|
dependencies:
|
||||||
|
'@vue/reactivity': 3.5.30
|
||||||
|
'@vue/runtime-core': 3.5.30
|
||||||
|
'@vue/shared': 3.5.30
|
||||||
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
'@vue/server-renderer@3.5.30(vue@3.5.30(typescript@5.9.3))':
|
||||||
|
dependencies:
|
||||||
|
'@vue/compiler-ssr': 3.5.30
|
||||||
|
'@vue/shared': 3.5.30
|
||||||
|
vue: 3.5.30(typescript@5.9.3)
|
||||||
|
|
||||||
|
'@vue/shared@3.5.30': {}
|
||||||
|
|
||||||
|
alien-signals@1.0.13: {}
|
||||||
|
|
||||||
|
balanced-match@1.0.2: {}
|
||||||
|
|
||||||
|
brace-expansion@2.0.2:
|
||||||
|
dependencies:
|
||||||
|
balanced-match: 1.0.2
|
||||||
|
|
||||||
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
|
de-indent@1.0.2: {}
|
||||||
|
|
||||||
|
entities@7.0.1: {}
|
||||||
|
|
||||||
|
esbuild@0.25.12:
|
||||||
|
optionalDependencies:
|
||||||
|
'@esbuild/aix-ppc64': 0.25.12
|
||||||
|
'@esbuild/android-arm': 0.25.12
|
||||||
|
'@esbuild/android-arm64': 0.25.12
|
||||||
|
'@esbuild/android-x64': 0.25.12
|
||||||
|
'@esbuild/darwin-arm64': 0.25.12
|
||||||
|
'@esbuild/darwin-x64': 0.25.12
|
||||||
|
'@esbuild/freebsd-arm64': 0.25.12
|
||||||
|
'@esbuild/freebsd-x64': 0.25.12
|
||||||
|
'@esbuild/linux-arm': 0.25.12
|
||||||
|
'@esbuild/linux-arm64': 0.25.12
|
||||||
|
'@esbuild/linux-ia32': 0.25.12
|
||||||
|
'@esbuild/linux-loong64': 0.25.12
|
||||||
|
'@esbuild/linux-mips64el': 0.25.12
|
||||||
|
'@esbuild/linux-ppc64': 0.25.12
|
||||||
|
'@esbuild/linux-riscv64': 0.25.12
|
||||||
|
'@esbuild/linux-s390x': 0.25.12
|
||||||
|
'@esbuild/linux-x64': 0.25.12
|
||||||
|
'@esbuild/netbsd-arm64': 0.25.12
|
||||||
|
'@esbuild/netbsd-x64': 0.25.12
|
||||||
|
'@esbuild/openbsd-arm64': 0.25.12
|
||||||
|
'@esbuild/openbsd-x64': 0.25.12
|
||||||
|
'@esbuild/openharmony-arm64': 0.25.12
|
||||||
|
'@esbuild/sunos-x64': 0.25.12
|
||||||
|
'@esbuild/win32-arm64': 0.25.12
|
||||||
|
'@esbuild/win32-ia32': 0.25.12
|
||||||
|
'@esbuild/win32-x64': 0.25.12
|
||||||
|
|
||||||
|
estree-walker@2.0.2: {}
|
||||||
|
|
||||||
|
fdir@6.5.0(picomatch@4.0.3):
|
||||||
|
optionalDependencies:
|
||||||
|
picomatch: 4.0.3
|
||||||
|
|
||||||
|
fsevents@2.3.3:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
he@1.2.0: {}
|
||||||
|
|
||||||
|
magic-string@0.30.21:
|
||||||
|
dependencies:
|
||||||
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
minimatch@9.0.9:
|
||||||
|
dependencies:
|
||||||
|
brace-expansion: 2.0.2
|
||||||
|
|
||||||
|
muggle-string@0.4.1: {}
|
||||||
|
|
||||||
|
nanoid@3.3.11: {}
|
||||||
|
|
||||||
|
path-browserify@1.0.1: {}
|
||||||
|
|
||||||
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
|
picomatch@4.0.3: {}
|
||||||
|
|
||||||
|
pinia@2.3.1(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)):
|
||||||
|
dependencies:
|
||||||
|
'@vue/devtools-api': 6.6.4
|
||||||
|
vue: 3.5.30(typescript@5.9.3)
|
||||||
|
vue-demi: 0.14.10(vue@3.5.30(typescript@5.9.3))
|
||||||
|
optionalDependencies:
|
||||||
|
typescript: 5.9.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@vue/composition-api'
|
||||||
|
|
||||||
|
postcss@8.5.8:
|
||||||
|
dependencies:
|
||||||
|
nanoid: 3.3.11
|
||||||
|
picocolors: 1.1.1
|
||||||
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
rollup@4.60.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/estree': 1.0.8
|
||||||
|
optionalDependencies:
|
||||||
|
'@rollup/rollup-android-arm-eabi': 4.60.0
|
||||||
|
'@rollup/rollup-android-arm64': 4.60.0
|
||||||
|
'@rollup/rollup-darwin-arm64': 4.60.0
|
||||||
|
'@rollup/rollup-darwin-x64': 4.60.0
|
||||||
|
'@rollup/rollup-freebsd-arm64': 4.60.0
|
||||||
|
'@rollup/rollup-freebsd-x64': 4.60.0
|
||||||
|
'@rollup/rollup-linux-arm-gnueabihf': 4.60.0
|
||||||
|
'@rollup/rollup-linux-arm-musleabihf': 4.60.0
|
||||||
|
'@rollup/rollup-linux-arm64-gnu': 4.60.0
|
||||||
|
'@rollup/rollup-linux-arm64-musl': 4.60.0
|
||||||
|
'@rollup/rollup-linux-loong64-gnu': 4.60.0
|
||||||
|
'@rollup/rollup-linux-loong64-musl': 4.60.0
|
||||||
|
'@rollup/rollup-linux-ppc64-gnu': 4.60.0
|
||||||
|
'@rollup/rollup-linux-ppc64-musl': 4.60.0
|
||||||
|
'@rollup/rollup-linux-riscv64-gnu': 4.60.0
|
||||||
|
'@rollup/rollup-linux-riscv64-musl': 4.60.0
|
||||||
|
'@rollup/rollup-linux-s390x-gnu': 4.60.0
|
||||||
|
'@rollup/rollup-linux-x64-gnu': 4.60.0
|
||||||
|
'@rollup/rollup-linux-x64-musl': 4.60.0
|
||||||
|
'@rollup/rollup-openbsd-x64': 4.60.0
|
||||||
|
'@rollup/rollup-openharmony-arm64': 4.60.0
|
||||||
|
'@rollup/rollup-win32-arm64-msvc': 4.60.0
|
||||||
|
'@rollup/rollup-win32-ia32-msvc': 4.60.0
|
||||||
|
'@rollup/rollup-win32-x64-gnu': 4.60.0
|
||||||
|
'@rollup/rollup-win32-x64-msvc': 4.60.0
|
||||||
|
fsevents: 2.3.3
|
||||||
|
|
||||||
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
|
tinyglobby@0.2.15:
|
||||||
|
dependencies:
|
||||||
|
fdir: 6.5.0(picomatch@4.0.3)
|
||||||
|
picomatch: 4.0.3
|
||||||
|
|
||||||
|
typescript@5.9.3: {}
|
||||||
|
|
||||||
|
vite@6.4.1:
|
||||||
|
dependencies:
|
||||||
|
esbuild: 0.25.12
|
||||||
|
fdir: 6.5.0(picomatch@4.0.3)
|
||||||
|
picomatch: 4.0.3
|
||||||
|
postcss: 8.5.8
|
||||||
|
rollup: 4.60.0
|
||||||
|
tinyglobby: 0.2.15
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents: 2.3.3
|
||||||
|
|
||||||
|
vscode-uri@3.1.0: {}
|
||||||
|
|
||||||
|
vue-demi@0.14.10(vue@3.5.30(typescript@5.9.3)):
|
||||||
|
dependencies:
|
||||||
|
vue: 3.5.30(typescript@5.9.3)
|
||||||
|
|
||||||
|
vue-tsc@2.2.12(typescript@5.9.3):
|
||||||
|
dependencies:
|
||||||
|
'@volar/typescript': 2.4.15
|
||||||
|
'@vue/language-core': 2.2.12(typescript@5.9.3)
|
||||||
|
typescript: 5.9.3
|
||||||
|
|
||||||
|
vue@3.5.30(typescript@5.9.3):
|
||||||
|
dependencies:
|
||||||
|
'@vue/compiler-dom': 3.5.30
|
||||||
|
'@vue/compiler-sfc': 3.5.30
|
||||||
|
'@vue/runtime-dom': 3.5.30
|
||||||
|
'@vue/server-renderer': 3.5.30(vue@3.5.30(typescript@5.9.3))
|
||||||
|
'@vue/shared': 3.5.30
|
||||||
|
optionalDependencies:
|
||||||
|
typescript: 5.9.3
|
||||||
178
ui/detection-app/src/App.vue
Normal file
178
ui/detection-app/src/App.vue
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { SSEDataSource } from 'mpr-ui-framework'
|
||||||
|
import type { LogEvent, StatsUpdate } from './types/sse-contract'
|
||||||
|
|
||||||
|
const jobId = ref(new URLSearchParams(window.location.search).get('job') || 'test-job')
|
||||||
|
const logs = ref<LogEvent[]>([])
|
||||||
|
const stats = ref<StatsUpdate | null>(null)
|
||||||
|
const status = ref('idle')
|
||||||
|
|
||||||
|
const source = new SSEDataSource({
|
||||||
|
id: 'detect-stream',
|
||||||
|
url: `/api/detect/stream/${jobId.value}`,
|
||||||
|
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) => {
|
||||||
|
stats.value = e
|
||||||
|
})
|
||||||
|
|
||||||
|
// Expose status reactively
|
||||||
|
const checkStatus = () => { status.value = source.status.value }
|
||||||
|
setInterval(checkStatus, 500)
|
||||||
|
|
||||||
|
source.connect()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="app">
|
||||||
|
<header>
|
||||||
|
<h1>Detection Pipeline</h1>
|
||||||
|
<span class="status" :class="status">{{ status }}</span>
|
||||||
|
<span class="job-id">job: {{ jobId }}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="stats" v-if="stats">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="label">Frames</span>
|
||||||
|
<span class="value">{{ stats.frames_extracted }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="label">After filter</span>
|
||||||
|
<span class="value">{{ stats.frames_after_scene_filter }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="label">Regions</span>
|
||||||
|
<span class="value">{{ stats.regions_detected }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="label">OCR resolved</span>
|
||||||
|
<span class="value">{{ stats.regions_resolved_by_ocr }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="label">Cloud calls</span>
|
||||||
|
<span class="value">{{ stats.cloud_llm_calls }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="label">Cost</span>
|
||||||
|
<span class="value">${{ stats.estimated_cloud_cost_usd.toFixed(4) }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="logs">
|
||||||
|
<h2>Log</h2>
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0d0d0f;
|
||||||
|
--surface: #16161a;
|
||||||
|
--border: #2e2e38;
|
||||||
|
--text: #e8e8f0;
|
||||||
|
--dim: #555568;
|
||||||
|
--green: #3ecf8e;
|
||||||
|
--blue: #4f9cf9;
|
||||||
|
--amber: #f5a623;
|
||||||
|
--red: #f06565;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 { font-size: 15px; font-weight: 600; }
|
||||||
|
|
||||||
|
.status {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.status.idle { background: var(--dim); }
|
||||||
|
.status.connecting { background: var(--blue); color: #000; }
|
||||||
|
.status.live { background: var(--green); color: #000; }
|
||||||
|
.status.error { background: var(--red); color: #000; }
|
||||||
|
|
||||||
|
.job-id { color: var(--dim); font-size: 11px; margin-left: auto; }
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.stat .label { display: block; color: var(--dim); font-size: 11px; margin-bottom: 4px; }
|
||||||
|
.stat .value { font-size: 20px; font-weight: 600; }
|
||||||
|
|
||||||
|
.logs h2 { font-size: 13px; margin-bottom: 8px; color: var(--dim); }
|
||||||
|
|
||||||
|
.log-scroll {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
max-height: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-line {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 2px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.log-line .ts { color: var(--dim); min-width: 80px; }
|
||||||
|
.log-line .level { min-width: 56px; font-weight: 600; }
|
||||||
|
.log-line .stage { color: var(--blue); min-width: 120px; }
|
||||||
|
.log-line.info .level { color: var(--green); }
|
||||||
|
.log-line.warning .level { color: var(--amber); }
|
||||||
|
.log-line.error .level { color: var(--red); }
|
||||||
|
.log-line.debug .level { color: var(--dim); }
|
||||||
|
|
||||||
|
.empty { color: var(--dim); padding: 20px; text-align: center; }
|
||||||
|
</style>
|
||||||
4
ui/detection-app/src/main.ts
Normal file
4
ui/detection-app/src/main.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
97
ui/detection-app/src/types/sse-contract.ts
Normal file
97
ui/detection-app/src/types/sse-contract.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* TypeScript Types - GENERATED FILE
|
||||||
|
*
|
||||||
|
* Do not edit directly. Regenerate using modelgen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
export interface GraphNode {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
items_in: number;
|
||||||
|
items_out: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphEdge {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
throughput: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoundingBoxEvent {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
confidence: number;
|
||||||
|
label: string;
|
||||||
|
resolved_brand: string | null;
|
||||||
|
source: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrandSummary {
|
||||||
|
brand: string;
|
||||||
|
total_appearances: number;
|
||||||
|
total_screen_time: number;
|
||||||
|
avg_confidence: number;
|
||||||
|
first_seen: number;
|
||||||
|
last_seen: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphUpdate {
|
||||||
|
nodes: GraphNode[];
|
||||||
|
edges: GraphEdge[];
|
||||||
|
active_path: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsUpdate {
|
||||||
|
frames_extracted: number;
|
||||||
|
frames_after_scene_filter: number;
|
||||||
|
regions_detected: number;
|
||||||
|
regions_resolved_by_ocr: number;
|
||||||
|
regions_escalated_to_local_vlm: number;
|
||||||
|
regions_escalated_to_cloud_llm: number;
|
||||||
|
cloud_llm_calls: number;
|
||||||
|
processing_time_seconds: number;
|
||||||
|
estimated_cloud_cost_usd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FrameUpdate {
|
||||||
|
frame_ref: number;
|
||||||
|
timestamp: number;
|
||||||
|
jpeg_b64: string;
|
||||||
|
boxes: BoundingBoxEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Detection {
|
||||||
|
brand: string;
|
||||||
|
timestamp: number;
|
||||||
|
duration: number;
|
||||||
|
confidence: number;
|
||||||
|
source: string;
|
||||||
|
content_type: string;
|
||||||
|
bbox: BoundingBoxEvent | null;
|
||||||
|
frame_ref: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogEvent {
|
||||||
|
level: string;
|
||||||
|
stage: string;
|
||||||
|
msg: string;
|
||||||
|
ts: string;
|
||||||
|
trace_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetectionReportSummary {
|
||||||
|
video_source: string;
|
||||||
|
content_type: string;
|
||||||
|
duration_seconds: number;
|
||||||
|
total_detections: number;
|
||||||
|
brands: BrandSummary[];
|
||||||
|
stats: StatsUpdate | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobComplete {
|
||||||
|
job_id: string;
|
||||||
|
report: DetectionReportSummary | null;
|
||||||
|
}
|
||||||
18
ui/detection-app/tsconfig.json
Normal file
18
ui/detection-app/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"noEmit": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||||
|
}
|
||||||
23
ui/detection-app/vite.config.ts
Normal file
23
ui/detection-app/vite.config.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
base: '/detection/',
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, 'src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5175,
|
||||||
|
allowedHosts: ['mpr.local.ar'],
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8702',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
23
ui/framework/package.json
Normal file
23
ui/framework/package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "mpr-ui-framework",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"typecheck": "vue-tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5",
|
||||||
|
"pinia": "^2.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.6",
|
||||||
|
"vitest": "^2",
|
||||||
|
"vue-tsc": "^2",
|
||||||
|
"vite": "^6",
|
||||||
|
"@vitejs/plugin-vue": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
1558
ui/framework/pnpm-lock.yaml
generated
Normal file
1558
ui/framework/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
ui/framework/src/composables/useDataSource.ts
Normal file
23
ui/framework/src/composables/useDataSource.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { onMounted, onUnmounted, type Ref } from 'vue'
|
||||||
|
import { DataSource, type DataSourceStatus } from '../datasources/DataSource'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable that connects a component to a DataSource.
|
||||||
|
*
|
||||||
|
* Connects on mount, disconnects on unmount.
|
||||||
|
* Returns reactive refs for data, status, and error.
|
||||||
|
*/
|
||||||
|
export function useDataSource<T = unknown>(source: DataSource<T>): {
|
||||||
|
data: Ref<T | null>
|
||||||
|
status: Ref<DataSourceStatus>
|
||||||
|
error: Ref<string | null>
|
||||||
|
} {
|
||||||
|
onMounted(() => source.connect())
|
||||||
|
onUnmounted(() => source.disconnect())
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: source.data as Ref<T | null>,
|
||||||
|
status: source.status,
|
||||||
|
error: source.error as Ref<string | null>,
|
||||||
|
}
|
||||||
|
}
|
||||||
40
ui/framework/src/datasources/DataSource.ts
Normal file
40
ui/framework/src/datasources/DataSource.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { type Ref, ref } from 'vue'
|
||||||
|
|
||||||
|
export type DataSourceStatus = 'idle' | 'connecting' | 'live' | 'error'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for all data sources.
|
||||||
|
*
|
||||||
|
* A DataSource connects to some event stream, exposes reactive state,
|
||||||
|
* and lets consumers subscribe to typed events. Panels read from these
|
||||||
|
* reactively — they never touch the transport layer directly.
|
||||||
|
*/
|
||||||
|
export abstract class DataSource<T = unknown> {
|
||||||
|
readonly id: string
|
||||||
|
readonly data: Ref<T | null> = ref(null) as Ref<T | null>
|
||||||
|
readonly status: Ref<DataSourceStatus> = ref('idle')
|
||||||
|
readonly error: Ref<string | null> = ref(null) as Ref<string | null>
|
||||||
|
|
||||||
|
private listeners = new Map<string, Set<(payload: any) => void>>()
|
||||||
|
|
||||||
|
constructor(id: string) {
|
||||||
|
this.id = id
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract connect(): void
|
||||||
|
abstract disconnect(): void
|
||||||
|
|
||||||
|
/** Subscribe to a specific event type */
|
||||||
|
on<P = unknown>(eventType: string, handler: (payload: P) => void): () => void {
|
||||||
|
if (!this.listeners.has(eventType)) {
|
||||||
|
this.listeners.set(eventType, new Set())
|
||||||
|
}
|
||||||
|
this.listeners.get(eventType)!.add(handler)
|
||||||
|
return () => this.listeners.get(eventType)?.delete(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emit an event to subscribers (called by subclasses) */
|
||||||
|
protected emit(eventType: string, payload: unknown): void {
|
||||||
|
this.listeners.get(eventType)?.forEach((fn) => fn(payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
94
ui/framework/src/datasources/SSEDataSource.ts
Normal file
94
ui/framework/src/datasources/SSEDataSource.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { DataSource } from './DataSource'
|
||||||
|
|
||||||
|
export interface SSEDataSourceOptions {
|
||||||
|
/** Unique identifier for this source */
|
||||||
|
id: string
|
||||||
|
/** SSE endpoint URL (e.g. '/api/detect/stream/job-123') */
|
||||||
|
url: string
|
||||||
|
/** Event types to listen for. Each is dispatched to subscribers via on(). */
|
||||||
|
eventTypes: string[]
|
||||||
|
/** Max reconnection attempts before giving up. Default: 10 */
|
||||||
|
maxRetries?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DataSource backed by native EventSource (Server-Sent Events).
|
||||||
|
*
|
||||||
|
* Connects to a single SSE endpoint and demultiplexes events by type.
|
||||||
|
* Multiple panels can subscribe to different event types from the same source.
|
||||||
|
*/
|
||||||
|
export class SSEDataSource extends DataSource {
|
||||||
|
private es: EventSource | null = null
|
||||||
|
private url: string
|
||||||
|
private eventTypes: string[]
|
||||||
|
private maxRetries: number
|
||||||
|
private retryCount = 0
|
||||||
|
|
||||||
|
constructor(opts: SSEDataSourceOptions) {
|
||||||
|
super(opts.id)
|
||||||
|
this.url = opts.url
|
||||||
|
this.eventTypes = opts.eventTypes
|
||||||
|
this.maxRetries = opts.maxRetries ?? 10
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(): void {
|
||||||
|
if (this.es) return
|
||||||
|
this.status.value = 'connecting'
|
||||||
|
this.error.value = null
|
||||||
|
|
||||||
|
this.es = new EventSource(this.url)
|
||||||
|
|
||||||
|
this.es.onopen = () => {
|
||||||
|
this.status.value = 'live'
|
||||||
|
this.retryCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
this.es.onerror = () => {
|
||||||
|
if (this.es?.readyState === EventSource.CLOSED) {
|
||||||
|
this.retryCount++
|
||||||
|
if (this.retryCount >= this.maxRetries) {
|
||||||
|
this.status.value = 'error'
|
||||||
|
this.error.value = `Connection lost after ${this.maxRetries} retries`
|
||||||
|
this.disconnect()
|
||||||
|
} else {
|
||||||
|
this.status.value = 'connecting'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register a listener for each event type
|
||||||
|
for (const eventType of this.eventTypes) {
|
||||||
|
this.es.addEventListener(eventType, (e: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(e.data)
|
||||||
|
this.data.value = parsed
|
||||||
|
this.emit(eventType, parsed)
|
||||||
|
} catch {
|
||||||
|
// ignore malformed events
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also listen to the generic 'done' terminal event
|
||||||
|
this.es.addEventListener('done', () => {
|
||||||
|
this.status.value = 'idle'
|
||||||
|
this.disconnect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
if (this.es) {
|
||||||
|
this.es.close()
|
||||||
|
this.es = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update the URL (e.g. when job ID changes) and reconnect */
|
||||||
|
setUrl(url: string): void {
|
||||||
|
this.url = url
|
||||||
|
if (this.status.value === 'live' || this.status.value === 'connecting') {
|
||||||
|
this.disconnect()
|
||||||
|
this.connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
ui/framework/src/datasources/StaticDataSource.ts
Normal file
45
ui/framework/src/datasources/StaticDataSource.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { DataSource } from './DataSource'
|
||||||
|
|
||||||
|
export interface StaticEvent {
|
||||||
|
type: string
|
||||||
|
data: unknown
|
||||||
|
/** Delay in ms before emitting this event (relative to previous). Default: 0 */
|
||||||
|
delay?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DataSource that replays a fixture array of events.
|
||||||
|
*
|
||||||
|
* Used for development and testing without a running backend.
|
||||||
|
* Events are emitted in sequence with optional delays.
|
||||||
|
*/
|
||||||
|
export class StaticDataSource extends DataSource {
|
||||||
|
private events: StaticEvent[]
|
||||||
|
private timeouts: ReturnType<typeof setTimeout>[] = []
|
||||||
|
|
||||||
|
constructor(id: string, events: StaticEvent[]) {
|
||||||
|
super(id)
|
||||||
|
this.events = events
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(): void {
|
||||||
|
this.status.value = 'live'
|
||||||
|
this.error.value = null
|
||||||
|
|
||||||
|
let cumDelay = 0
|
||||||
|
for (const event of this.events) {
|
||||||
|
cumDelay += event.delay ?? 0
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
this.data.value = event.data
|
||||||
|
this.emit(event.type, event.data)
|
||||||
|
}, cumDelay)
|
||||||
|
this.timeouts.push(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
for (const t of this.timeouts) clearTimeout(t)
|
||||||
|
this.timeouts = []
|
||||||
|
this.status.value = 'idle'
|
||||||
|
}
|
||||||
|
}
|
||||||
103
ui/framework/src/datasources/__tests__/StaticDataSource.test.ts
Normal file
103
ui/framework/src/datasources/__tests__/StaticDataSource.test.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||||
|
import { StaticDataSource } from '../StaticDataSource'
|
||||||
|
|
||||||
|
describe('StaticDataSource', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits events in order', async () => {
|
||||||
|
const source = new StaticDataSource('test', [
|
||||||
|
{ type: 'log', data: { msg: 'first' } },
|
||||||
|
{ type: 'log', data: { msg: 'second' } },
|
||||||
|
{ type: 'stats', data: { count: 42 } },
|
||||||
|
])
|
||||||
|
|
||||||
|
const received: { type: string; data: unknown }[] = []
|
||||||
|
source.on('log', (d) => received.push({ type: 'log', data: d }))
|
||||||
|
source.on('stats', (d) => received.push({ type: 'stats', data: d }))
|
||||||
|
|
||||||
|
source.connect()
|
||||||
|
|
||||||
|
// Events with delay=0 fire on next microtask via setTimeout(0)
|
||||||
|
await new Promise((r) => setTimeout(r, 10))
|
||||||
|
|
||||||
|
expect(source.status.value).toBe('live')
|
||||||
|
expect(received).toHaveLength(3)
|
||||||
|
expect(received[0]).toEqual({ type: 'log', data: { msg: 'first' } })
|
||||||
|
expect(received[1]).toEqual({ type: 'log', data: { msg: 'second' } })
|
||||||
|
expect(received[2]).toEqual({ type: 'stats', data: { count: 42 } })
|
||||||
|
|
||||||
|
source.disconnect()
|
||||||
|
expect(source.status.value).toBe('idle')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('respects delays between events', async () => {
|
||||||
|
const source = new StaticDataSource('test-delay', [
|
||||||
|
{ type: 'a', data: 1 },
|
||||||
|
{ type: 'b', data: 2, delay: 50 },
|
||||||
|
])
|
||||||
|
|
||||||
|
const received: unknown[] = []
|
||||||
|
source.on('a', (d) => received.push(d))
|
||||||
|
source.on('b', (d) => received.push(d))
|
||||||
|
|
||||||
|
source.connect()
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 10))
|
||||||
|
expect(received).toHaveLength(1) // only 'a' so far
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 60))
|
||||||
|
expect(received).toHaveLength(2) // 'b' arrived after delay
|
||||||
|
|
||||||
|
source.disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates data ref with latest event payload', async () => {
|
||||||
|
const source = new StaticDataSource('test-data', [
|
||||||
|
{ type: 'x', data: { v: 1 } },
|
||||||
|
{ type: 'x', data: { v: 2 } },
|
||||||
|
])
|
||||||
|
|
||||||
|
source.connect()
|
||||||
|
await new Promise((r) => setTimeout(r, 10))
|
||||||
|
|
||||||
|
expect(source.data.value).toEqual({ v: 2 })
|
||||||
|
|
||||||
|
source.disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cleans up on disconnect', async () => {
|
||||||
|
const source = new StaticDataSource('test-cleanup', [
|
||||||
|
{ type: 'a', data: 1 },
|
||||||
|
{ type: 'b', data: 2, delay: 100 },
|
||||||
|
])
|
||||||
|
|
||||||
|
const received: unknown[] = []
|
||||||
|
source.on('b', (d) => received.push(d))
|
||||||
|
|
||||||
|
source.connect()
|
||||||
|
await new Promise((r) => setTimeout(r, 10))
|
||||||
|
source.disconnect()
|
||||||
|
|
||||||
|
// 'b' should never fire since we disconnected before its delay
|
||||||
|
await new Promise((r) => setTimeout(r, 150))
|
||||||
|
expect(received).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unsubscribe removes listener', async () => {
|
||||||
|
const source = new StaticDataSource('test-unsub', [
|
||||||
|
{ type: 'x', data: 1 },
|
||||||
|
])
|
||||||
|
|
||||||
|
const received: unknown[] = []
|
||||||
|
const unsub = source.on('x', (d) => received.push(d))
|
||||||
|
unsub()
|
||||||
|
|
||||||
|
source.connect()
|
||||||
|
await new Promise((r) => setTimeout(r, 10))
|
||||||
|
|
||||||
|
expect(received).toHaveLength(0)
|
||||||
|
source.disconnect()
|
||||||
|
})
|
||||||
|
})
|
||||||
5
ui/framework/src/index.ts
Normal file
5
ui/framework/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// Framework public API
|
||||||
|
export { DataSource, type DataSourceStatus } from './datasources/DataSource'
|
||||||
|
export { SSEDataSource } from './datasources/SSEDataSource'
|
||||||
|
export { StaticDataSource } from './datasources/StaticDataSource'
|
||||||
|
export { useDataSource } from './composables/useDataSource'
|
||||||
18
ui/framework/tsconfig.json
Normal file
18
ui/framework/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"noEmit": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||||
|
}
|
||||||
7
ui/framework/vitest.config.ts
Normal file
7
ui/framework/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user