This commit is contained in:
2026-03-28 09:40:07 -03:00
parent 0bd3888155
commit e46bbc419c
10 changed files with 508 additions and 49 deletions

View File

@@ -24,8 +24,10 @@ const logPanel = ref<{ clear: () => void } | null>(null)
// SSE connection + pipeline status
const {
jobId, stats, runContext, status, sseConnected, source,
stopPipeline, onJobStarted: sseJobStarted,
jobId, stats, runContext, status, paused, pauseAfterStage,
sseConnected, source,
stopPipeline, pausePipeline, resumePipeline, stepPipeline,
togglePauseAfterStage, onJobStarted: sseJobStarted,
} = useSSEConnection()
// Checkpoint frames + navigation
@@ -50,9 +52,9 @@ function setCheckpointFrame(index: number) {
}
// Wire job start to clear log panel
function onJobStarted(newJobId: string) {
function onJobStarted(newJobId: string, opts?: { pauseAfterStage?: boolean }) {
logPanel.value?.clear()
sseJobStarted(newJobId)
sseJobStarted(newJobId, opts)
}
</script>
@@ -62,20 +64,52 @@ function onJobStarted(newJobId: string) {
<header>
<h1>Detection Pipeline</h1>
<span class="status-badge" :class="status">{{ status }}</span>
<span v-if="runContext" class="run-info">
{{ runContext.run_type }} · run: {{ runContext.run_id }}
</span>
<button class="header-btn" title="Select source" @click="pipeline.openSourceSelector()">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M2 4h4l2 2h6v8H2V4z"/><path d="M2 4V2h12v2"/>
</svg>
</button>
<button
v-if="sseConnected && (status === 'live' || status === 'processing')"
class="header-btn stop-btn"
title="Stop pipeline"
@click="stopPipeline"
></button>
<!-- Transport controls visible when a pipeline is running -->
<div v-if="sseConnected && (status === 'live' || status === 'processing' || paused)" class="transport">
<button
v-if="paused"
class="header-btn play-btn"
title="Resume"
@click="resumePipeline"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><polygon points="2,1 11,6 2,11"/></svg>
</button>
<button
v-else
class="header-btn pause-btn"
title="Pause after current stage"
@click="pausePipeline"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><rect x="2" y="1" width="3" height="10"/><rect x="7" y="1" width="3" height="10"/></svg>
</button>
<button
class="header-btn step-btn"
title="Run one stage"
@click="stepPipeline"
:disabled="!paused"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><polygon points="1,1 7,6 1,11"/><rect x="8" y="1" width="2.5" height="10"/></svg>
</button>
<button
class="header-btn stop-btn"
title="Stop pipeline"
@click="stopPipeline"
>
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor"><rect width="10" height="10"/></svg>
</button>
<label class="pause-toggle" title="Pause after each stage">
<input type="checkbox" :checked="pauseAfterStage" @change="togglePauseAfterStage" />
<span>step</span>
</label>
</div>
<span v-if="paused" class="status-badge paused">PAUSED</span>
<span class="job-id">job: {{ jobId || '—' }}</span>
</header>
@@ -203,19 +237,14 @@ function onJobStarted(newJobId: string) {
</Panel>
<!-- === SOURCE SELECTOR MODE === -->
<SourceSelector v-else-if="pipeline.layoutMode === 'source_selector'" @job-started="onJobStarted" />
<SourceSelector v-else-if="pipeline.layoutMode === 'source_selector'" @job-started="(id: string, opts: any) => onJobStarted(id, opts)" />
</template>
</SplitPane>
<!-- Bottom bar: Log or Blob viewer depending on mode -->
<div class="log-row">
<template v-if="pipeline.layoutMode === 'source_selector'">
<!-- no log in source selector -->
</template>
<template v-else>
<LogPanel ref="logPanel" :source="source" :status="status" />
</template>
<!-- Bottom bar: Log (hidden in source selector) -->
<div v-if="pipeline.layoutMode !== 'source_selector'" class="log-row">
<LogPanel ref="logPanel" :source="source" :status="status" />
</div>
</div>
</template>
@@ -283,6 +312,16 @@ header h1 { font-size: var(--font-size-lg); font-weight: 600; }
background: var(--surface-3);
color: var(--text-primary);
}
.transport {
display: flex;
align-items: center;
gap: 2px;
}
.play-btn { color: var(--status-live); }
.pause-btn { color: var(--text-secondary); }
.step-btn { color: var(--text-secondary); }
.step-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.stop-btn {
background: var(--status-error);
color: #000;
@@ -293,6 +332,19 @@ header h1 { font-size: var(--font-size-lg); font-weight: 600; }
opacity: 0.8;
}
.pause-toggle {
display: flex;
align-items: center;
gap: 4px;
font-size: 10px;
color: var(--text-dim);
cursor: pointer;
margin-left: 4px;
}
.pause-toggle input { accent-color: var(--status-processing); }
.status-badge.paused { background: var(--status-processing); color: #000; }
.job-id { color: var(--text-dim); font-size: var(--font-size-sm); margin-left: auto; }
.stats-col {

View File

@@ -68,6 +68,9 @@ export function useSSEConnection() {
sseConnected.value = true
}
const paused = ref(false)
const pauseAfterStage = ref(false)
async function stopPipeline() {
if (!jobId.value) return
try {
@@ -75,11 +78,70 @@ export function useSSEConnection() {
} catch { /* ignore — UI will see the cancel event via SSE */ }
}
function onJobStarted(newJobId: string) {
async function pausePipeline() {
if (!jobId.value) return
try {
await fetch(`/api/detect/pause/${jobId.value}`, { method: 'POST' })
paused.value = true
} catch { /* ignore */ }
}
async function resumePipeline() {
if (!jobId.value) return
try {
await fetch(`/api/detect/resume/${jobId.value}`, { method: 'POST' })
paused.value = false
} catch { /* ignore */ }
}
async function stepPipeline() {
if (!jobId.value) return
try {
await fetch(`/api/detect/step/${jobId.value}`, { method: 'POST' })
paused.value = false // briefly unpaused, will re-pause after stage
} catch { /* ignore */ }
}
async function togglePauseAfterStage() {
if (!jobId.value) return
const next = !pauseAfterStage.value
try {
await fetch(`/api/detect/pause-after-stage/${jobId.value}?enabled=${next}`, { method: 'POST' })
pauseAfterStage.value = next
} catch { /* ignore */ }
}
// Poll pipeline status to track paused state
// (SSE doesn't emit pause events — the thread is blocked)
let statusPoll: ReturnType<typeof setInterval> | null = null
function startStatusPoll() {
if (statusPoll) return
statusPoll = setInterval(async () => {
if (!jobId.value) return
try {
const resp = await fetch(`/api/detect/status/${jobId.value}`)
if (!resp.ok) return
const data = await resp.json()
paused.value = data.status === 'paused'
} catch { /* ignore */ }
}, 1000)
}
function stopStatusPoll() {
if (statusPoll) {
clearInterval(statusPoll)
statusPoll = null
}
}
function onJobStarted(newJobId: string, opts?: { pauseAfterStage?: boolean }) {
jobId.value = newJobId
stats.value = null
runContext.value = null
status.value = 'processing'
paused.value = false
pauseAfterStage.value = opts?.pauseAfterStage ?? false
pipeline.reset()
pipeline.setStatus('running')
// Update URL without reload
@@ -91,16 +153,34 @@ export function useSSEConnection() {
source.setUrl(`/api/detect/stream/${newJobId}`)
source.connect()
sseConnected.value = true
startStatusPoll()
}
// Start polling if we already have an active job
if (jobId.value && sseConnected.value) {
startStatusPoll()
}
// Stop polling when job completes
source.on<{ report?: { status?: string } }>('job_complete', () => {
stopStatusPoll()
paused.value = false
})
return {
jobId,
stats,
runContext,
status,
paused,
pauseAfterStage,
sseConnected,
source: source as DataSource,
stopPipeline,
pausePipeline,
resumePipeline,
stepPipeline,
togglePauseAfterStage,
onJobStarted,
}
}

View File

@@ -17,6 +17,7 @@ const nodes = ref<GraphNode[]>([])
// Derive graph mode from pipeline layout mode
const graphMode = computed<GraphMode>(() => {
if (pipeline.layoutMode === 'source_selector') return 'observe'
if (pipeline.layoutMode === 'bbox_editor') return 'edit-isolated'
if (pipeline.layoutMode === 'stage_editor') return 'edit-in-pipeline'
return 'observe'
@@ -29,6 +30,20 @@ watch(stageNames, (names) => {
}
}, { immediate: true })
// Source selector: placeholders until a chunk is selected, then real stage names
const displayNodes = computed<GraphNode[]>(() => {
if (pipeline.layoutMode === 'source_selector') {
if (pipeline.sourceHasSelection) {
return stageNames.value.map((id) => ({ id, status: 'pending' as const }))
}
return Array.from({ length: 10 }, (_, i) => ({
id: `_placeholder_${i}`,
status: 'placeholder' as const,
}))
}
return nodes.value
})
props.source.on<{ nodes: GraphNode[] }>('graph_update', (e) => {
nodes.value = e.nodes
})
@@ -60,7 +75,7 @@ function onOpenStageEditor(stage: string) {
<template>
<Panel title="Pipeline" :status="status">
<GraphRenderer
:nodes="nodes"
:nodes="displayNodes"
:mode="graphMode"
:active-stage="pipeline.editorStage"
:region-stages="editableStages"

View File

@@ -25,15 +25,22 @@ const SOURCE_TYPE_LABELS: Record<string, string> = {
stream: 'STREAM',
}
interface ProfileInfo {
name: string
}
const sources = ref<SourceInfo[]>([])
const chunks = ref<ChunkInfo[]>([])
const profiles = ref<ProfileInfo[]>([])
const selectedSource = ref<string | null>(null)
const selectedChunk = ref<string | null>(null)
const selectedChunks = ref<Set<string>>(new Set())
const selectedProfile = ref('soccer_broadcast')
const loading = ref(false)
const running = ref(false)
const skipVlm = ref(false)
const skipCloud = ref(true)
const checkpoint = ref(true)
const pauseAfterStage = ref(false)
const logLevel = ref('INFO')
const error = ref<string | null>(null)
@@ -41,9 +48,19 @@ async function loadSources() {
loading.value = true
error.value = null
try {
const resp = await fetch('/api/detect/sources')
if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`)
sources.value = await resp.json()
const [srcResp, profResp] = await Promise.all([
fetch('/api/detect/sources'),
fetch('/api/detect/config/profiles'),
])
if (!srcResp.ok) throw new Error(`${srcResp.status} ${srcResp.statusText}`)
sources.value = await srcResp.json()
if (profResp.ok) {
profiles.value = await profResp.json()
if (profiles.value.length > 0 && !profiles.value.find(p => p.name === selectedProfile.value)) {
selectedProfile.value = profiles.value[0].name
}
}
} catch (e: any) {
error.value = `Failed to load sources: ${e.message}`
} finally {
@@ -53,7 +70,7 @@ async function loadSources() {
async function loadChunks(jobId: string) {
selectedSource.value = jobId
selectedChunk.value = null
selectedChunks.value = new Set()
chunks.value = []
try {
const resp = await fetch(`/api/detect/sources/${jobId}/chunks`)
@@ -64,8 +81,25 @@ async function loadChunks(jobId: string) {
}
}
function selectChunk(chunk: ChunkInfo) {
selectedChunk.value = chunk.key
function toggleChunk(chunk: ChunkInfo) {
const s = new Set(selectedChunks.value)
if (s.has(chunk.key)) {
s.delete(chunk.key)
} else {
s.add(chunk.key)
}
selectedChunks.value = s
pipeline.sourceHasSelection = s.size > 0
}
function selectAllChunks() {
selectedChunks.value = new Set(chunks.value.map(c => c.key))
pipeline.sourceHasSelection = true
}
function clearSelection() {
selectedChunks.value = new Set()
pipeline.sourceHasSelection = false
}
async function openPreview(chunk: ChunkInfo) {
@@ -83,24 +117,29 @@ async function openPreview(chunk: ChunkInfo) {
}
const emit = defineEmits<{
(e: 'job-started', jobId: string): void
(e: 'job-started', jobId: string, opts: { pauseAfterStage: boolean }): void
}>()
async function runPipeline() {
if (!selectedChunk.value) return
if (selectedChunks.value.size === 0) return
running.value = true
error.value = null
// Run first selected chunk (multi-run queuing is future work)
const videoPath = [...selectedChunks.value][0]
try {
const resp = await fetch('/api/detect/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
video_path: selectedChunk.value,
video_path: videoPath,
profile_name: selectedProfile.value,
checkpoint: checkpoint.value,
skip_vlm: skipVlm.value,
skip_cloud: skipCloud.value,
log_level: logLevel.value,
pause_after_stage: pauseAfterStage.value,
}),
})
if (!resp.ok) {
@@ -109,7 +148,7 @@ async function runPipeline() {
}
const data = await resp.json()
emit('job-started', data.job_id)
emit('job-started', data.job_id, { pauseAfterStage: pauseAfterStage.value })
} catch (e: any) {
error.value = `Failed to start pipeline: ${e.message}`
running.value = false
@@ -160,13 +199,20 @@ onMounted(loadSources)
<!-- Chunk list -->
<div class="source-section" v-if="chunks.length > 0">
<h3>Chunks</h3>
<div class="chunk-header">
<h3>Chunks</h3>
<span class="chunk-actions">
<button class="link-btn" @click="selectAllChunks">all</button>
<button class="link-btn" @click="clearSelection">none</button>
<span v-if="selectedChunks.size > 0" class="selection-count">{{ selectedChunks.size }} selected</span>
</span>
</div>
<div class="chunk-list">
<div
v-for="chunk in chunks"
:key="chunk.key"
:class="['chunk-item', { selected: selectedChunk === chunk.key }]"
@click="selectChunk(chunk)"
:class="['chunk-item', { selected: selectedChunks.has(chunk.key) }]"
@click="toggleChunk(chunk)"
>
<span class="chunk-name">{{ chunk.filename }}</span>
<span class="chunk-meta">
@@ -182,11 +228,18 @@ onMounted(loadSources)
</div>
<!-- Run options -->
<div class="run-options" v-if="selectedChunk">
<div class="run-options" v-if="selectedChunks.size > 0">
<h3>Run Options</h3>
<label>
Profile
<select v-model="selectedProfile" class="log-level-select">
<option v-for="p in profiles" :key="p.name" :value="p.name">{{ p.name.replace(/_/g, ' ') }}</option>
</select>
</label>
<label><input type="checkbox" v-model="checkpoint"> Checkpointing</label>
<label><input type="checkbox" v-model="skipVlm"> Skip VLM</label>
<label><input type="checkbox" v-model="skipCloud"> Skip Cloud</label>
<label><input type="checkbox" v-model="pauseAfterStage"> Pause after each stage</label>
<label>
Log level
<select v-model="logLevel" class="log-level-select">
@@ -195,10 +248,10 @@ onMounted(loadSources)
</select>
</label>
<div class="selected-path">{{ selectedChunk }}</div>
<div class="selected-path">{{ [...selectedChunks].join(', ') }}</div>
<button class="run-btn" @click="runPipeline" :disabled="running">
{{ running ? 'Starting...' : 'Run Pipeline' }}
{{ running ? 'Starting...' : selectedChunks.size === 1 ? 'Run Pipeline' : `Run Pipeline (${selectedChunks.size} chunks)` }}
</button>
</div>
@@ -239,6 +292,35 @@ onMounted(loadSources)
letter-spacing: 0.05em;
}
.chunk-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.chunk-actions {
display: flex;
align-items: center;
gap: var(--space-2);
}
.link-btn {
background: none;
border: none;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 10px;
cursor: pointer;
text-decoration: underline;
padding: 0;
}
.link-btn:hover { color: var(--text-primary); }
.selection-count {
font-size: 10px;
color: var(--text-secondary);
}
.source-list, .chunk-list {
max-height: 200px;
overflow-y: auto;

View File

@@ -24,6 +24,8 @@ export const usePipelineStore = defineStore('pipeline', () => {
const layoutMode = ref<string>('normal')
const editorStage = ref<string | null>(null)
const sourceHasSelection = ref(false)
const isRunning = computed(() => status.value === 'running')
const isPaused = computed(() => status.value === 'paused')
const canReplay = computed(() => checkpoints.value.length > 0)
@@ -76,12 +78,14 @@ export const usePipelineStore = defineStore('pipeline', () => {
function closeEditor() {
layoutMode.value = 'normal'
editorStage.value = null
sourceHasSelection.value = false
}
function reset() {
status.value = 'idle'
layoutMode.value = 'normal'
editorStage.value = null
sourceHasSelection.value = false
nodes.value = []
currentStage.value = null
runId.value = null
@@ -92,7 +96,7 @@ export const usePipelineStore = defineStore('pipeline', () => {
return {
jobId, status, nodes, currentStage, runId, parentJobId, runType,
checkpoints, error, layoutMode, editorStage,
checkpoints, error, layoutMode, editorStage, sourceHasSelection,
isRunning, isPaused, canReplay, isEditing,
setJob, setStatus, updateNodes, setRunContext, setCheckpoints, setError,
openSourceSelector, openBBoxEditor, openStageEditor, closeEditor, reset,