init commit
This commit is contained in:
178
ui/framework/src/renderers/FrameRenderer.vue
Normal file
178
ui/framework/src/renderers/FrameRenderer.vue
Normal 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>
|
||||
317
ui/framework/src/renderers/GraphRenderer.vue
Normal file
317
ui/framework/src/renderers/GraphRenderer.vue
Normal 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>
|
||||
143
ui/framework/src/renderers/LogRenderer.vue
Normal file
143
ui/framework/src/renderers/LogRenderer.vue
Normal 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>
|
||||
122
ui/framework/src/renderers/TableRenderer.vue
Normal file
122
ui/framework/src/renderers/TableRenderer.vue
Normal 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>
|
||||
198
ui/framework/src/renderers/TimeSeriesRenderer.vue
Normal file
198
ui/framework/src/renderers/TimeSeriesRenderer.vue
Normal 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>
|
||||
Reference in New Issue
Block a user