init commit

This commit is contained in:
2026-04-12 07:19:48 -03:00
commit 9dbf89da02
111 changed files with 14925 additions and 0 deletions

View File

@@ -0,0 +1,178 @@
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
export interface FrameBBox {
x: number
y: number
w: number
h: number
confidence: number
label: string
resolved_brand?: string | null
source?: string | null
stage?: string | null
ocr_text?: string | null
}
export interface FrameOverlay {
/** Base64 encoded image (same dimensions as main image) */
src: string
label: string
visible: boolean
/** Opacity 0-1, default 0.5 */
opacity?: number
/** Image format — 'jpeg' (default) or 'png' (supports transparency) */
srcFormat?: 'jpeg' | 'png'
}
const props = defineProps<{
/** Base64 JPEG image */
imageSrc: string
/** Bounding boxes to overlay */
boxes: FrameBBox[]
/** Debug overlay layers (edge images, line visualizations, etc.) */
overlays?: FrameOverlay[]
}>()
const canvas = ref<HTMLCanvasElement | null>(null)
const container = ref<HTMLElement | null>(null)
function draw() {
const cvs = canvas.value
const ctr = container.value
if (!cvs || !ctr || !props.imageSrc) return
const ctx = cvs.getContext('2d')
if (!ctx) return
const img = new window.Image()
img.onload = () => {
cvs.width = ctr.clientWidth
cvs.height = ctr.clientHeight
const scale = Math.min(cvs.width / img.width, cvs.height / img.height)
const dx = (cvs.width - img.width * scale) / 2
const dy = (cvs.height - img.height * scale) / 2
ctx.clearRect(0, 0, cvs.width, cvs.height)
ctx.drawImage(img, dx, dy, img.width * scale, img.height * scale)
// Draw debug overlays (edge images, line visualizations)
drawOverlays(ctx, dx, dy, img.width * scale, img.height * scale)
// Draw bounding boxes on top
for (const box of props.boxes) {
const bx = dx + box.x * scale
const by = dy + box.y * scale
const bw = box.w * scale
const bh = box.h * scale
const color = sourceColor(box)
const resolved = box.resolved_brand || box.ocr_text
ctx.strokeStyle = color
ctx.lineWidth = 2
if (!resolved) {
ctx.setLineDash([4, 3])
}
ctx.strokeRect(bx, by, bw, bh)
ctx.setLineDash([])
}
}
img.src = `data:image/jpeg;base64,${props.imageSrc}`
}
/** Pending overlay images that need async loading */
const overlayCache = new Map<string, HTMLImageElement>()
function drawOverlays(ctx: CanvasRenderingContext2D, dx: number, dy: number, dw: number, dh: number) {
const layers = props.overlays ?? []
for (const layer of layers) {
if (!layer.visible || !layer.src) continue
const cached = overlayCache.get(layer.src)
if (cached && cached.complete) {
ctx.globalAlpha = layer.opacity ?? 0.5
ctx.drawImage(cached, dx, dy, dw, dh)
ctx.globalAlpha = 1.0
} else if (!cached) {
// Load async, redraw when ready
const overlay = new window.Image()
overlay.onload = () => draw()
overlay.src = `data:image/${layer.srcFormat ?? 'jpeg'};base64,${layer.src}`
overlayCache.set(layer.src, overlay)
}
}
}
const SOURCE_COLORS: Record<string, string> = {
yolo: '#f5a623', // yellow — raw detection
ocr: '#ff8c42', // orange — text extracted
ocr_matched: '#3ecf8e', // green — brand resolved
local_vlm: '#4f9cf9', // blue — VLM resolved
cloud_llm: '#a78bfa', // purple — cloud resolved
unresolved: '#e05252', // red — nothing matched
}
// CV region labels — distinct from source-based colors
const REGION_COLORS: Record<string, string> = {
edge_region: '#00bcd4', // cyan
contour_region: '#ffd54f', // yellow
color_region: '#e040fb', // magenta
candidate: '#4caf50', // green — passed readability
rejected: '#e05252', // red — failed readability
}
function sourceColor(box: FrameBBox): string {
if (REGION_COLORS[box.label]) return REGION_COLORS[box.label]
if (box.resolved_brand) return SOURCE_COLORS.ocr_matched
if (box.source && SOURCE_COLORS[box.source]) return SOURCE_COLORS[box.source]
return confidenceColor(box.confidence)
}
function confidenceColor(conf: number): string {
if (conf >= 0.7) return 'var(--conf-high)'
if (conf >= 0.4) return 'var(--conf-mid)'
return 'var(--conf-low)'
}
watch(() => [props.imageSrc, props.boxes, props.overlays], () => nextTick(draw), { deep: true })
onMounted(() => {
nextTick(draw)
const observer = new ResizeObserver(() => draw())
if (container.value) observer.observe(container.value)
onUnmounted(() => observer.disconnect())
})
</script>
<template>
<div ref="container" class="frame-renderer">
<canvas ref="canvas" />
<div v-if="!imageSrc" class="frame-empty">No frame</div>
</div>
</template>
<style scoped>
.frame-renderer {
width: 100%;
height: 100%;
min-height: 200px;
position: relative;
}
.frame-renderer canvas {
display: block;
width: 100%;
height: 100%;
}
.frame-empty {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-dim);
}
</style>

View File

@@ -0,0 +1,317 @@
<script setup lang="ts">
import { computed } from 'vue'
import { VueFlow } from '@vue-flow/core'
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
export interface GraphNode {
id: string
status: 'pending' | 'running' | 'done' | 'error' | 'skipped' | 'placeholder'
/** Whether a checkpoint exists at this stage */
hasCheckpoint?: boolean
/** Stage category (e.g. 'cv', 'ai', 'preprocessing') */
category?: string
/** Which editors are available for this stage */
availableEditors?: string[]
}
export type GraphMode = 'observe' | 'edit-in-pipeline' | 'edit-isolated'
const props = withDefaults(defineProps<{
nodes: GraphNode[]
/** Interaction mode — changes visual treatment and click behavior */
mode?: GraphMode
/** Currently edited stage (highlighted in edit modes) */
activeStage?: string | null
/** Stages that have a region editor (bbox/polygon) */
regionStages?: string[]
}>(), {
mode: 'observe',
activeStage: null,
})
const emit = defineEmits<{
'open-region-editor': [stage: string]
'open-stage-editor': [stage: string]
'node-click': [stage: string]
}>()
const regionStageSet = computed(() => new Set(props.regionStages ?? []))
const STATUS_COLORS: Record<string, string> = {
pending: 'var(--status-idle)',
running: 'var(--status-processing)',
done: 'var(--status-live)',
error: 'var(--status-error)',
skipped: '#4a6fa5',
placeholder: 'transparent',
}
function nodeAppearance(node: GraphNode) {
const isActive = node.id === props.activeStage
const mode = props.mode
// Edit-isolated: only the active node is fully visible
if (mode === 'edit-isolated' && !isActive) {
return {
color: 'var(--surface-3)',
textColor: 'var(--text-dim)',
opacity: 0.5,
outline: false,
}
}
// Edit-in-pipeline: active node highlighted, upstream dimmed, downstream normal
if (mode === 'edit-in-pipeline' && props.activeStage) {
const activeIdx = props.nodes.findIndex(n => n.id === props.activeStage)
const nodeIdx = props.nodes.findIndex(n => n.id === node.id)
if (isActive) {
return {
color: 'var(--status-processing)',
textColor: '#fff',
opacity: 1,
outline: true,
}
}
if (nodeIdx < activeIdx) {
// Upstream: frozen from checkpoint
return {
color: 'var(--surface-3)',
textColor: 'var(--text-secondary)',
opacity: 0.7,
outline: false,
}
}
}
// Placeholder: hollow, no text
if (node.status === 'placeholder') {
return {
color: 'transparent',
textColor: 'transparent',
opacity: 0.6,
outline: false,
}
}
// Default: observe mode or downstream in edit-in-pipeline
return {
color: STATUS_COLORS[node.status] ?? STATUS_COLORS.pending,
textColor: '#fff',
opacity: 1,
outline: isActive,
}
}
const flowNodes = computed(() =>
props.nodes.map((n, i) => {
const appearance = nodeAppearance(n)
return {
id: n.id,
type: 'stage',
position: { x: 20, y: i * 80 },
data: {
label: n.id.replace(/_/g, ' '),
status: n.status,
...appearance,
hasCheckpoint: n.hasCheckpoint ?? false,
hasStageEditor: regionStageSet.value.has(n.id),
isRunning: n.status === 'running',
isActive: n.id === props.activeStage,
},
}
})
)
const flowEdges = computed(() => {
const edges = []
for (let i = 0; i < props.nodes.length - 1; i++) {
const isActiveEdge = props.mode !== 'observe' && props.activeStage
&& props.nodes.findIndex(n => n.id === props.activeStage) > i
edges.push({
id: `${props.nodes[i].id}->${props.nodes[i + 1].id}`,
source: props.nodes[i].id,
target: props.nodes[i + 1].id,
animated: props.nodes[i].status === 'running',
style: {
stroke: isActiveEdge ? 'var(--text-dim)' : '#555568',
strokeDasharray: isActiveEdge ? '4 4' : undefined,
},
})
}
return edges
})
function onNodeClick(id: string) {
emit('node-click', id)
}
</script>
<template>
<div class="graph-renderer">
<VueFlow
:nodes="flowNodes"
:edges="flowEdges"
:fit-view-on-init="true"
:nodes-draggable="false"
:nodes-connectable="false"
:zoom-on-scroll="false"
:pan-on-scroll="false"
>
<template #node-stage="{ data, id }">
<div
class="stage-node"
:class="{
running: data.isRunning,
active: data.isActive,
outline: data.outline,
dimmed: data.opacity < 1,
placeholder: data.status === 'placeholder',
}"
:style="{
background: data.color,
color: data.textColor,
opacity: data.opacity,
}"
@click="onNodeClick(id)"
>
<span class="stage-label">{{ data.label }}</span>
<!-- Checkpoint indicator -->
<span v-if="data.hasCheckpoint" class="checkpoint-badge" title="Checkpoint available">
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
<circle cx="5" cy="5" r="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
<circle cx="5" cy="5" r="1.5"/>
</svg>
</span>
<span class="stage-actions">
<button
v-if="data.hasStageEditor"
class="stage-btn editor-btn"
title="Stage editor"
@click.stop="emit('open-region-editor', id)"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="5" cy="5" r="3.5"/><line x1="7.5" y1="7.5" x2="11" y2="11"/>
</svg>
</button>
<button
class="stage-btn config-btn"
title="Stage config"
@click.stop="emit('open-stage-editor', id)"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="6" cy="6" r="2"/><path d="M6 1v2M6 9v2M1 6h2M9 6h2M2.5 2.5l1.4 1.4M8.1 8.1l1.4 1.4M2.5 9.5l1.4-1.4M8.1 3.9l1.4-1.4"/>
</svg>
</button>
</span>
</div>
</template>
</VueFlow>
</div>
</template>
<style scoped>
.graph-renderer {
width: 100%;
height: 100%;
min-height: 200px;
}
.graph-renderer :deep(.vue-flow__background) {
background: transparent;
}
/* Hide default node styling — we use custom template */
.graph-renderer :deep(.vue-flow__node-stage) {
padding: 0;
border: none;
background: transparent;
border-radius: 0;
}
.stage-node {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: var(--panel-radius);
font-family: var(--font-mono);
font-size: var(--font-size-sm);
font-weight: 600;
min-width: 180px;
cursor: pointer;
transition: opacity 0.2s, box-shadow 0.2s;
}
.stage-node.running {
animation: node-pulse 1.5s infinite;
}
.stage-node.outline {
box-shadow: 0 0 0 2px var(--status-processing);
}
.stage-node.dimmed {
pointer-events: none;
}
.stage-node.placeholder {
border: 1px dashed var(--text-secondary);
background: transparent;
color: transparent;
pointer-events: none;
}
.stage-node.placeholder .stage-actions,
.stage-node.placeholder .checkpoint-badge {
display: none;
}
.stage-label {
flex: 1;
}
.checkpoint-badge {
opacity: 0.7;
display: flex;
align-items: center;
}
.stage-actions {
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.15s;
}
.stage-node:hover .stage-actions {
opacity: 1;
}
.stage-btn {
background: rgba(0, 0, 0, 0.15);
border: none;
border-radius: 3px;
width: 20px;
height: 20px;
font-size: 11px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: inherit;
}
.stage-btn:hover {
background: rgba(0, 0, 0, 0.3);
}
@keyframes node-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
</style>

View File

@@ -0,0 +1,143 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
export interface LogEntry {
level: string
stage: string
msg: string
ts: string
}
const props = withDefaults(defineProps<{
entries: LogEntry[]
rowHeight?: number
autoScroll?: boolean
}>(), {
rowHeight: 24,
autoScroll: true,
})
const container = ref<HTMLElement | null>(null)
const scrollTop = ref(0)
const containerHeight = ref(0)
const userScrolled = ref(false)
const visibleRange = computed(() => {
const start = Math.floor(scrollTop.value / props.rowHeight)
const visible = Math.ceil(containerHeight.value / props.rowHeight) + 2
return {
start: Math.max(0, start - 1),
end: Math.min(props.entries.length, start + visible),
}
})
const totalHeight = computed(() => props.entries.length * props.rowHeight)
const visibleEntries = computed(() =>
props.entries.slice(visibleRange.value.start, visibleRange.value.end).map((entry, i) => ({
...entry,
index: visibleRange.value.start + i,
}))
)
function onScroll(e: Event) {
const el = e.target as HTMLElement
scrollTop.value = el.scrollTop
// If user scrolled away from bottom, pause auto-scroll
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < props.rowHeight * 2
userScrolled.value = !atBottom
}
function scrollToBottom() {
if (container.value && props.autoScroll && !userScrolled.value) {
container.value.scrollTop = container.value.scrollHeight
}
}
watch(() => props.entries.length, () => {
nextTick(scrollToBottom)
})
onMounted(() => {
if (container.value) {
containerHeight.value = container.value.clientHeight
const observer = new ResizeObserver(([entry]) => {
containerHeight.value = entry.contentRect.height
})
observer.observe(container.value)
onUnmounted(() => observer.disconnect())
}
})
const levelClass = (level: string) => level.toLowerCase()
</script>
<template>
<div class="log-renderer" ref="container" @scroll="onScroll">
<div class="log-spacer" :style="{ height: totalHeight + 'px' }">
<div
class="log-viewport"
:style="{ transform: `translateY(${visibleRange.start * rowHeight}px)` }"
>
<div
v-for="entry in visibleEntries"
:key="entry.index"
class="log-row"
:class="levelClass(entry.level)"
:style="{ height: rowHeight + 'px' }"
>
<span class="log-ts">{{ entry.ts }}</span>
<span class="log-level">{{ entry.level }}</span>
<span class="log-stage">{{ entry.stage }}</span>
<span class="log-msg">{{ entry.msg }}</span>
</div>
</div>
</div>
<div v-if="entries.length === 0" class="log-empty">
Waiting for log events...
</div>
</div>
</template>
<style scoped>
.log-renderer {
overflow-y: auto;
height: 100%;
font-family: var(--font-mono);
font-size: 12px;
}
.log-spacer {
position: relative;
}
.log-viewport {
position: absolute;
left: 0;
right: 0;
}
.log-row {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-2);
line-height: 1;
}
.log-ts { color: var(--text-dim); min-width: 80px; flex-shrink: 0; }
.log-level { min-width: 56px; font-weight: 600; flex-shrink: 0; }
.log-stage { color: var(--status-processing); min-width: 120px; flex-shrink: 0; }
.log-msg { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.log-row.info .log-level { color: var(--status-live); }
.log-row.warning .log-level { color: var(--status-escalating); }
.log-row.error .log-level { color: var(--status-error); }
.log-row.debug .log-level { color: var(--text-dim); }
.log-empty {
color: var(--text-dim);
padding: var(--space-6);
text-align: center;
}
</style>

View File

@@ -0,0 +1,122 @@
<script setup lang="ts">
import { computed } from 'vue'
export interface TableColumn {
key: string
label: string
width?: string
}
const props = defineProps<{
columns: TableColumn[]
rows: Record<string, unknown>[]
sortKey?: string
sortDir?: 'asc' | 'desc'
}>()
const emits = defineEmits<{
sort: [key: string]
}>()
const sorted = computed(() => {
if (!props.sortKey) return props.rows
const key = props.sortKey
const dir = props.sortDir === 'desc' ? -1 : 1
return [...props.rows].sort((a, b) => {
const av = a[key] as number | string
const bv = b[key] as number | string
if (av < bv) return -1 * dir
if (av > bv) return 1 * dir
return 0
})
})
</script>
<template>
<div class="table-renderer">
<table>
<thead>
<tr>
<th
v-for="col in columns"
:key="col.key"
:style="{ width: col.width }"
@click="emits('sort', col.key)"
class="sortable"
>
{{ col.label }}
<span v-if="sortKey === col.key" class="sort-indicator">
{{ sortDir === 'desc' ? '' : '' }}
</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in sorted" :key="i">
<td v-for="col in columns" :key="col.key">
{{ row[col.key] }}
</td>
</tr>
<tr v-if="rows.length === 0">
<td :colspan="columns.length" class="empty">No detections yet</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
.table-renderer {
overflow: auto;
height: 100%;
font-family: var(--font-mono);
font-size: var(--font-size-sm);
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
th {
position: sticky;
top: 0;
background: var(--surface-2);
color: var(--text-secondary);
font-weight: 600;
text-align: left;
padding: var(--space-2) var(--space-3);
border-bottom: var(--panel-border);
cursor: pointer;
user-select: none;
}
th:hover {
color: var(--text-primary);
}
.sort-indicator {
font-size: 9px;
margin-left: 4px;
}
td {
padding: var(--space-1) var(--space-3);
border-bottom: 1px solid var(--surface-3);
white-space: normal;
word-break: break-word;
overflow: hidden;
text-overflow: ellipsis;
}
tr:hover td {
background: var(--surface-3);
}
.empty {
color: var(--text-dim);
text-align: center;
padding: var(--space-6);
}
</style>

View File

@@ -0,0 +1,198 @@
<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)
const zoomed = ref(false)
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' },
size: 40,
font: '10px monospace',
ticks: { size: 3 },
},
{
stroke: '#555568',
grid: { stroke: '#2e2e3822' },
size: 35,
font: '10px monospace',
ticks: { size: 3 },
},
],
cursor: { show: true },
legend: { show: true, live: false },
padding: [8, 8, 0, 0],
hooks: {
setScale: [(_self: uPlot, scaleKey: string) => {
if (scaleKey === 'x') zoomed.value = true
}],
},
}
}
function resetZoom() {
if (!chart) return
const data = chart.data
if (data && data[0] && data[0].length > 0) {
const min = data[0][0]
const max = data[0][data[0].length - 1]
chart.setScale('x', { min, max })
}
zoomed.value = false
}
function getLegendHeight(): number {
if (!container.value) return 0
const legend = container.value.querySelector('.u-legend') as HTMLElement | null
return legend ? legend.offsetHeight : 0
}
function createChart() {
if (!container.value) return
if (chart) chart.destroy()
chart = new uPlot(buildOpts(), props.data, container.value)
// Refit after legend renders
nextTick(() => resize())
}
function resize() {
if (!chart || !container.value) return
const legendH = getLegendHeight()
const availableH = container.value.clientHeight
// uPlot height = canvas height (chart sets total = canvas + legend)
const chartH = Math.max(60, availableH - legendH)
chart.setSize({
width: container.value.clientWidth,
height: chartH,
})
}
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 class="timeseries-wrapper">
<button v-if="zoomed" class="reset-zoom" @click="resetZoom" title="Reset zoom"></button>
<div ref="container" class="timeseries-renderer" />
</div>
</template>
<style scoped>
.timeseries-wrapper {
width: 100%;
height: 100%;
position: relative;
}
.reset-zoom {
position: absolute;
top: 4px;
right: 4px;
z-index: 20;
background: var(--surface-2);
border: 1px solid var(--surface-3);
border-radius: 4px;
color: var(--text-secondary);
font-size: 14px;
width: 24px;
height: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.7;
transition: opacity 0.15s;
}
.reset-zoom:hover {
opacity: 1;
color: var(--text-primary);
}
.timeseries-renderer {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* uPlot creates a .u-wrap for canvas + a .u-legend below it */
.timeseries-renderer :deep(.u-wrap) {
flex: 1;
min-height: 0;
}
.timeseries-renderer :deep(.u-legend) {
flex-shrink: 0;
}
.timeseries-renderer :deep(.u-legend) {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-secondary);
padding: 2px 0;
display: flex;
flex-wrap: wrap;
gap: 0 8px;
}
.timeseries-renderer :deep(.u-legend .u-series) {
display: inline-flex;
padding: 0;
}
</style>