phase 5: edge transforms, soleprint-ui rename, infra fixes

- pipeline edge transforms: stages can declare accepted_transforms,
  edges carry a transform dict, runner injects per-stage and nodes
  apply (e.g. invert_mask before edge detection); editable from UI
  via PUT /config/edge-transform
- rename mpr-ui-framework -> soleprint-ui (now an external package
  synced via .spr from /home/mariano/wdir/spr); add @vue-flow/core
  and uplot to detection-app so linked package resolves them
- Tiltfile guards kubectl context, k8s commands pin --context kind-mpr
- kind-config: gateway on hostPort 30080 (Caddy fronts mpr.local.ar)
- modelgen: pyproject.toml, .spr marker, dict default_factory support
This commit is contained in:
2026-04-29 05:31:08 -03:00
parent 55e83e4203
commit 020f3540d3
35 changed files with 414 additions and 1747 deletions

View File

@@ -38,6 +38,14 @@ class StageOutputHintInfo(BaseModel):
src_format: str = "png" src_format: str = "png"
class TransformOptionInfo(BaseModel):
key: str
type: str
default: object = False
label: str = ""
description: str = ""
class StageConfigInfo(BaseModel): class StageConfigInfo(BaseModel):
name: str name: str
label: str label: str
@@ -45,6 +53,7 @@ class StageConfigInfo(BaseModel):
category: str category: str
config_fields: list[dict] config_fields: list[dict]
output_hints: list[StageOutputHintInfo] = [] output_hints: list[StageOutputHintInfo] = []
accepted_transforms: list[TransformOptionInfo] = []
reads: list[str] reads: list[str]
writes: list[str] writes: list[str]
@@ -87,6 +96,51 @@ def get_pipeline_config(profile_name: str):
return profile["pipeline"] return profile["pipeline"]
class UpdateEdgeTransformRequest(BaseModel):
profile_name: str = "soccer_broadcast"
source_stage: str
target_stage: str
transform: dict
@router.put("/config/edge-transform")
def update_edge_transform(req: UpdateEdgeTransformRequest):
"""Update the transform on an edge in a profile's pipeline config."""
from uuid import UUID
from core.db.models import Profile
from core.db.connection import get_session
from sqlmodel import select
from fastapi import HTTPException
with get_session() as session:
stmt = select(Profile).where(Profile.name == req.profile_name)
profile = session.exec(stmt).first()
if not profile:
raise HTTPException(status_code=404, detail=f"Profile not found: {req.profile_name}")
pipeline = dict(profile.pipeline)
edges = pipeline.get("edges", [])
found = False
for edge in edges:
if edge.get("source") == req.source_stage and edge.get("target") == req.target_stage:
edge["transform"] = req.transform
found = True
break
if not found:
raise HTTPException(
status_code=404,
detail=f"Edge not found: {req.source_stage}{req.target_stage}",
)
pipeline["edges"] = edges
profile.pipeline = pipeline
session.commit()
return {"status": "updated", "edge": f"{req.source_stage}{req.target_stage}", "transform": req.transform}
@router.get("/config/stages", response_model=list[StageConfigInfo]) @router.get("/config/stages", response_model=list[StageConfigInfo])
def list_stage_configs(): def list_stage_configs():
"""Return the stage palette with config field metadata for the editor.""" """Return the stage palette with config field metadata for the editor."""
@@ -137,6 +191,13 @@ def _stage_to_info(stage) -> StageConfigInfo:
) )
for h in getattr(stage, "output_hints", []) for h in getattr(stage, "output_hints", [])
], ],
accepted_transforms=[
TransformOptionInfo(
key=t.key, type=t.type, default=t.default,
label=t.label, description=t.description,
)
for t in getattr(stage, "accepted_transforms", [])
],
reads=stage.io.reads, reads=stage.io.reads,
writes=stage.io.writes, writes=stage.io.writes,
) )

View File

@@ -54,7 +54,8 @@
}, },
{ {
"source": "field_segmentation", "source": "field_segmentation",
"target": "detect_edges" "target": "detect_edges",
"transform": {"invert_mask": true}
}, },
{ {
"source": "field_segmentation", "source": "field_segmentation",

View File

@@ -162,6 +162,16 @@ def node_detect_edges(state: DetectState) -> dict:
field_masks = state.get("field_masks", {}) field_masks = state.get("field_masks", {})
job_id = state.get("job_id") job_id = state.get("job_id")
# Apply edge transforms from upstream connections
edge_transforms = state.get("_edge_transforms", {})
for source_stage, transform in edge_transforms.items():
if transform.get("invert_mask") and field_masks:
import numpy as np
field_masks = {
seq: np.bitwise_not(mask) if mask is not None else None
for seq, mask in field_masks.items()
}
regions = detect_edge_regions( regions = detect_edge_regions(
frames, config, inference_url=INFERENCE_URL, job_id=job_id, frames, config, inference_url=INFERENCE_URL, job_id=job_id,
field_masks=field_masks, field_masks=field_masks,

View File

@@ -213,6 +213,13 @@ class PipelineRunner:
self.config = config self.config = config
self.do_checkpoint = checkpoint self.do_checkpoint = checkpoint
self.stage_sequence = _flatten_config(config, start_from) self.stage_sequence = _flatten_config(config, start_from)
# Build edge transform lookup: {target_stage: {source_stage: transform_dict}}
self._edge_transforms: dict[str, dict[str, dict]] = {}
for edge in config.edges:
if edge.transform:
if edge.target not in self._edge_transforms:
self._edge_transforms[edge.target] = {}
self._edge_transforms[edge.target][edge.source] = edge.transform
def invoke(self, state: DetectState) -> DetectState: def invoke(self, state: DetectState) -> DetectState:
"""Run the pipeline on the given state. Returns final state.""" """Run the pipeline on the given state. Returns final state."""
@@ -224,6 +231,14 @@ class PipelineRunner:
if check and check(): if check and check():
raise PipelineCancelled(f"Cancelled before {stage_name}") raise PipelineCancelled(f"Cancelled before {stage_name}")
# Inject edge transforms into state so the stage can read them.
# Compatible with LangGraph — just a state dict key.
transforms = self._edge_transforms.get(stage_name, {})
if transforms:
state["_edge_transforms"] = transforms
elif "_edge_transforms" in state:
del state["_edge_transforms"]
# 2. Run node function # 2. Run node function
node_fn = _NODE_FN_MAP.get(stage_name) node_fn = _NODE_FN_MAP.get(stage_name)
if node_fn is None: if node_fn is None:

View File

@@ -25,6 +25,7 @@ from core.detect.stages.models import (
StageDefinition, StageDefinition,
StageIO, StageIO,
StageOutputHint, StageOutputHint,
TransformOption,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -54,6 +55,9 @@ class FieldSegmentationStage(Stage):
output_hints=[ output_hints=[
StageOutputHint(key="mask_overlay_b64", type="overlay", label="Field mask", default_opacity=0.5, src_format="png"), StageOutputHint(key="mask_overlay_b64", type="overlay", label="Field mask", default_opacity=0.5, src_format="png"),
], ],
accepted_transforms=[
TransformOption(key="invert_mask", type="bool", default=False, label="Invert selection", description="Invert the mask so downstream stages look outside the detected area"),
],
) )

View File

@@ -35,6 +35,14 @@ class StageOutputHint(BaseModel):
default_opacity: float = 0.5 default_opacity: float = 0.5
src_format: str = "png" src_format: str = "png"
class TransformOption(BaseModel):
"""A transform the stage accepts on its incoming edges."""
key: str
type: str
default: Any = False
label: str = ""
description: str = ""
class StageDefinition(BaseModel): class StageDefinition(BaseModel):
"""Complete metadata for a pipeline stage.""" """Complete metadata for a pipeline stage."""
name: str name: str
@@ -44,6 +52,7 @@ class StageDefinition(BaseModel):
io: StageIO io: StageIO
config_fields: List[StageConfigField] = Field(default_factory=list) config_fields: List[StageConfigField] = Field(default_factory=list)
output_hints: List[StageOutputHint] = Field(default_factory=list) output_hints: List[StageOutputHint] = Field(default_factory=list)
accepted_transforms: List[TransformOption] = Field(default_factory=list)
tracks_element: Optional[str] = None tracks_element: Optional[str] = None
class FrameExtractionConfig(BaseModel): class FrameExtractionConfig(BaseModel):
@@ -105,6 +114,7 @@ class Edge(BaseModel):
source: str source: str
target: str target: str
condition: str = "" condition: str = ""
transform: Dict[str, Any] = Field(default_factory=dict)
class PipelineConfig(BaseModel): class PipelineConfig(BaseModel):
"""Pipeline graph topology + routing rules.""" """Pipeline graph topology + routing rules."""
@@ -112,4 +122,4 @@ class PipelineConfig(BaseModel):
profile_name: str profile_name: str
stages: List[StageRef] = Field(default_factory=list) stages: List[StageRef] = Field(default_factory=list)
edges: List[Edge] = Field(default_factory=list) edges: List[Edge] = Field(default_factory=list)
routing_rules: Dict[str, Any] routing_rules: Dict[str, Any] = Field(default_factory=dict)

View File

@@ -35,6 +35,16 @@ class StageIO:
optional_reads: List[str] = field(default_factory=list) optional_reads: List[str] = field(default_factory=list)
@dataclass
class TransformOption:
"""A transform the stage accepts on its incoming edges."""
key: str
type: str # "bool", "float", "int", "str"
default: Any = False
label: str = ""
description: str = ""
@dataclass @dataclass
class StageOutputHint: class StageOutputHint:
"""How to render a stage output in the compare/editor views.""" """How to render a stage output in the compare/editor views."""
@@ -55,6 +65,7 @@ class StageDefinition:
io: StageIO = field(default_factory=StageIO) io: StageIO = field(default_factory=StageIO)
config_fields: List[StageConfigField] = field(default_factory=list) config_fields: List[StageConfigField] = field(default_factory=list)
output_hints: List[StageOutputHint] = field(default_factory=list) output_hints: List[StageOutputHint] = field(default_factory=list)
accepted_transforms: List[TransformOption] = field(default_factory=list)
tracks_element: Optional[str] = None tracks_element: Optional[str] = None
@@ -129,10 +140,16 @@ class StageRef:
@dataclass @dataclass
class Edge: class Edge:
"""Connection between stages in the graph.""" """Connection between stages in the graph.
transform: per-edge data transformation spec. Flexible JSONB blob
type-checked by the consuming stage. E.g. {"invert_mask": true}
tells edge detection to invert the field segmentation mask.
"""
source: str source: str
target: str target: str
condition: str = "" condition: str = ""
transform: Dict[str, Any] = field(default_factory=dict)
@dataclass @dataclass
@@ -151,6 +168,7 @@ STAGE_VIEWS = [
StageConfigField, StageConfigField,
StageIO, StageIO,
StageOutputHint, StageOutputHint,
TransformOption,
StageDefinition, StageDefinition,
FrameExtractionConfig, FrameExtractionConfig,
SceneFilterConfig, SceneFilterConfig,

View File

@@ -4,9 +4,15 @@
allow_k8s_contexts('kind-mpr') allow_k8s_contexts('kind-mpr')
# Hard guard: Tilt deploys to whatever the shell's current kubectl context is,
# and allow_k8s_contexts auto-permits any local-looking (kind-*) cluster.
# Fail early instead of silently landing the workload in the wrong cluster.
if k8s_context() != 'kind-mpr':
fail("Wrong kubectl context: '%s'. Run: kubectl config use-context kind-mpr" % k8s_context())
# Create namespace first — kustomize includes it but Tilt may apply # Create namespace first — kustomize includes it but Tilt may apply
# all resources in parallel, causing "namespace not found" races # all resources in parallel, causing "namespace not found" races (pin cluster explicitly)
local('kubectl create namespace mpr --dry-run=client -o yaml | kubectl apply -f -') local('kubectl --context kind-mpr create namespace mpr --dry-run=client -o yaml | kubectl --context kind-mpr apply -f -')
# Apply k8s manifests via kustomize (dev overlay) # Apply k8s manifests via kustomize (dev overlay)
k8s_yaml(kustomize('k8s/overlays/dev')) k8s_yaml(kustomize('k8s/overlays/dev'))

View File

@@ -202,7 +202,7 @@ services:
- ../ui/detection-app/src:/app/src - ../ui/detection-app/src:/app/src
- ../ui/detection-app/vite.config.ts:/app/vite.config.ts - ../ui/detection-app/vite.config.ts:/app/vite.config.ts
- ../ui/detection-app/index.html:/app/index.html - ../ui/detection-app/index.html:/app/index.html
- ../ui/framework:/app/node_modules/mpr-ui-framework - ../ui/framework:/app/node_modules/soleprint-ui
volumes: volumes:
postgres-data: postgres-data:

View File

@@ -4,10 +4,10 @@ name: mpr
nodes: nodes:
- role: control-plane - role: control-plane
extraPortMappings: 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) # Gateway → routed via Caddy on port 80 (mpr.local.ar, k8s.mpr.local.ar)
- containerPort: 30080 - containerPort: 30080
hostPort: 80 hostPort: 30080
listenAddress: "127.0.0.2" listenAddress: "127.0.0.1"
protocol: TCP protocol: TCP
# Redis # Redis
- containerPort: 30379 - containerPort: 30379

7
modelgen/.spr Normal file
View File

@@ -0,0 +1,7 @@
name=soleprint-modelgen
version=0.2.0
type=pip
sha=0822761
mode=published
updated=2026-04-12T00:22:10Z
source=/home/mariano/wdir/spr/soleprint/station/tools/modelgen

View File

@@ -328,7 +328,12 @@ class PydanticGenerator(BaseGenerator):
if isinstance(default, Enum): if isinstance(default, Enum):
return f" = {default.__class__.__name__}.{default.name}" return f" = {default.__class__.__name__}.{default.name}"
if callable(default): if callable(default):
return " = Field(default_factory=list)" if "list" in str(default) else "" default_str = str(default)
if "list" in default_str:
return " = Field(default_factory=list)"
if "dict" in default_str:
return " = Field(default_factory=dict)"
return ""
return f" = {default!r}" return f" = {default!r}"
def _generate_from_config(self, config) -> str: def _generate_from_config(self, config) -> str:

16
modelgen/pyproject.toml Normal file
View File

@@ -0,0 +1,16 @@
[build-system]
requires = ["setuptools>=68.0"]
build-backend = "setuptools.build_meta"
[project]
name = "soleprint-modelgen"
version = "0.2.0"
description = "Multi-source, multi-target model code generator"
requires-python = ">=3.10"
dependencies = []
[project.scripts]
modelgen = "modelgen.__main__:main"
[tool.setuptools.packages.find]
include = ["modelgen*"]

View File

@@ -11,8 +11,10 @@
}, },
"dependencies": { "dependencies": {
"@techstark/opencv-js": "4.12.0-release.1", "@techstark/opencv-js": "4.12.0-release.1",
"mpr-ui-framework": "link:../framework", "@vue-flow/core": "^1.48.2",
"soleprint-ui": "link:../framework",
"pinia": "^2.2", "pinia": "^2.2",
"uplot": "^1.6",
"vue": "^3.5" "vue": "^3.5"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -11,12 +11,12 @@ importers:
'@techstark/opencv-js': '@techstark/opencv-js':
specifier: 4.12.0-release.1 specifier: 4.12.0-release.1
version: 4.12.0-release.1 version: 4.12.0-release.1
mpr-ui-framework:
specifier: link:../framework
version: link:../framework
pinia: pinia:
specifier: ^2.2 specifier: ^2.2
version: 2.3.1(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)) version: 2.3.1(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))
soleprint-ui:
specifier: link:../framework
version: link:../framework
vue: vue:
specifier: ^3.5 specifier: ^3.5
version: 3.5.30(typescript@5.9.3) version: 3.5.30(typescript@5.9.3)

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { Panel, ResizeHandle, SplitPane } from 'mpr-ui-framework' import { Panel, ResizeHandle, SplitPane } from 'soleprint-ui'
import 'mpr-ui-framework/src/tokens.css' import 'soleprint-ui/src/tokens.css'
import LogPanel from './panels/LogPanel.vue' import LogPanel from './panels/LogPanel.vue'
import FunnelPanel from './panels/FunnelPanel.vue' import FunnelPanel from './panels/FunnelPanel.vue'
import PipelineGraphPanel from './panels/PipelineGraphPanel.vue' import PipelineGraphPanel from './panels/PipelineGraphPanel.vue'

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { FrameOverlay } from 'mpr-ui-framework' import type { FrameOverlay } from 'soleprint-ui'
defineProps<{ defineProps<{
overlays: FrameOverlay[] overlays: FrameOverlay[]

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { ParameterEditor, useEditorExecution } from 'mpr-ui-framework' import { ParameterEditor, useEditorExecution } from 'soleprint-ui'
import type { ConfigField } from 'mpr-ui-framework' import type { ConfigField } from 'soleprint-ui'
import { import {
runEdgeDetectionTs, runEdgeDetectionTs,
runEdgeDetectionTsDebug, runEdgeDetectionTsDebug,
@@ -40,6 +40,9 @@ const emit = defineEmits<{
const fields = ref<ConfigField[]>([]) const fields = ref<ConfigField[]>([])
const values = ref<Record<string, unknown>>({}) const values = ref<Record<string, unknown>>({})
const transforms = ref<{ key: string; type: string; default: unknown; label: string; description: string }[]>([])
const transformValues = ref<Record<string, unknown>>({})
const edgeTargets = ref<string[]>([])
const statusText = ref<string | null>(null) const statusText = ref<string | null>(null)
const debugEnabled = ref(true) const debugEnabled = ref(true)
const processingIndex = ref<number | null>(null) const processingIndex = ref<number | null>(null)
@@ -159,6 +162,98 @@ const {
apply: applyDetection, onParameterChange, apply: applyDetection, onParameterChange,
} = useEditorExecution(executeDetection) } = useEditorExecution(executeDetection)
/**
* Load cached overlays from DB stage output + S3 overlay cache.
* Returns true if data was found and emitted, false if nothing cached.
*/
async function loadCachedOverlays(): Promise<boolean> {
if (!props.jobId || !props.frames?.length) return false
try {
// Load job detail to get stage outputs (inline overlays like field masks)
const jobResp = await fetch(`/api/detect/jobs/${props.jobId}`)
if (!jobResp.ok) return false
const jobDetail = await jobResp.json()
const stageOutput = jobDetail.stage_outputs?.[props.stage] ?? {}
// Check for inline overlay data (e.g. mask_overlays_by_frame)
const overlays_by_frame: Record<string, Record<string, string>> = {}
const regions_by_frame: Record<string, unknown[]> = {}
let foundData = false
// Find box data
for (const [key, val] of Object.entries(stageOutput)) {
if (key.endsWith('_by_frame') && !key.includes('overlay') && typeof val === 'object') {
for (const [seq, boxes] of Object.entries(val as Record<string, unknown>)) {
if (Array.isArray(boxes)) {
regions_by_frame[seq] = boxes
foundData = true
}
}
}
}
// Find inline overlay data
for (const [key, val] of Object.entries(stageOutput)) {
if (key.includes('overlay') && key.endsWith('_by_frame') && typeof val === 'object') {
for (const [seq, b64] of Object.entries(val as Record<string, string>)) {
if (!overlays_by_frame[seq]) overlays_by_frame[seq] = {}
const overlayKey = key.replace('s_by_frame', '_b64')
overlays_by_frame[seq][overlayKey] = b64
foundData = true
}
}
}
// Also try S3 overlay cache for each frame
if (!foundData || Object.keys(overlays_by_frame).length === 0) {
const { timelineId } = await import('../stores/pipeline').then(m => m.usePipelineStore())
if (timelineId) {
for (const frame of props.frames) {
try {
const oResp = await fetch(`/api/detect/overlays/${timelineId}/${props.jobId}/${props.stage}/${frame.seq}`)
if (oResp.ok) {
const oData = await oResp.json()
if (oData.overlays && Object.keys(oData.overlays).length > 0) {
overlays_by_frame[String(frame.seq)] = oData.overlays
foundData = true
}
}
} catch { /* ignore */ }
}
}
}
if (foundData) {
emit('replay-result', {
regions_by_frame: Object.keys(regions_by_frame).length > 0 ? regions_by_frame : undefined,
overlays_by_frame: Object.keys(overlays_by_frame).length > 0 ? overlays_by_frame : undefined,
})
return true
}
} catch { /* ignore */ }
return false
}
function onTransformChange(key: string, value: unknown) {
transformValues.value[key] = value
// Save to all outgoing edges from this stage
const transform = { ...transformValues.value }
for (const target of edgeTargets.value) {
fetch('/api/detect/config/edge-transform', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
profile_name: 'soccer_broadcast',
source_stage: props.stage,
target_stage: target,
transform,
}),
}).catch(() => {})
}
}
// --- Init --- // --- Init ---
onMounted(async () => { onMounted(async () => {
@@ -167,16 +262,50 @@ onMounted(async () => {
if (resp.ok) { if (resp.ok) {
const data = await resp.json() const data = await resp.json()
fields.value = data.config_fields ?? [] fields.value = data.config_fields ?? []
transforms.value = data.accepted_transforms ?? []
} }
} catch { /* use empty fields */ } } catch { /* use empty fields */ }
for (const f of fields.value) { for (const f of fields.value) {
values.value[f.name] = f.default values.value[f.name] = f.default
} }
for (const t of transforms.value) {
transformValues.value[t.key] = t.default
}
// Load current edge transform values from pipeline config
// Transforms are on outgoing edges (source === this stage)
if (transforms.value.length > 0) {
try {
const pResp = await fetch(`/api/detect/config/profiles/soccer_broadcast/pipeline`)
if (pResp.ok) {
const pipeline = await pResp.json()
const edges = pipeline.edges ?? []
// Find outgoing edges from this stage and merge their transforms
for (const edge of edges) {
if (edge.source === props.stage && edge.transform) {
for (const [k, v] of Object.entries(edge.transform)) {
if (k in transformValues.value) {
transformValues.value[k] = v
}
}
// Remember which targets we're connected to
if (!edgeTargets.value.includes(edge.target)) {
edgeTargets.value.push(edge.target)
}
}
}
}
} catch { /* ignore */ }
}
if (props.frameImage) { if (props.frameImage) {
// Try loading cached overlays first (from stage output or S3)
const loaded = await loadCachedOverlays()
if (!loaded) {
applyDetection() applyDetection()
} }
}
}) })
watch(() => props.frameImage, (newVal, oldVal) => { watch(() => props.frameImage, (newVal, oldVal) => {
@@ -220,7 +349,10 @@ function getTargetFrames() {
(props.selectionEnd ?? props.frames.length - 1) + 1, (props.selectionEnd ?? props.frames.length - 1) + 1,
) )
} }
return [{ seq: props.frameRef ?? 0, jpeg_b64: props.frameImage! }] if (props.frameImage) {
return [{ seq: props.frameRef ?? 0, jpeg_b64: props.frameImage }]
}
return []
} }
function edgeParams(): Partial<EdgeDetectionParams> { function edgeParams(): Partial<EdgeDetectionParams> {
@@ -482,6 +614,20 @@ async function runGenericServer() {
@update="onFieldUpdate" @update="onFieldUpdate"
@reset="resetDefaults" @reset="resetDefaults"
/> />
<div v-if="transforms.length > 0" class="transform-section">
<div class="transform-header">Connection</div>
<label v-for="t in transforms" :key="t.key" class="transform-option">
<input
v-if="t.type === 'bool'"
type="checkbox"
:checked="!!transformValues[t.key]"
@change="onTransformChange(t.key, !transformValues[t.key])"
/>
<span class="transform-label">{{ t.label || t.key }}</span>
<span v-if="t.description" class="transform-desc">{{ t.description }}</span>
</label>
</div>
</div> </div>
<div class="sliders-footer"> <div class="sliders-footer">
@@ -631,4 +777,43 @@ async function runGenericServer() {
font-size: 9px; font-size: 9px;
margin-left: auto; margin-left: auto;
} }
.transform-section {
margin-top: var(--space-3);
padding-top: var(--space-2);
border-top: 1px solid var(--surface-3);
}
.transform-header {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-dim);
margin-bottom: var(--space-2);
}
.transform-option {
display: flex;
align-items: flex-start;
gap: var(--space-2);
font-size: var(--font-size-sm);
color: var(--text-secondary);
cursor: pointer;
padding: var(--space-1) 0;
}
.transform-option input[type="checkbox"] {
accent-color: #00bcd4;
margin-top: 2px;
}
.transform-label {
font-weight: 500;
}
.transform-desc {
font-size: 10px;
color: var(--text-dim);
display: block;
}
</style> </style>

View File

@@ -1,6 +1,6 @@
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import type { DataSource } from 'mpr-ui-framework' import type { DataSource } from 'soleprint-ui'
import { usePipelineStore } from '../stores/pipeline' import { usePipelineStore } from '../stores/pipeline'
import { useStageRegistry } from './useStageRegistry' import { useStageRegistry } from './useStageRegistry'

View File

@@ -1,6 +1,6 @@
import { ref } from 'vue' import { ref } from 'vue'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import type { FrameOverlay, FrameBBox } from 'mpr-ui-framework' import type { FrameOverlay, FrameBBox } from 'soleprint-ui'
import type { StageResult } from '@/components/StageConfig.vue' import type { StageResult } from '@/components/StageConfig.vue'
export type RegionBox = { export type RegionBox = {

View File

@@ -1,6 +1,6 @@
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { SSEDataSource } from 'mpr-ui-framework' import { SSEDataSource } from 'soleprint-ui'
import type { DataSource } from 'mpr-ui-framework' import type { DataSource } from 'soleprint-ui'
import type { StatsUpdate, RunContext } from '../types/sse-contract' import type { StatsUpdate, RunContext } from '../types/sse-contract'
import { usePipelineStore } from '../stores/pipeline' import { usePipelineStore } from '../stores/pipeline'
@@ -24,8 +24,12 @@ export function useSSEConnection() {
const sseConnected = ref(false) const sseConnected = ref(false)
// No job selected and no hash route → open source selector // No job selected and no hash route → open source selector
if (!jobParam && !window.location.hash.replace(/^#\/?/, '')) { // Job selected but no hash route → go to compare (dashboard is for live runs)
const hashPath = window.location.hash.replace(/^#\/?/, '')
if (!jobParam && !hashPath) {
pipeline.openSourceSelector() pipeline.openSourceSelector()
} else if (jobParam && !hashPath) {
pipeline.openCompare()
} }
// Resolve timeline_id from job on init // Resolve timeline_id from job on init
@@ -137,6 +141,10 @@ export function useSSEConnection() {
if (!resp.ok) return if (!resp.ok) return
const data = await resp.json() const data = await resp.json()
paused.value = data.status === 'paused' paused.value = data.status === 'paused'
// Stop polling if the job is no longer active
if (data.status === 'idle') {
stopStatusPoll()
}
} catch { /* ignore */ } } catch { /* ignore */ }
}, 1000) }, 1000)
} }

View File

@@ -1,5 +1,5 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useRegistry } from 'mpr-ui-framework' import { useRegistry } from 'soleprint-ui'
export interface StageConfigField { export interface StageConfigField {
name: string name: string

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { Panel, TableRenderer } from 'mpr-ui-framework' import { Panel, TableRenderer } from 'soleprint-ui'
import type { TableColumn, DataSource } from 'mpr-ui-framework' import type { TableColumn, DataSource } from 'soleprint-ui'
const props = defineProps<{ const props = defineProps<{
source: DataSource source: DataSource

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue' import { ref, computed, watch, onMounted } from 'vue'
import { Panel, SplitPane, FrameRenderer } from 'mpr-ui-framework' import { Panel, SplitPane, FrameRenderer } from 'soleprint-ui'
import type { FrameBBox, FrameOverlay } from 'mpr-ui-framework' import type { FrameBBox, FrameOverlay } from 'soleprint-ui'
import type { Job, Checkpoint } from '@common/types/generated' import type { Job, Checkpoint } from '@common/types/generated'
import { usePipelineStore } from '../stores/pipeline' import { usePipelineStore } from '../stores/pipeline'
import { useStageRegistry } from '../composables/useStageRegistry' import { useStageRegistry } from '../composables/useStageRegistry'
@@ -222,12 +222,23 @@ function refreshOverlays() {
watch([currentSeq, selectedStage], refreshOverlays) watch([currentSeq, selectedStage], refreshOverlays)
// Sync overlay visibility/opacity from A controls → B overlays // Sync overlay visibility/opacity from A controls → B overlays
// Also share src if one side has it and the other doesn't
watch(overlaysA, (aList) => { watch(overlaysA, (aList) => {
for (const a of aList) { for (const a of aList) {
const b = overlaysB.value.find(o => o.label === a.label) const b = overlaysB.value.find(o => o.label === a.label)
if (b) { if (b) {
b.visible = a.visible b.visible = a.visible
b.opacity = a.opacity b.opacity = a.opacity
if (a.src && !b.src) b.src = a.src
}
}
}, { deep: true })
watch(overlaysB, (bList) => {
for (const b of bList) {
const a = overlaysA.value.find(o => o.label === b.label)
if (a) {
if (b.src && !a.src) a.src = b.src
} }
} }
}, { deep: true }) }, { deep: true })

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { Panel } from 'mpr-ui-framework' import { Panel } from 'soleprint-ui'
import type { DataSource } from 'mpr-ui-framework' import type { DataSource } from 'soleprint-ui'
import type { StatsUpdate, Detection } from '../types/sse-contract' import type { StatsUpdate, Detection } from '../types/sse-contract'
const props = defineProps<{ const props = defineProps<{

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { Panel, FrameRenderer } from 'mpr-ui-framework' import { Panel, FrameRenderer } from 'soleprint-ui'
import type { FrameBBox, FrameOverlay, DataSource } from 'mpr-ui-framework' import type { FrameBBox, FrameOverlay, DataSource } from 'soleprint-ui'
const props = defineProps<{ const props = defineProps<{
source: DataSource source: DataSource

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { Panel, TimeSeriesRenderer } from 'mpr-ui-framework' import { Panel, TimeSeriesRenderer } from 'soleprint-ui'
import type { DataSource } from 'mpr-ui-framework' import type { DataSource } from 'soleprint-ui'
import type { StatsUpdate } from '../types/sse-contract' import type { StatsUpdate } from '../types/sse-contract'
const props = defineProps<{ const props = defineProps<{

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { Panel, LogRenderer } from 'mpr-ui-framework' import { Panel, LogRenderer } from 'soleprint-ui'
import type { LogEntry, DataSource } from 'mpr-ui-framework' import type { LogEntry, DataSource } from 'soleprint-ui'
import type { LogEvent } from '../types/sse-contract' import type { LogEvent } from '../types/sse-contract'
const props = defineProps<{ const props = defineProps<{

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, computed, onMounted } from 'vue' import { ref, watch, computed, onMounted } from 'vue'
import { Panel, GraphRenderer } from 'mpr-ui-framework' import { Panel, GraphRenderer } from 'soleprint-ui'
import type { GraphNode, GraphMode, DataSource } from 'mpr-ui-framework' import type { GraphNode, GraphMode, DataSource } from 'soleprint-ui'
import { usePipelineStore } from '../stores/pipeline' import { usePipelineStore } from '../stores/pipeline'
import { useStageRegistry } from '../composables/useStageRegistry' import { useStageRegistry } from '../composables/useStageRegistry'
@@ -43,20 +43,13 @@ const graphMode = computed<GraphMode>(() => {
return 'observe' return 'observe'
}) })
// Initialize nodes from pipeline config when it loads // Initialize nodes from pipeline config — single source of truth for stage order
watch(pipelineConfig, (config) => { watch(pipelineConfig, (config) => {
if (config && config.stages.length > 0 && nodes.value.length === 0) { if (config && config.stages.length > 0 && nodes.value.length === 0) {
nodes.value = config.stages.map((s) => ({ id: s.name, status: 'pending' })) nodes.value = config.stages.map((s) => ({ id: s.name, status: 'pending' }))
} }
}, { immediate: true }) }, { immediate: true })
// Fallback: init from registry if no config loaded
watch(stageNames, (names) => {
if (names.length > 0 && nodes.value.length === 0) {
nodes.value = names.map((id) => ({ id, status: 'pending' }))
}
}, { immediate: true })
props.source.on<{ nodes: GraphNode[] }>('graph_update', (e) => { props.source.on<{ nodes: GraphNode[] }>('graph_update', (e) => {
nodes.value = e.nodes nodes.value = e.nodes
}) })

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { Panel } from 'mpr-ui-framework' import { Panel } from 'soleprint-ui'
import { usePipelineStore } from '../stores/pipeline' import { usePipelineStore } from '../stores/pipeline'
import type { Timeline, Job } from '@common/types/generated' import type { Timeline, Job } from '@common/types/generated'

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { Panel } from 'mpr-ui-framework' import { Panel } from 'soleprint-ui'
import type { DataSource } from 'mpr-ui-framework' import type { DataSource } from 'soleprint-ui'
import type { Detection } from '../types/sse-contract' import type { Detection } from '../types/sse-contract'
const props = defineProps<{ const props = defineProps<{

View File

@@ -86,9 +86,9 @@ export const usePipelineStore = defineStore('pipeline', () => {
} }
function closeEditor() { function closeEditor() {
// Reload to reinitialize panels with current job context // Go to compare view — the dashboard is for live runs only
const url = new URL(window.location.href) const url = new URL(window.location.href)
url.hash = '' url.hash = '#/compare'
if (jobId.value) { if (jobId.value) {
url.searchParams.set('job', jobId.value) url.searchParams.set('job', jobId.value)
} }

7
ui/framework/.spr Normal file
View File

@@ -0,0 +1,7 @@
name=soleprint-ui
version=0.1.0
type=npm
sha=0822761
mode=published
updated=2026-04-12T00:22:10Z
source=/home/mariano/wdir/spr/soleprint/common/ui

View File

@@ -1,5 +1,5 @@
{ {
"name": "mpr-ui-framework", "name": "soleprint-ui",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",

File diff suppressed because it is too large Load Diff