phase 7
This commit is contained in:
@@ -6,6 +6,7 @@ import LogPanel from './panels/LogPanel.vue'
|
||||
import FunnelPanel from './panels/FunnelPanel.vue'
|
||||
import PipelineGraphPanel from './panels/PipelineGraphPanel.vue'
|
||||
import FramePanel from './panels/FramePanel.vue'
|
||||
import BrandTablePanel from './panels/BrandTablePanel.vue'
|
||||
import type { StatsUpdate } from './types/sse-contract'
|
||||
|
||||
const jobId = ref(new URLSearchParams(window.location.search).get('job') || 'test-job')
|
||||
@@ -42,7 +43,7 @@ source.connect()
|
||||
<span class="job-id">job: {{ jobId }}</span>
|
||||
</header>
|
||||
|
||||
<LayoutGrid :columns="3" :rows="2" gap="var(--space-2)">
|
||||
<LayoutGrid :columns="3" :rows="3" gap="var(--space-2)">
|
||||
<Panel title="Stats" :status="status">
|
||||
<div class="stats" v-if="stats">
|
||||
<div class="stat" v-for="s in [
|
||||
@@ -66,6 +67,8 @@ source.connect()
|
||||
|
||||
<PipelineGraphPanel :source="source" :status="status" />
|
||||
|
||||
<BrandTablePanel :source="source" :status="status" />
|
||||
|
||||
<LogPanel :source="source" :status="status" />
|
||||
</LayoutGrid>
|
||||
</div>
|
||||
|
||||
57
ui/detection-app/src/panels/BrandTablePanel.vue
Normal file
57
ui/detection-app/src/panels/BrandTablePanel.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Panel } from 'mpr-ui-framework'
|
||||
import TableRenderer from 'mpr-ui-framework/src/renderers/TableRenderer.vue'
|
||||
import type { TableColumn } from 'mpr-ui-framework/src/renderers/TableRenderer.vue'
|
||||
import type { DataSource } from 'mpr-ui-framework'
|
||||
|
||||
const props = defineProps<{
|
||||
source: DataSource
|
||||
status?: 'idle' | 'live' | 'processing' | 'error'
|
||||
}>()
|
||||
|
||||
const columns: TableColumn[] = [
|
||||
{ key: 'brand', label: 'Brand', width: '120px' },
|
||||
{ key: 'confidence', label: 'Conf', width: '60px' },
|
||||
{ key: 'source', label: 'Source', width: '80px' },
|
||||
{ key: 'timestamp', label: 'Time', width: '60px' },
|
||||
{ key: 'content_type', label: 'Type', width: '100px' },
|
||||
{ key: 'frame_ref', label: 'Frame', width: '50px' },
|
||||
]
|
||||
|
||||
const rows = ref<Record<string, unknown>[]>([])
|
||||
const sortKey = ref('timestamp')
|
||||
const sortDir = ref<'asc' | 'desc'>('desc')
|
||||
|
||||
props.source.on<Record<string, unknown>>('detection', (e) => {
|
||||
rows.value.push({
|
||||
brand: e.brand,
|
||||
confidence: typeof e.confidence === 'number' ? (e.confidence as number).toFixed(2) : e.confidence,
|
||||
source: e.source,
|
||||
timestamp: typeof e.timestamp === 'number' ? (e.timestamp as number).toFixed(1) : e.timestamp,
|
||||
content_type: e.content_type,
|
||||
frame_ref: e.frame_ref,
|
||||
})
|
||||
})
|
||||
|
||||
function onSort(key: string) {
|
||||
if (sortKey.value === key) {
|
||||
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
sortKey.value = key
|
||||
sortDir.value = 'desc'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Panel title="Detections" :status="status">
|
||||
<TableRenderer
|
||||
:columns="columns"
|
||||
:rows="rows"
|
||||
:sort-key="sortKey"
|
||||
:sort-dir="sortDir"
|
||||
@sort="onSort"
|
||||
/>
|
||||
</Panel>
|
||||
</template>
|
||||
@@ -13,3 +13,4 @@ export { default as LogRenderer } from './renderers/LogRenderer.vue'
|
||||
export { default as TimeSeriesRenderer } from './renderers/TimeSeriesRenderer.vue'
|
||||
export { default as GraphRenderer } from './renderers/GraphRenderer.vue'
|
||||
export { default as FrameRenderer } from './renderers/FrameRenderer.vue'
|
||||
export { default as TableRenderer } from './renderers/TableRenderer.vue'
|
||||
|
||||
119
ui/framework/src/renderers/TableRenderer.vue
Normal file
119
ui/framework/src/renderers/TableRenderer.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<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;
|
||||
}
|
||||
|
||||
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;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
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: nowrap;
|
||||
}
|
||||
|
||||
tr:hover td {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
padding: var(--space-6);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user