phase 2
This commit is contained in:
@@ -40,7 +40,7 @@ app = FastAPI(
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://mpr.local.ar", "http://localhost:5173"],
|
||||
allow_origins=["http://mpr.local.ar", "http://k8s.mpr.local.ar", "http://localhost:5173"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
@@ -57,6 +57,11 @@ app.include_router(chunker_router)
|
||||
app.include_router(detect_router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
"""API root."""
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
RUN pip install --no-cache-dir uv
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN uv pip install --system --no-cache -r requirements.txt
|
||||
|
||||
# No COPY . . — code is volume-mounted in dev (..:/app)
|
||||
# This image only provides the Python runtime + dependencies
|
||||
# Copy code into image (k8s uses this, docker-compose volume-mounts over it)
|
||||
COPY . .
|
||||
|
||||
CMD ["python", "admin/manage.py", "runserver", "0.0.0.0:8000"]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
RUN pip install --no-cache-dir uv
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -7,9 +9,9 @@ RUN apt-get update && apt-get install -y \
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt requirements-worker.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements-worker.txt
|
||||
RUN uv pip install --system --no-cache -r requirements-worker.txt
|
||||
|
||||
# No COPY . . — code is volume-mounted in dev (..:/app)
|
||||
# This image only provides Python runtime + FFmpeg + dependencies
|
||||
# Copy code into image (k8s uses this, docker-compose volume-mounts over it)
|
||||
COPY . .
|
||||
|
||||
CMD ["celery", "-A", "admin.mpr", "worker", "--loglevel=info"]
|
||||
|
||||
43
ctrl/Tiltfile
Normal file
43
ctrl/Tiltfile
Normal file
@@ -0,0 +1,43 @@
|
||||
# MPR — Tilt development environment
|
||||
# Usage: cd ctrl && tilt up
|
||||
# Cluster: kind (name: mpr)
|
||||
|
||||
allow_k8s_contexts('kind-mpr')
|
||||
|
||||
# Apply k8s manifests via kustomize (dev overlay)
|
||||
k8s_yaml(kustomize('k8s/overlays/dev'))
|
||||
|
||||
# --- Images — reuse existing Dockerfiles ---
|
||||
|
||||
# FastAPI (Python backend)
|
||||
docker_build(
|
||||
'mpr-fastapi',
|
||||
context='..',
|
||||
dockerfile='Dockerfile',
|
||||
live_update=[
|
||||
sync('..', '/app'),
|
||||
],
|
||||
)
|
||||
|
||||
# Detection UI (Vue 3)
|
||||
docker_build(
|
||||
'mpr-detection',
|
||||
context='../ui/detection-app',
|
||||
dockerfile='../ui/detection-app/Dockerfile',
|
||||
live_update=[
|
||||
sync('../ui/detection-app/src', '/app/src'),
|
||||
sync('../ui/detection-app/index.html', '/app/index.html'),
|
||||
sync('../ui/detection-app/vite.config.ts', '/app/vite.config.ts'),
|
||||
],
|
||||
)
|
||||
|
||||
# Framework changes trigger a full rebuild (live_update can't reach outside context)
|
||||
watch_file('../ui/framework/src')
|
||||
|
||||
# --- Resources ---
|
||||
|
||||
k8s_resource('redis')
|
||||
k8s_resource('fastapi', resource_deps=['redis'])
|
||||
k8s_resource('detection-ui')
|
||||
k8s_resource('gateway', resource_deps=['fastapi', 'detection-ui'],
|
||||
port_forwards=['8080:8080'])
|
||||
11
ctrl/k8s/base/configmap.yaml
Normal file
11
ctrl/k8s/base/configmap.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: mpr-config
|
||||
namespace: mpr
|
||||
data:
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
DEBUG: "1"
|
||||
FASTAPI_PORT: "8702"
|
||||
DETECTION_UI_PORT: "5175"
|
||||
GATEWAY_PORT: "8080"
|
||||
44
ctrl/k8s/base/detection-ui.yaml
Normal file
44
ctrl/k8s/base/detection-ui.yaml
Normal file
@@ -0,0 +1,44 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: detection-ui
|
||||
namespace: mpr
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: detection-ui
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: detection-ui
|
||||
spec:
|
||||
containers:
|
||||
- name: detection-ui
|
||||
image: mpr-detection
|
||||
ports:
|
||||
- containerPort: 5175
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: mpr-config
|
||||
env:
|
||||
- name: VITE_ALLOWED_HOSTS
|
||||
value: "k8s.mpr.local.ar"
|
||||
resources:
|
||||
requests:
|
||||
memory: 64Mi
|
||||
cpu: 50m
|
||||
limits:
|
||||
memory: 256Mi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: detection-ui
|
||||
namespace: mpr
|
||||
spec:
|
||||
selector:
|
||||
app: detection-ui
|
||||
ports:
|
||||
- port: 5175
|
||||
targetPort: 5175
|
||||
48
ctrl/k8s/base/fastapi.yaml
Normal file
48
ctrl/k8s/base/fastapi.yaml
Normal file
@@ -0,0 +1,48 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: fastapi
|
||||
namespace: mpr
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: fastapi
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: fastapi
|
||||
spec:
|
||||
containers:
|
||||
- name: fastapi
|
||||
image: mpr-fastapi
|
||||
command: ["sh", "-c", "uvicorn core.api.main:app --host 0.0.0.0 --port $FASTAPI_PORT --reload"]
|
||||
ports:
|
||||
- containerPort: 8702
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: mpr-config
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8702
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
requests:
|
||||
memory: 128Mi
|
||||
cpu: 100m
|
||||
limits:
|
||||
memory: 512Mi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: fastapi
|
||||
namespace: mpr
|
||||
spec:
|
||||
selector:
|
||||
app: fastapi
|
||||
ports:
|
||||
- port: 8702
|
||||
targetPort: 8702
|
||||
128
ctrl/k8s/base/gateway.yaml
Normal file
128
ctrl/k8s/base/gateway.yaml
Normal file
@@ -0,0 +1,128 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: envoy-gateway-config
|
||||
namespace: mpr
|
||||
data:
|
||||
envoy.yaml: |
|
||||
static_resources:
|
||||
listeners:
|
||||
- name: http
|
||||
address:
|
||||
socket_address:
|
||||
address: 0.0.0.0
|
||||
port_value: 8080
|
||||
filter_chains:
|
||||
- filters:
|
||||
- name: envoy.filters.network.http_connection_manager
|
||||
typed_config:
|
||||
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
|
||||
stat_prefix: ingress
|
||||
codec_type: AUTO
|
||||
route_config:
|
||||
name: local_routes
|
||||
virtual_hosts:
|
||||
- name: mpr
|
||||
domains: ["k8s.mpr.local.ar", "*"]
|
||||
routes:
|
||||
# SSE — long timeout, no buffering
|
||||
- match:
|
||||
prefix: "/api/detect/stream/"
|
||||
route:
|
||||
cluster: fastapi
|
||||
timeout: 3600s
|
||||
idle_timeout: 3600s
|
||||
# FastAPI — strip /api/ prefix
|
||||
- match:
|
||||
prefix: "/api/"
|
||||
route:
|
||||
cluster: fastapi
|
||||
prefix_rewrite: "/"
|
||||
# Detection UI
|
||||
- match:
|
||||
prefix: "/detection/"
|
||||
route:
|
||||
cluster: detection-ui
|
||||
# Default
|
||||
- match:
|
||||
prefix: "/"
|
||||
route:
|
||||
cluster: detection-ui
|
||||
prefix_rewrite: "/detection/"
|
||||
http_filters:
|
||||
- name: envoy.filters.http.router
|
||||
typed_config:
|
||||
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
|
||||
clusters:
|
||||
- name: fastapi
|
||||
connect_timeout: 5s
|
||||
type: STRICT_DNS
|
||||
lb_policy: ROUND_ROBIN
|
||||
load_assignment:
|
||||
cluster_name: fastapi
|
||||
endpoints:
|
||||
- lb_endpoints:
|
||||
- endpoint:
|
||||
address:
|
||||
socket_address:
|
||||
address: fastapi
|
||||
port_value: 8702
|
||||
- name: detection-ui
|
||||
connect_timeout: 5s
|
||||
type: STRICT_DNS
|
||||
lb_policy: ROUND_ROBIN
|
||||
load_assignment:
|
||||
cluster_name: detection-ui
|
||||
endpoints:
|
||||
- lb_endpoints:
|
||||
- endpoint:
|
||||
address:
|
||||
socket_address:
|
||||
address: detection-ui
|
||||
port_value: 5175
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: gateway
|
||||
namespace: mpr
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: gateway
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: gateway
|
||||
spec:
|
||||
containers:
|
||||
- name: envoy
|
||||
image: envoyproxy/envoy:v1.28-latest
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /etc/envoy
|
||||
resources:
|
||||
requests:
|
||||
memory: 64Mi
|
||||
cpu: 50m
|
||||
limits:
|
||||
memory: 256Mi
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: envoy-gateway-config
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: gateway
|
||||
namespace: mpr
|
||||
spec:
|
||||
selector:
|
||||
app: gateway
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8080
|
||||
12
ctrl/k8s/base/kustomization.yaml
Normal file
12
ctrl/k8s/base/kustomization.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
namespace: mpr
|
||||
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- configmap.yaml
|
||||
- redis.yaml
|
||||
- fastapi.yaml
|
||||
- detection-ui.yaml
|
||||
- gateway.yaml
|
||||
4
ctrl/k8s/base/namespace.yaml
Normal file
4
ctrl/k8s/base/namespace.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: mpr
|
||||
43
ctrl/k8s/base/redis.yaml
Normal file
43
ctrl/k8s/base/redis.yaml
Normal file
@@ -0,0 +1,43 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: mpr
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["redis-cli", "ping"]
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 5
|
||||
resources:
|
||||
requests:
|
||||
memory: 64Mi
|
||||
cpu: 50m
|
||||
limits:
|
||||
memory: 256Mi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: mpr
|
||||
spec:
|
||||
selector:
|
||||
app: redis
|
||||
ports:
|
||||
- port: 6379
|
||||
targetPort: 6379
|
||||
38
ctrl/k8s/k8s/base/detection-ui.yaml
Normal file
38
ctrl/k8s/k8s/base/detection-ui.yaml
Normal file
@@ -0,0 +1,38 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: detection-ui
|
||||
namespace: mpr
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: detection-ui
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: detection-ui
|
||||
spec:
|
||||
containers:
|
||||
- name: detection-ui
|
||||
image: mpr-detection
|
||||
ports:
|
||||
- containerPort: 5175
|
||||
resources:
|
||||
requests:
|
||||
memory: 64Mi
|
||||
cpu: 50m
|
||||
limits:
|
||||
memory: 256Mi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: detection-ui
|
||||
namespace: mpr
|
||||
spec:
|
||||
selector:
|
||||
app: detection-ui
|
||||
ports:
|
||||
- port: 5175
|
||||
targetPort: 5175
|
||||
52
ctrl/k8s/k8s/base/fastapi.yaml
Normal file
52
ctrl/k8s/k8s/base/fastapi.yaml
Normal file
@@ -0,0 +1,52 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: fastapi
|
||||
namespace: mpr
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: fastapi
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: fastapi
|
||||
spec:
|
||||
containers:
|
||||
- name: fastapi
|
||||
image: mpr-fastapi
|
||||
command: ["uvicorn", "core.api.main:app", "--host", "0.0.0.0", "--port", "8702", "--reload"]
|
||||
ports:
|
||||
- containerPort: 8702
|
||||
env:
|
||||
- name: REDIS_URL
|
||||
value: redis://redis:6379/0
|
||||
- name: DJANGO_ALLOW_ASYNC_UNSAFE
|
||||
value: "true"
|
||||
- name: DEBUG
|
||||
value: "1"
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8702
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
requests:
|
||||
memory: 128Mi
|
||||
cpu: 100m
|
||||
limits:
|
||||
memory: 512Mi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: fastapi
|
||||
namespace: mpr
|
||||
spec:
|
||||
selector:
|
||||
app: fastapi
|
||||
ports:
|
||||
- port: 8702
|
||||
targetPort: 8702
|
||||
128
ctrl/k8s/k8s/base/gateway.yaml
Normal file
128
ctrl/k8s/k8s/base/gateway.yaml
Normal file
@@ -0,0 +1,128 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: envoy-gateway-config
|
||||
namespace: mpr
|
||||
data:
|
||||
envoy.yaml: |
|
||||
static_resources:
|
||||
listeners:
|
||||
- name: http
|
||||
address:
|
||||
socket_address:
|
||||
address: 0.0.0.0
|
||||
port_value: 8080
|
||||
filter_chains:
|
||||
- filters:
|
||||
- name: envoy.filters.network.http_connection_manager
|
||||
typed_config:
|
||||
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
|
||||
stat_prefix: ingress
|
||||
codec_type: AUTO
|
||||
route_config:
|
||||
name: local_routes
|
||||
virtual_hosts:
|
||||
- name: mpr
|
||||
domains: ["k8s.mpr.local.ar", "*"]
|
||||
routes:
|
||||
# SSE — disable buffering
|
||||
- match:
|
||||
prefix: "/api/detect/stream/"
|
||||
route:
|
||||
cluster: fastapi
|
||||
timeout: 3600s
|
||||
idle_timeout: 3600s
|
||||
# FastAPI — strip /api/ prefix
|
||||
- match:
|
||||
prefix: "/api/"
|
||||
route:
|
||||
cluster: fastapi
|
||||
prefix_rewrite: "/"
|
||||
# Detection UI
|
||||
- match:
|
||||
prefix: "/detection/"
|
||||
route:
|
||||
cluster: detection-ui
|
||||
# Default — detection UI
|
||||
- match:
|
||||
prefix: "/"
|
||||
route:
|
||||
cluster: detection-ui
|
||||
prefix_rewrite: "/detection/"
|
||||
http_filters:
|
||||
- name: envoy.filters.http.router
|
||||
typed_config:
|
||||
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
|
||||
clusters:
|
||||
- name: fastapi
|
||||
connect_timeout: 5s
|
||||
type: STRICT_DNS
|
||||
lb_policy: ROUND_ROBIN
|
||||
load_assignment:
|
||||
cluster_name: fastapi
|
||||
endpoints:
|
||||
- lb_endpoints:
|
||||
- endpoint:
|
||||
address:
|
||||
socket_address:
|
||||
address: fastapi
|
||||
port_value: 8702
|
||||
- name: detection-ui
|
||||
connect_timeout: 5s
|
||||
type: STRICT_DNS
|
||||
lb_policy: ROUND_ROBIN
|
||||
load_assignment:
|
||||
cluster_name: detection-ui
|
||||
endpoints:
|
||||
- lb_endpoints:
|
||||
- endpoint:
|
||||
address:
|
||||
socket_address:
|
||||
address: detection-ui
|
||||
port_value: 5175
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: gateway
|
||||
namespace: mpr
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: gateway
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: gateway
|
||||
spec:
|
||||
containers:
|
||||
- name: envoy
|
||||
image: envoyproxy/envoy:v1.28-latest
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /etc/envoy
|
||||
resources:
|
||||
requests:
|
||||
memory: 64Mi
|
||||
cpu: 50m
|
||||
limits:
|
||||
memory: 256Mi
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: envoy-gateway-config
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: gateway
|
||||
namespace: mpr
|
||||
spec:
|
||||
selector:
|
||||
app: gateway
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8080
|
||||
11
ctrl/k8s/k8s/base/kustomization.yaml
Normal file
11
ctrl/k8s/k8s/base/kustomization.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
namespace: mpr
|
||||
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- redis.yaml
|
||||
- fastapi.yaml
|
||||
- detection-ui.yaml
|
||||
- gateway.yaml
|
||||
4
ctrl/k8s/k8s/base/namespace.yaml
Normal file
4
ctrl/k8s/k8s/base/namespace.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: mpr
|
||||
43
ctrl/k8s/k8s/base/redis.yaml
Normal file
43
ctrl/k8s/k8s/base/redis.yaml
Normal file
@@ -0,0 +1,43 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: mpr
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["redis-cli", "ping"]
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 5
|
||||
resources:
|
||||
requests:
|
||||
memory: 64Mi
|
||||
cpu: 50m
|
||||
limits:
|
||||
memory: 256Mi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: mpr
|
||||
spec:
|
||||
selector:
|
||||
app: redis
|
||||
ports:
|
||||
- port: 6379
|
||||
targetPort: 6379
|
||||
20
ctrl/k8s/k8s/overlays/cloud/kustomization.yaml
Normal file
20
ctrl/k8s/k8s/overlays/cloud/kustomization.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- ../../base
|
||||
|
||||
patches:
|
||||
# Gateway as cloud LoadBalancer
|
||||
- target:
|
||||
kind: Service
|
||||
name: gateway
|
||||
patch: |
|
||||
- op: replace
|
||||
path: /spec/type
|
||||
value: LoadBalancer
|
||||
- op: add
|
||||
path: /metadata/annotations
|
||||
value:
|
||||
service.beta.kubernetes.io/aws-load-balancer-type: nlb
|
||||
service.beta.kubernetes.io/aws-load-balancer-scheme: internal
|
||||
30
ctrl/k8s/k8s/overlays/dev/kustomization.yaml
Normal file
30
ctrl/k8s/k8s/overlays/dev/kustomization.yaml
Normal file
@@ -0,0 +1,30 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- ../../base
|
||||
|
||||
patches:
|
||||
# Gateway as NodePort for local access
|
||||
- target:
|
||||
kind: Service
|
||||
name: gateway
|
||||
patch: |
|
||||
- op: replace
|
||||
path: /spec/type
|
||||
value: NodePort
|
||||
- op: add
|
||||
path: /spec/ports/0/nodePort
|
||||
value: 30080
|
||||
|
||||
# Redis as NodePort for redis-cli access from host
|
||||
- target:
|
||||
kind: Service
|
||||
name: redis
|
||||
patch: |
|
||||
- op: replace
|
||||
path: /spec/type
|
||||
value: NodePort
|
||||
- op: add
|
||||
path: /spec/ports/0/nodePort
|
||||
value: 30379
|
||||
15
ctrl/k8s/k8s/overlays/onprem/kustomization.yaml
Normal file
15
ctrl/k8s/k8s/overlays/onprem/kustomization.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- ../../base
|
||||
|
||||
patches:
|
||||
# Gateway as LoadBalancer — MetalLB assigns a LAN IP
|
||||
- target:
|
||||
kind: Service
|
||||
name: gateway
|
||||
patch: |
|
||||
- op: replace
|
||||
path: /spec/type
|
||||
value: LoadBalancer
|
||||
15
ctrl/k8s/kind-config.yaml
Normal file
15
ctrl/k8s/kind-config.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
kind: Cluster
|
||||
apiVersion: kind.x-k8s.io/v1alpha4
|
||||
name: mpr
|
||||
nodes:
|
||||
- role: control-plane
|
||||
extraPortMappings:
|
||||
# Gateway → http://k8s.mpr.local.ar (bind to 127.0.0.2 to avoid conflict with docker-compose on 127.0.0.1:80)
|
||||
- containerPort: 30080
|
||||
hostPort: 80
|
||||
listenAddress: "127.0.0.2"
|
||||
protocol: TCP
|
||||
# Redis
|
||||
- containerPort: 30379
|
||||
hostPort: 6382
|
||||
protocol: TCP
|
||||
20
ctrl/k8s/overlays/cloud/kustomization.yaml
Normal file
20
ctrl/k8s/overlays/cloud/kustomization.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- ../../base
|
||||
|
||||
patches:
|
||||
# Gateway as cloud LoadBalancer
|
||||
- target:
|
||||
kind: Service
|
||||
name: gateway
|
||||
patch: |
|
||||
- op: replace
|
||||
path: /spec/type
|
||||
value: LoadBalancer
|
||||
- op: add
|
||||
path: /metadata/annotations
|
||||
value:
|
||||
service.beta.kubernetes.io/aws-load-balancer-type: nlb
|
||||
service.beta.kubernetes.io/aws-load-balancer-scheme: internal
|
||||
30
ctrl/k8s/overlays/dev/kustomization.yaml
Normal file
30
ctrl/k8s/overlays/dev/kustomization.yaml
Normal file
@@ -0,0 +1,30 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- ../../base
|
||||
|
||||
patches:
|
||||
# Gateway as NodePort for local access
|
||||
- target:
|
||||
kind: Service
|
||||
name: gateway
|
||||
patch: |
|
||||
- op: replace
|
||||
path: /spec/type
|
||||
value: NodePort
|
||||
- op: add
|
||||
path: /spec/ports/0/nodePort
|
||||
value: 30080
|
||||
|
||||
# Redis as NodePort for redis-cli access from host
|
||||
- target:
|
||||
kind: Service
|
||||
name: redis
|
||||
patch: |
|
||||
- op: replace
|
||||
path: /spec/type
|
||||
value: NodePort
|
||||
- op: add
|
||||
path: /spec/ports/0/nodePort
|
||||
value: 30379
|
||||
15
ctrl/k8s/overlays/onprem/kustomization.yaml
Normal file
15
ctrl/k8s/overlays/onprem/kustomization.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- ../../base
|
||||
|
||||
patches:
|
||||
# Gateway as LoadBalancer — MetalLB assigns a LAN IP
|
||||
- target:
|
||||
kind: Service
|
||||
name: gateway
|
||||
patch: |
|
||||
- op: replace
|
||||
path: /spec/type
|
||||
value: LoadBalancer
|
||||
23
detect/profiles/__init__.py
Normal file
23
detect/profiles/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from .base import (
|
||||
ContentTypeProfile,
|
||||
BrandDictionary,
|
||||
CropContext,
|
||||
DetectionConfig,
|
||||
FrameExtractionConfig,
|
||||
OCRConfig,
|
||||
ResolverConfig,
|
||||
SceneFilterConfig,
|
||||
)
|
||||
from .soccer import SoccerBroadcastProfile
|
||||
|
||||
__all__ = [
|
||||
"ContentTypeProfile",
|
||||
"BrandDictionary",
|
||||
"CropContext",
|
||||
"DetectionConfig",
|
||||
"FrameExtractionConfig",
|
||||
"OCRConfig",
|
||||
"ResolverConfig",
|
||||
"SceneFilterConfig",
|
||||
"SoccerBroadcastProfile",
|
||||
]
|
||||
71
detect/profiles/base.py
Normal file
71
detect/profiles/base.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
ContentTypeProfile protocol and config dataclasses.
|
||||
|
||||
The pipeline graph is fixed — what varies per content type is configuration
|
||||
and hooks. Each profile provides stage configs, a brand dictionary,
|
||||
VLM prompt templates, and an aggregation strategy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Protocol
|
||||
|
||||
from detect.models import BrandDetection, DetectionReport
|
||||
|
||||
|
||||
@dataclass
|
||||
class FrameExtractionConfig:
|
||||
fps: float = 2.0
|
||||
max_frames: int = 500
|
||||
|
||||
|
||||
@dataclass
|
||||
class SceneFilterConfig:
|
||||
hamming_threshold: int = 8
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class DetectionConfig:
|
||||
model_name: str = "yolov8n.pt"
|
||||
confidence_threshold: float = 0.3
|
||||
target_classes: list[str] = field(default_factory=lambda: ["logo", "text"])
|
||||
|
||||
|
||||
@dataclass
|
||||
class OCRConfig:
|
||||
languages: list[str] = field(default_factory=lambda: ["en"])
|
||||
min_confidence: float = 0.5
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolverConfig:
|
||||
fuzzy_threshold: int = 75
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrandDictionary:
|
||||
"""Maps canonical brand name → list of known aliases/spellings."""
|
||||
brands: dict[str, list[str]] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CropContext:
|
||||
image: bytes
|
||||
surrounding_text: str = ""
|
||||
position_hint: str = ""
|
||||
|
||||
|
||||
class ContentTypeProfile(Protocol):
|
||||
name: str
|
||||
|
||||
def frame_extraction_config(self) -> FrameExtractionConfig: ...
|
||||
def scene_filter_config(self) -> SceneFilterConfig: ...
|
||||
def detection_config(self) -> DetectionConfig: ...
|
||||
def ocr_config(self) -> OCRConfig: ...
|
||||
def brand_dictionary(self) -> BrandDictionary: ...
|
||||
def resolver_config(self) -> ResolverConfig: ...
|
||||
def vlm_prompt(self, crop_context: CropContext) -> str: ...
|
||||
def aggregate(self, detections: list[BrandDetection]) -> DetectionReport: ...
|
||||
def auxiliary_detections(self, source: str) -> list[BrandDetection]: ...
|
||||
92
detect/profiles/soccer.py
Normal file
92
detect/profiles/soccer.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Soccer broadcast profile — pitch hoardings, kits, scoreboards."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from detect.models import BrandDetection, BrandStats, DetectionReport, PipelineStats
|
||||
|
||||
from .base import (
|
||||
BrandDictionary,
|
||||
CropContext,
|
||||
DetectionConfig,
|
||||
FrameExtractionConfig,
|
||||
OCRConfig,
|
||||
ResolverConfig,
|
||||
SceneFilterConfig,
|
||||
)
|
||||
|
||||
|
||||
class SoccerBroadcastProfile:
|
||||
name = "soccer_broadcast"
|
||||
|
||||
def frame_extraction_config(self) -> FrameExtractionConfig:
|
||||
return FrameExtractionConfig(fps=2.0, max_frames=500)
|
||||
|
||||
def scene_filter_config(self) -> SceneFilterConfig:
|
||||
return SceneFilterConfig(hamming_threshold=8, enabled=True)
|
||||
|
||||
def detection_config(self) -> DetectionConfig:
|
||||
return DetectionConfig(
|
||||
model_name="yolov8n.pt",
|
||||
confidence_threshold=0.3,
|
||||
target_classes=["logo", "text", "banner", "scoreboard"],
|
||||
)
|
||||
|
||||
def ocr_config(self) -> OCRConfig:
|
||||
return OCRConfig(languages=["en", "es"], min_confidence=0.5)
|
||||
|
||||
def brand_dictionary(self) -> BrandDictionary:
|
||||
return BrandDictionary(brands={
|
||||
"Nike": ["nike", "NIKE", "swoosh"],
|
||||
"Adidas": ["adidas", "ADIDAS", "adi"],
|
||||
"Puma": ["puma", "PUMA"],
|
||||
"Emirates": ["emirates", "fly emirates", "EMIRATES"],
|
||||
"Coca-Cola": ["coca-cola", "coca cola", "coke", "COCA-COLA"],
|
||||
"Pepsi": ["pepsi", "PEPSI"],
|
||||
"Mastercard": ["mastercard", "MASTERCARD"],
|
||||
"Heineken": ["heineken", "HEINEKEN"],
|
||||
"Santander": ["santander", "SANTANDER"],
|
||||
"Gazprom": ["gazprom", "GAZPROM"],
|
||||
"Qatar Airways": ["qatar airways", "QATAR AIRWAYS"],
|
||||
"Lay's": ["lays", "lay's", "LAYS", "LAY'S"],
|
||||
})
|
||||
|
||||
def resolver_config(self) -> ResolverConfig:
|
||||
return ResolverConfig(fuzzy_threshold=75)
|
||||
|
||||
def vlm_prompt(self, crop_context: CropContext) -> str:
|
||||
hint = f" Position: {crop_context.position_hint}." if crop_context.position_hint else ""
|
||||
text = f" Nearby text: '{crop_context.surrounding_text}'." if crop_context.surrounding_text else ""
|
||||
return (
|
||||
f"Identify the brand or sponsor visible in this cropped region "
|
||||
f"from a soccer broadcast.{hint}{text} "
|
||||
f"Respond with: brand, confidence (0-1), reasoning."
|
||||
)
|
||||
|
||||
def aggregate(self, detections: list[BrandDetection]) -> DetectionReport:
|
||||
brands: dict[str, BrandStats] = {}
|
||||
for d in detections:
|
||||
if d.brand not in brands:
|
||||
brands[d.brand] = BrandStats()
|
||||
s = brands[d.brand]
|
||||
s.total_appearances += 1
|
||||
s.total_screen_time += d.duration
|
||||
s.avg_confidence = (
|
||||
(s.avg_confidence * (s.total_appearances - 1) + d.confidence)
|
||||
/ s.total_appearances
|
||||
)
|
||||
if s.first_seen == 0.0 or d.timestamp < s.first_seen:
|
||||
s.first_seen = d.timestamp
|
||||
if d.timestamp > s.last_seen:
|
||||
s.last_seen = d.timestamp
|
||||
|
||||
return DetectionReport(
|
||||
video_source="",
|
||||
content_type=self.name,
|
||||
duration_seconds=0.0,
|
||||
brands=brands,
|
||||
timeline=sorted(detections, key=lambda d: d.timestamp),
|
||||
pipeline_stats=PipelineStats(),
|
||||
)
|
||||
|
||||
def auxiliary_detections(self, source: str) -> list[BrandDetection]:
|
||||
return []
|
||||
108
detect/profiles/stubs.py
Normal file
108
detect/profiles/stubs.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Stub profiles — interfaces defined, not yet implemented."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from detect.models import BrandDetection, DetectionReport
|
||||
|
||||
from .base import (
|
||||
BrandDictionary,
|
||||
CropContext,
|
||||
DetectionConfig,
|
||||
FrameExtractionConfig,
|
||||
OCRConfig,
|
||||
ResolverConfig,
|
||||
SceneFilterConfig,
|
||||
)
|
||||
|
||||
|
||||
class NewsBroadcastProfile:
|
||||
name = "news_broadcast"
|
||||
|
||||
def frame_extraction_config(self) -> FrameExtractionConfig:
|
||||
raise NotImplementedError
|
||||
|
||||
def scene_filter_config(self) -> SceneFilterConfig:
|
||||
raise NotImplementedError
|
||||
|
||||
def detection_config(self) -> DetectionConfig:
|
||||
raise NotImplementedError
|
||||
|
||||
def ocr_config(self) -> OCRConfig:
|
||||
raise NotImplementedError
|
||||
|
||||
def brand_dictionary(self) -> BrandDictionary:
|
||||
raise NotImplementedError
|
||||
|
||||
def resolver_config(self) -> ResolverConfig:
|
||||
raise NotImplementedError
|
||||
|
||||
def vlm_prompt(self, crop_context: CropContext) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def aggregate(self, detections: list[BrandDetection]) -> DetectionReport:
|
||||
raise NotImplementedError
|
||||
|
||||
def auxiliary_detections(self, source: str) -> list[BrandDetection]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class AdvertisingProfile:
|
||||
name = "advertising"
|
||||
|
||||
def frame_extraction_config(self) -> FrameExtractionConfig:
|
||||
raise NotImplementedError
|
||||
|
||||
def scene_filter_config(self) -> SceneFilterConfig:
|
||||
raise NotImplementedError
|
||||
|
||||
def detection_config(self) -> DetectionConfig:
|
||||
raise NotImplementedError
|
||||
|
||||
def ocr_config(self) -> OCRConfig:
|
||||
raise NotImplementedError
|
||||
|
||||
def brand_dictionary(self) -> BrandDictionary:
|
||||
raise NotImplementedError
|
||||
|
||||
def resolver_config(self) -> ResolverConfig:
|
||||
raise NotImplementedError
|
||||
|
||||
def vlm_prompt(self, crop_context: CropContext) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def aggregate(self, detections: list[BrandDetection]) -> DetectionReport:
|
||||
raise NotImplementedError
|
||||
|
||||
def auxiliary_detections(self, source: str) -> list[BrandDetection]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class TranscriptProfile:
|
||||
name = "transcript"
|
||||
|
||||
def frame_extraction_config(self) -> FrameExtractionConfig:
|
||||
raise NotImplementedError
|
||||
|
||||
def scene_filter_config(self) -> SceneFilterConfig:
|
||||
raise NotImplementedError
|
||||
|
||||
def detection_config(self) -> DetectionConfig:
|
||||
raise NotImplementedError
|
||||
|
||||
def ocr_config(self) -> OCRConfig:
|
||||
raise NotImplementedError
|
||||
|
||||
def brand_dictionary(self) -> BrandDictionary:
|
||||
raise NotImplementedError
|
||||
|
||||
def resolver_config(self) -> ResolverConfig:
|
||||
raise NotImplementedError
|
||||
|
||||
def vlm_prompt(self, crop_context: CropContext) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def aggregate(self, detections: list[BrandDetection]) -> DetectionReport:
|
||||
raise NotImplementedError
|
||||
|
||||
def auxiliary_detections(self, source: str) -> list[BrandDetection]:
|
||||
raise NotImplementedError
|
||||
73
tests/detect/test_profiles.py
Normal file
73
tests/detect/test_profiles.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Tests for ContentTypeProfile implementations."""
|
||||
|
||||
import pytest
|
||||
|
||||
from detect.models import BrandDetection
|
||||
from detect.profiles.base import ContentTypeProfile, CropContext
|
||||
from detect.profiles.soccer import SoccerBroadcastProfile
|
||||
from detect.profiles.stubs import AdvertisingProfile, NewsBroadcastProfile, TranscriptProfile
|
||||
|
||||
|
||||
def test_soccer_satisfies_protocol():
|
||||
profile: ContentTypeProfile = SoccerBroadcastProfile()
|
||||
assert profile.name == "soccer_broadcast"
|
||||
|
||||
|
||||
def test_soccer_frame_extraction_config():
|
||||
cfg = SoccerBroadcastProfile().frame_extraction_config()
|
||||
assert cfg.fps > 0
|
||||
assert cfg.max_frames > 0
|
||||
|
||||
|
||||
def test_soccer_detection_config():
|
||||
cfg = SoccerBroadcastProfile().detection_config()
|
||||
assert 0 < cfg.confidence_threshold < 1
|
||||
assert len(cfg.target_classes) > 0
|
||||
|
||||
|
||||
def test_soccer_brand_dictionary_non_empty():
|
||||
bd = SoccerBroadcastProfile().brand_dictionary()
|
||||
assert len(bd.brands) > 0
|
||||
for canonical, aliases in bd.brands.items():
|
||||
assert len(aliases) > 0
|
||||
|
||||
|
||||
def test_soccer_vlm_prompt():
|
||||
ctx = CropContext(image=b"fake", surrounding_text="Emirates", position_hint="top-center")
|
||||
prompt = SoccerBroadcastProfile().vlm_prompt(ctx)
|
||||
assert "brand" in prompt.lower()
|
||||
assert "Emirates" in prompt
|
||||
|
||||
|
||||
def test_soccer_aggregate_empty():
|
||||
report = SoccerBroadcastProfile().aggregate([])
|
||||
assert len(report.brands) == 0
|
||||
assert len(report.timeline) == 0
|
||||
|
||||
|
||||
def test_soccer_aggregate_groups():
|
||||
detections = [
|
||||
BrandDetection(brand="Nike", timestamp=1.0, duration=0.5, confidence=0.9, source="ocr"),
|
||||
BrandDetection(brand="Nike", timestamp=2.0, duration=0.5, confidence=0.8, source="ocr"),
|
||||
BrandDetection(brand="Adidas", timestamp=3.0, duration=0.5, confidence=0.7, source="logo_match"),
|
||||
]
|
||||
report = SoccerBroadcastProfile().aggregate(detections)
|
||||
assert "Nike" in report.brands
|
||||
assert "Adidas" in report.brands
|
||||
assert report.brands["Nike"].total_appearances == 2
|
||||
assert report.brands["Adidas"].total_appearances == 1
|
||||
assert report.timeline == sorted(report.timeline, key=lambda d: d.timestamp)
|
||||
|
||||
|
||||
def test_soccer_auxiliary_returns_empty():
|
||||
assert SoccerBroadcastProfile().auxiliary_detections("test.mp4") == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stub_cls", [NewsBroadcastProfile, AdvertisingProfile, TranscriptProfile])
|
||||
def test_stubs_raise(stub_cls):
|
||||
stub = stub_cls()
|
||||
assert isinstance(stub.name, str)
|
||||
with pytest.raises(NotImplementedError):
|
||||
stub.frame_extraction_config()
|
||||
with pytest.raises(NotImplementedError):
|
||||
stub.brand_dictionary()
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { SSEDataSource } from 'mpr-ui-framework'
|
||||
import { SSEDataSource, Panel, LayoutGrid } from 'mpr-ui-framework'
|
||||
import 'mpr-ui-framework/src/tokens.css'
|
||||
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 status = ref<'idle' | 'live' | 'processing' | 'error'>('idle')
|
||||
|
||||
const source = new SSEDataSource({
|
||||
id: 'detect-stream',
|
||||
@@ -23,8 +24,13 @@ source.on<StatsUpdate>('stats_update', (e) => {
|
||||
stats.value = e
|
||||
})
|
||||
|
||||
// Expose status reactively
|
||||
const checkStatus = () => { status.value = source.status.value }
|
||||
const statusMap: Record<string, 'idle' | 'live' | 'processing' | 'error'> = {
|
||||
idle: 'idle',
|
||||
connecting: 'processing',
|
||||
live: 'live',
|
||||
error: 'error',
|
||||
}
|
||||
const checkStatus = () => { status.value = statusMap[source.status.value] ?? 'idle' }
|
||||
setInterval(checkStatus, 500)
|
||||
|
||||
source.connect()
|
||||
@@ -34,39 +40,29 @@ source.connect()
|
||||
<div class="app">
|
||||
<header>
|
||||
<h1>Detection Pipeline</h1>
|
||||
<span class="status" :class="status">{{ status }}</span>
|
||||
<span class="status-badge" :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>
|
||||
<LayoutGrid :columns="2" :rows="1" gap="var(--space-2)">
|
||||
<Panel title="Stats" :status="status">
|
||||
<div class="stats" v-if="stats">
|
||||
<div class="stat" v-for="s in [
|
||||
{ label: 'Frames', value: stats.frames_extracted },
|
||||
{ label: 'After filter', value: stats.frames_after_scene_filter },
|
||||
{ label: 'Regions', value: stats.regions_detected },
|
||||
{ label: 'OCR resolved', value: stats.regions_resolved_by_ocr },
|
||||
{ label: 'Cloud calls', value: stats.cloud_llm_calls },
|
||||
{ label: 'Cost', value: `$${stats.estimated_cloud_cost_usd.toFixed(4)}` },
|
||||
]" :key="s.label">
|
||||
<span class="label">{{ s.label }}</span>
|
||||
<span class="value">{{ s.value }}</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>
|
||||
<div v-else class="empty">Waiting for stats...</div>
|
||||
</Panel>
|
||||
|
||||
<section class="logs">
|
||||
<h2>Log</h2>
|
||||
<Panel title="Log" :status="status">
|
||||
<div class="log-scroll">
|
||||
<div v-for="(log, i) in logs" :key="i" class="log-line" :class="log.level.toLowerCase()">
|
||||
<span class="ts">{{ log.ts }}</span>
|
||||
@@ -76,103 +72,86 @@ source.connect()
|
||||
</div>
|
||||
<div v-if="logs.length === 0" class="empty">Waiting for events...</div>
|
||||
</div>
|
||||
</section>
|
||||
</Panel>
|
||||
</LayoutGrid>
|
||||
</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;
|
||||
background: var(--surface-0);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--space-4);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 16px;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-3) 0;
|
||||
border-bottom: var(--panel-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
header h1 { font-size: 15px; font-weight: 600; }
|
||||
header h1 { font-size: var(--font-size-lg); font-weight: 600; }
|
||||
|
||||
.status {
|
||||
padding: 2px 8px;
|
||||
.status-badge {
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-size: var(--font-size-sm);
|
||||
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; }
|
||||
.status-badge.idle { background: var(--status-idle); }
|
||||
.status-badge.processing { background: var(--status-processing); color: #000; }
|
||||
.status-badge.live { background: var(--status-live); color: #000; }
|
||||
.status-badge.error { background: var(--status-error); color: #000; }
|
||||
|
||||
.job-id { color: var(--dim); font-size: 11px; margin-left: auto; }
|
||||
.job-id { color: var(--text-dim); font-size: var(--font-size-sm); margin-left: auto; }
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.stat {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
background: var(--surface-2);
|
||||
border-radius: var(--panel-radius);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
.stat .label { display: block; color: var(--dim); font-size: 11px; margin-bottom: 4px; }
|
||||
.stat .label { display: block; color: var(--text-dim); font-size: var(--font-size-sm); margin-bottom: var(--space-1); }
|
||||
.stat .value { font-size: 20px; font-weight: 600; }
|
||||
|
||||
.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;
|
||||
gap: var(--space-2);
|
||||
padding: 2px 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.log-line .ts { color: var(--dim); min-width: 80px; }
|
||||
.log-line .ts { color: var(--text-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); }
|
||||
.log-line .stage { color: var(--status-processing); min-width: 120px; }
|
||||
.log-line.info .level { color: var(--status-live); }
|
||||
.log-line.warning .level { color: var(--status-escalating); }
|
||||
.log-line.error .level { color: var(--status-error); }
|
||||
.log-line.debug .level { color: var(--text-dim); }
|
||||
|
||||
.empty { color: var(--dim); padding: 20px; text-align: center; }
|
||||
.empty { color: var(--text-dim); padding: var(--space-6); text-align: center; }
|
||||
</style>
|
||||
|
||||
32
ui/framework/src/components/LayoutGrid.vue
Normal file
32
ui/framework/src/components/LayoutGrid.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
columns?: number
|
||||
rows?: number
|
||||
gap?: string
|
||||
}>(), {
|
||||
columns: 2,
|
||||
rows: 2,
|
||||
gap: 'var(--space-2)',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="layout-grid"
|
||||
:style="{
|
||||
gridTemplateColumns: `repeat(${props.columns}, 1fr)`,
|
||||
gridTemplateRows: `repeat(${props.rows}, 1fr)`,
|
||||
gap: props.gap,
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout-grid {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
79
ui/framework/src/components/Panel.vue
Normal file
79
ui/framework/src/components/Panel.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string
|
||||
status?: 'idle' | 'live' | 'processing' | 'error'
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">{{ title }}</span>
|
||||
<span class="panel-status" :class="status ?? 'idle'" />
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="panel-overlay">
|
||||
<slot name="overlay" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
position: relative;
|
||||
background: var(--surface-1);
|
||||
border: var(--panel-border);
|
||||
border-radius: var(--panel-radius);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
height: var(--panel-header-height);
|
||||
padding: 0 var(--space-3);
|
||||
background: var(--surface-2);
|
||||
border-bottom: var(--panel-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.panel-status {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-left: auto;
|
||||
}
|
||||
.panel-status.idle { background: var(--status-idle); }
|
||||
.panel-status.live { background: var(--status-live); }
|
||||
.panel-status.processing { background: var(--status-processing); }
|
||||
.panel-status.error { background: var(--status-error); }
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.panel-overlay {
|
||||
position: absolute;
|
||||
inset: var(--panel-header-height) 0 0 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.panel-overlay > :deep(*) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -3,3 +3,7 @@ export { DataSource, type DataSourceStatus } from './datasources/DataSource'
|
||||
export { SSEDataSource } from './datasources/SSEDataSource'
|
||||
export { StaticDataSource } from './datasources/StaticDataSource'
|
||||
export { useDataSource } from './composables/useDataSource'
|
||||
|
||||
// Components
|
||||
export { default as Panel } from './components/Panel.vue'
|
||||
export { default as LayoutGrid } from './components/LayoutGrid.vue'
|
||||
|
||||
45
ui/framework/src/tokens.css
Normal file
45
ui/framework/src/tokens.css
Normal file
@@ -0,0 +1,45 @@
|
||||
/* Framework design tokens — retheme by replacing this file */
|
||||
:root {
|
||||
/* spacing scale (4px base) */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
|
||||
/* color — dark theme (observability UIs are always dark) */
|
||||
--surface-0: #0d0d0f;
|
||||
--surface-1: #16161a;
|
||||
--surface-2: #1e1e24;
|
||||
--surface-3: #26262f;
|
||||
--border: #2e2e38;
|
||||
|
||||
--text-primary: #e8e8f0;
|
||||
--text-secondary: #8888a0;
|
||||
--text-dim: #555568;
|
||||
|
||||
/* status colors */
|
||||
--status-idle: #555568;
|
||||
--status-live: #3ecf8e;
|
||||
--status-processing: #4f9cf9;
|
||||
--status-escalating: #f5a623;
|
||||
--status-error: #f06565;
|
||||
|
||||
/* confidence color scale (low → high) */
|
||||
--conf-low: #f06565;
|
||||
--conf-mid: #f5a623;
|
||||
--conf-high: #3ecf8e;
|
||||
|
||||
/* typography */
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
--font-ui: 'Inter', system-ui, sans-serif;
|
||||
--font-size-sm: 11px;
|
||||
--font-size-base: 13px;
|
||||
--font-size-lg: 15px;
|
||||
|
||||
/* panel chrome */
|
||||
--panel-radius: 6px;
|
||||
--panel-border: 1px solid var(--border);
|
||||
--panel-header-height: 36px;
|
||||
}
|
||||
Reference in New Issue
Block a user