This commit is contained in:
2026-03-23 15:18:23 -03:00
parent 5ed876d694
commit b57da622cb
17 changed files with 554 additions and 103 deletions

View File

@@ -9,6 +9,7 @@ COPY framework/ ./framework/
COPY detection-app/ ./detection-app/
WORKDIR /ui/detection-app
ENV CI=true
RUN pnpm install
EXPOSE 5175

View File

@@ -3,6 +3,7 @@ import { ref } from 'vue'
import { SSEDataSource, Panel, LayoutGrid } from 'mpr-ui-framework'
import 'mpr-ui-framework/src/tokens.css'
import LogPanel from './panels/LogPanel.vue'
import FunnelPanel from './panels/FunnelPanel.vue'
import type { StatsUpdate } from './types/sse-contract'
const jobId = ref(new URLSearchParams(window.location.search).get('job') || 'test-job')
@@ -39,7 +40,7 @@ source.connect()
<span class="job-id">job: {{ jobId }}</span>
</header>
<LayoutGrid :columns="2" :rows="1" gap="var(--space-2)">
<LayoutGrid :columns="2" :rows="2" gap="var(--space-2)">
<Panel title="Stats" :status="status">
<div class="stats" v-if="stats">
<div class="stat" v-for="s in [
@@ -57,6 +58,8 @@ source.connect()
<div v-else class="empty">Waiting for stats...</div>
</Panel>
<FunnelPanel :source="source" :status="status" />
<LogPanel :source="source" :status="status" />
</LayoutGrid>
</div>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Panel } from 'mpr-ui-framework'
import TimeSeriesRenderer from 'mpr-ui-framework/src/renderers/TimeSeriesRenderer.vue'
import type { DataSource } from 'mpr-ui-framework'
import type { StatsUpdate } from '../types/sse-contract'
const props = defineProps<{
source: DataSource
status?: 'idle' | 'live' | 'processing' | 'error'
}>()
// Accumulate stats snapshots over time
const snapshots = ref<{ ts: number; stats: StatsUpdate }[]>([])
const startTime = Date.now() / 1000
props.source.on<StatsUpdate>('stats_update', (e) => {
snapshots.value.push({ ts: Date.now() / 1000 - startTime, stats: e })
})
const series = [
{ label: 'Frames', color: '#4f9cf9' },
{ label: 'After filter', color: '#3ecf8e' },
{ label: 'Regions', color: '#f5a623' },
{ label: 'OCR resolved', color: '#a78bfa' },
]
const chartData = computed(() => {
const timestamps = snapshots.value.map((s) => s.ts)
const frames = snapshots.value.map((s) => s.stats.frames_extracted)
const filtered = snapshots.value.map((s) => s.stats.frames_after_scene_filter)
const regions = snapshots.value.map((s) => s.stats.regions_detected)
const ocr = snapshots.value.map((s) => s.stats.regions_resolved_by_ocr)
return [timestamps, frames, filtered, regions, ocr] as const
})
</script>
<template>
<Panel title="Processing Funnel" :status="status">
<TimeSeriesRenderer
v-if="snapshots.length > 0"
:series="series"
:data="chartData"
:stacked="true"
/>
<div v-else class="empty">Waiting for stats...</div>
</Panel>
</template>
<style scoped>
.empty {
color: var(--text-dim);
padding: var(--space-6);
text-align: center;
}
</style>

View File

@@ -11,7 +11,8 @@
},
"dependencies": {
"vue": "^3.5",
"pinia": "^2.2"
"pinia": "^2.2",
"uplot": "^1.6"
},
"devDependencies": {
"typescript": "^5.6",

View File

@@ -11,6 +11,9 @@ importers:
pinia:
specifier: ^2.2
version: 2.3.1(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))
uplot:
specifier: ^1.6
version: 1.6.32
vue:
specifier: ^3.5
version: 3.5.30(typescript@5.9.3)
@@ -748,6 +751,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
uplot@1.6.32:
resolution: {integrity: sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==}
vite-node@2.1.9:
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -1460,6 +1466,8 @@ snapshots:
typescript@5.9.3: {}
uplot@1.6.32: {}
vite-node@2.1.9:
dependencies:
cac: 6.7.14

View File

@@ -10,3 +10,4 @@ export { default as LayoutGrid } from './components/LayoutGrid.vue'
// Renderers
export { default as LogRenderer } from './renderers/LogRenderer.vue'
export { default as TimeSeriesRenderer } from './renderers/TimeSeriesRenderer.vue'

View File

@@ -0,0 +1,101 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import uPlot from 'uplot'
import 'uplot/dist/uPlot.min.css'
export interface TimeSeriesSeries {
label: string
color: string
}
const props = withDefaults(defineProps<{
/** Array of series configs (label + color) */
series: TimeSeriesSeries[]
/** Data: [timestamps[], series1[], series2[], ...] */
data: uPlot.AlignedData
/** Chart title (optional) */
title?: string
/** Stacked area mode */
stacked?: boolean
}>(), {
stacked: false,
})
const container = ref<HTMLElement | null>(null)
let chart: uPlot | null = null
function buildOpts(): uPlot.Options {
const seriesOpts: uPlot.Series[] = [
{ label: 'Time' },
...props.series.map((s) => ({
label: s.label,
stroke: s.color,
fill: props.stacked ? s.color + '40' : undefined,
width: 2,
})),
]
return {
width: container.value?.clientWidth ?? 400,
height: container.value?.clientHeight ?? 200,
series: seriesOpts,
axes: [
{ stroke: '#555568', grid: { stroke: '#2e2e3822' } },
{ stroke: '#555568', grid: { stroke: '#2e2e3822' } },
],
cursor: { show: true },
legend: { show: true },
}
}
function createChart() {
if (!container.value) return
if (chart) chart.destroy()
chart = new uPlot(buildOpts(), props.data, container.value)
}
function resize() {
if (!chart || !container.value) return
chart.setSize({
width: container.value.clientWidth,
height: container.value.clientHeight,
})
}
watch(() => props.data, (newData) => {
if (chart) {
chart.setData(newData)
} else {
nextTick(createChart)
}
}, { deep: true })
onMounted(() => {
nextTick(createChart)
const observer = new ResizeObserver(resize)
if (container.value) observer.observe(container.value)
onUnmounted(() => {
observer.disconnect()
chart?.destroy()
chart = null
})
})
</script>
<template>
<div ref="container" class="timeseries-renderer" />
</template>
<style scoped>
.timeseries-renderer {
width: 100%;
height: 100%;
min-height: 150px;
}
.timeseries-renderer :deep(.u-legend) {
font-family: var(--font-mono);
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
</style>