phase 4
This commit is contained in:
@@ -9,6 +9,7 @@ COPY framework/ ./framework/
|
||||
COPY detection-app/ ./detection-app/
|
||||
|
||||
WORKDIR /ui/detection-app
|
||||
ENV CI=true
|
||||
RUN pnpm install
|
||||
|
||||
EXPOSE 5175
|
||||
|
||||
@@ -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>
|
||||
|
||||
56
ui/detection-app/src/panels/FunnelPanel.vue
Normal file
56
ui/detection-app/src/panels/FunnelPanel.vue
Normal 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>
|
||||
@@ -11,7 +11,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5",
|
||||
"pinia": "^2.2"
|
||||
"pinia": "^2.2",
|
||||
"uplot": "^1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6",
|
||||
|
||||
8
ui/framework/pnpm-lock.yaml
generated
8
ui/framework/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
101
ui/framework/src/renderers/TimeSeriesRenderer.vue
Normal file
101
ui/framework/src/renderers/TimeSeriesRenderer.vue
Normal 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>
|
||||
Reference in New Issue
Block a user