feat(stage-tamagotchi,stage-pages): initialize IO trace viewer
This commit is contained in:
@@ -117,6 +117,15 @@ const openDevtoolsWindow = useElectronEventaInvoke(electronOpenDevtoolsWindow)
|
||||
>
|
||||
{{ t('tamagotchi.settings.devtools.pages.markdown-stress.title') }}
|
||||
</ButtonBar>
|
||||
<ButtonBar
|
||||
mb-2
|
||||
icon="i-solar:chart-2-bold-duotone"
|
||||
text="IO Tracer"
|
||||
transition="all ease-in-out duration-250"
|
||||
@click="() => openDevtoolsWindow({ route: '/devtools/io-tracer' })"
|
||||
>
|
||||
IO Tracer
|
||||
</ButtonBar>
|
||||
<ButtonBar
|
||||
mb-2
|
||||
icon="i-solar:chart-square-bold-duotone"
|
||||
|
||||
@@ -0,0 +1,827 @@
|
||||
<script setup lang="ts">
|
||||
import type { IOSpan, IOSubsystem, IOTurn } from '@proj-airi/stage-shared'
|
||||
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
GAP_WARN_THRESHOLD_MS,
|
||||
LABEL_COL_WIDTH,
|
||||
MINIMAP_HEIGHT,
|
||||
ROW_HEIGHT,
|
||||
ROW_PADDING,
|
||||
SUBSYSTEM_CONFIG_MAP,
|
||||
TIME_AXIS_HEIGHT,
|
||||
} from '../io-tracer-types'
|
||||
|
||||
const props = defineProps<{
|
||||
turns: IOTurn[]
|
||||
selectedSpanId: string | null
|
||||
timeOrigin: number
|
||||
hiddenSubsystems: Set<IOSubsystem>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectSpan: [spanId: string | null]
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const scrollAreaRef = ref<HTMLDivElement>()
|
||||
const { width: containerWidth } = useElementSize(containerRef)
|
||||
const hoveredSpan = ref<{ span: IOSpan, turn: IOTurn, x: number, y: number } | null>(null)
|
||||
|
||||
const turns = computed(() => {
|
||||
return props.turns.toSorted((a, b) => a.startTs - b.startTs)
|
||||
})
|
||||
|
||||
const visibleSpans = computed(() => {
|
||||
const result: { span: IOSpan, turn: IOTurn }[] = []
|
||||
for (const turn of turns.value) {
|
||||
for (const span of turn.spans) {
|
||||
if (!props.hiddenSubsystems.has(span.subsystem))
|
||||
result.push({ span, turn })
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const viewStart = ref(0)
|
||||
const viewEnd = ref(1000)
|
||||
|
||||
const globalRange = computed(() => {
|
||||
if (visibleSpans.value.length === 0)
|
||||
return { min: props.timeOrigin, max: props.timeOrigin + 1000 }
|
||||
let min = Infinity
|
||||
let max = -Infinity
|
||||
for (const { span } of visibleSpans.value) {
|
||||
min = Math.min(min, span.startTs)
|
||||
max = Math.max(max, span.endTs ?? performance.now())
|
||||
}
|
||||
if (min === Infinity)
|
||||
return { min: props.timeOrigin, max: props.timeOrigin + 1000 }
|
||||
const pad = (max - min) * 0.05 || 50
|
||||
return { min: min - pad, max: max + pad }
|
||||
})
|
||||
|
||||
const chartWidth = computed(() => Math.max(1, containerWidth.value - LABEL_COL_WIDTH))
|
||||
|
||||
const minViewDuration = computed(() => globalRange.value.max - globalRange.value.min)
|
||||
const maxViewDuration = computed(() => Math.max(minViewDuration.value, 10))
|
||||
const minZoomDuration = 1
|
||||
|
||||
function clampViewport(start: number, end: number): { start: number, end: number } {
|
||||
let dur = end - start
|
||||
|
||||
if (dur > maxViewDuration.value)
|
||||
dur = maxViewDuration.value
|
||||
if (dur < minZoomDuration)
|
||||
dur = minZoomDuration
|
||||
|
||||
const range = globalRange.value
|
||||
|
||||
if (start < range.min) {
|
||||
start = range.min
|
||||
end = start + dur
|
||||
}
|
||||
if (end > range.max) {
|
||||
end = range.max
|
||||
start = end - dur
|
||||
}
|
||||
if (start < range.min)
|
||||
start = range.min
|
||||
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
function setViewport(start: number, end: number) {
|
||||
const clamped = clampViewport(start, end)
|
||||
viewStart.value = clamped.start
|
||||
viewEnd.value = clamped.end
|
||||
}
|
||||
|
||||
const hoveredTurnId = ref<string | null>(null)
|
||||
|
||||
interface TurnSeparator {
|
||||
type: 'turn-separator'
|
||||
y: number
|
||||
}
|
||||
|
||||
interface SpanRow {
|
||||
type: 'span'
|
||||
span: IOSpan
|
||||
turn: IOTurn
|
||||
subsystem: IOSubsystem
|
||||
y: number
|
||||
}
|
||||
|
||||
interface GapAnnotation {
|
||||
startTs: number
|
||||
endTs: number
|
||||
durationMs: number
|
||||
y: number
|
||||
}
|
||||
|
||||
type LayoutRow = TurnSeparator | SpanRow
|
||||
|
||||
const layout = computed(() => {
|
||||
const rows: LayoutRow[] = []
|
||||
const gapAnnotations: GapAnnotation[] = []
|
||||
let y = 0
|
||||
|
||||
const subsystemOrder: IOSubsystem[] = ['tts', 'playback']
|
||||
const ttsSubsystems = new Set<IOSubsystem>(['tts', 'playback'])
|
||||
let isFirstTurn = true
|
||||
|
||||
for (const turn of turns.value) {
|
||||
const turnSpans = turn.spans.filter(s => !props.hiddenSubsystems.has(s.subsystem))
|
||||
if (turnSpans.length === 0)
|
||||
continue
|
||||
|
||||
if (!isFirstTurn) {
|
||||
rows.push({ type: 'turn-separator', y })
|
||||
y += 1
|
||||
}
|
||||
isFirstTurn = false
|
||||
|
||||
const llmSpans = turnSpans.filter(s => s.subsystem === 'llm').sort((a, b) => a.startTs - b.startTs)
|
||||
const ttsSpanList = turnSpans.filter(s => ttsSubsystems.has(s.subsystem))
|
||||
|
||||
const segmentGroups = new Map<string, IOSpan[]>()
|
||||
for (const span of ttsSpanList) {
|
||||
const segId = span.ttsCorrelationId ?? span.id
|
||||
let group = segmentGroups.get(segId)
|
||||
if (!group) {
|
||||
group = []
|
||||
segmentGroups.set(segId, group)
|
||||
}
|
||||
group.push(span)
|
||||
}
|
||||
for (const group of segmentGroups.values())
|
||||
group.sort((a, b) => subsystemOrder.indexOf(a.subsystem) - subsystemOrder.indexOf(b.subsystem))
|
||||
|
||||
const sortedSegments = [...segmentGroups.values()]
|
||||
.sort((a, b) => a[0].startTs - b[0].startTs)
|
||||
|
||||
for (const span of llmSpans) {
|
||||
rows.push({ type: 'span', span, turn, subsystem: 'llm', y })
|
||||
y += ROW_HEIGHT
|
||||
}
|
||||
|
||||
for (const group of sortedSegments) {
|
||||
for (const span of group) {
|
||||
rows.push({ type: 'span', span, turn, subsystem: span.subsystem, y })
|
||||
y += ROW_HEIGHT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { rows, totalHeight: y, gapAnnotations }
|
||||
})
|
||||
|
||||
function timeToX(ts: number): number {
|
||||
const duration = viewEnd.value - viewStart.value
|
||||
if (duration <= 0)
|
||||
return 0
|
||||
return ((ts - viewStart.value) / duration) * chartWidth.value
|
||||
}
|
||||
|
||||
function xToTime(x: number): number {
|
||||
const duration = viewEnd.value - viewStart.value
|
||||
return viewStart.value + (x / chartWidth.value) * duration
|
||||
}
|
||||
|
||||
function spanBarX(span: IOSpan): number {
|
||||
return timeToX(span.startTs)
|
||||
}
|
||||
|
||||
function spanBarWidth(span: IOSpan): number {
|
||||
const end = span.endTs ?? performance.now()
|
||||
const x1 = timeToX(span.startTs)
|
||||
const x2 = timeToX(end)
|
||||
return Math.max(x2 - x1, 3)
|
||||
}
|
||||
|
||||
function isClippedLeft(span: IOSpan): boolean {
|
||||
return timeToX(span.startTs) < 0 && spanBarX(span) + spanBarWidth(span) > 0
|
||||
}
|
||||
|
||||
function isClippedRight(span: IOSpan): boolean {
|
||||
const end = span.endTs ?? performance.now()
|
||||
return timeToX(end) > chartWidth.value && timeToX(span.startTs) < chartWidth.value
|
||||
}
|
||||
|
||||
interface EdgeIndicator {
|
||||
subsystem: IOSubsystem
|
||||
side: 'left' | 'right'
|
||||
y: number
|
||||
spanId: string
|
||||
}
|
||||
|
||||
const edgeIndicators = computed(() => {
|
||||
const indicators: EdgeIndicator[] = []
|
||||
const vStart = viewStart.value
|
||||
const vEnd = viewEnd.value
|
||||
|
||||
for (const row of layout.value.rows) {
|
||||
if (row.type !== 'span')
|
||||
continue
|
||||
const span = row.span
|
||||
const spanEnd = span.endTs ?? performance.now()
|
||||
|
||||
if (spanEnd < vStart) {
|
||||
indicators.push({ subsystem: span.subsystem, side: 'left', y: row.y, spanId: span.id })
|
||||
}
|
||||
else if (span.startTs > vEnd) {
|
||||
indicators.push({ subsystem: span.subsystem, side: 'right', y: row.y, spanId: span.id })
|
||||
}
|
||||
}
|
||||
return indicators
|
||||
})
|
||||
|
||||
const ticks = computed(() => {
|
||||
const width = chartWidth.value
|
||||
if (width <= 0)
|
||||
return []
|
||||
const vStart = viewStart.value
|
||||
const vEnd = viewEnd.value
|
||||
const duration = vEnd - vStart
|
||||
if (duration <= 0)
|
||||
return []
|
||||
|
||||
const targetCount = Math.max(4, Math.floor(width / 120))
|
||||
let interval = duration / targetCount
|
||||
const mag = 10 ** Math.floor(Math.log10(interval))
|
||||
const norm = interval / mag
|
||||
if (norm < 1.5)
|
||||
interval = mag
|
||||
else if (norm < 3.5)
|
||||
interval = 2 * mag
|
||||
else if (norm < 7.5)
|
||||
interval = 5 * mag
|
||||
else interval = 10 * mag
|
||||
|
||||
const result: { x: number, label: string }[] = []
|
||||
const start = Math.ceil(vStart / interval) * interval
|
||||
for (let ts = start; ts <= vEnd; ts += interval) {
|
||||
result.push({ x: timeToX(ts), label: fmtMs(ts - props.timeOrigin) })
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
function minimapSpanX(span: IOSpan): number {
|
||||
const range = globalRange.value
|
||||
const dur = range.max - range.min
|
||||
if (dur <= 0)
|
||||
return 0
|
||||
return ((span.startTs - range.min) / dur) * chartWidth.value
|
||||
}
|
||||
|
||||
function minimapSpanW(span: IOSpan): number {
|
||||
const range = globalRange.value
|
||||
const dur = range.max - range.min
|
||||
if (dur <= 0)
|
||||
return 0
|
||||
const end = span.endTs ?? performance.now()
|
||||
return Math.max(((end - span.startTs) / dur) * chartWidth.value, 1)
|
||||
}
|
||||
|
||||
const minimapViewportX = computed(() => {
|
||||
const range = globalRange.value
|
||||
const dur = range.max - range.min
|
||||
if (dur <= 0)
|
||||
return 0
|
||||
return ((viewStart.value - range.min) / dur) * chartWidth.value
|
||||
})
|
||||
|
||||
const minimapViewportW = computed(() => {
|
||||
const range = globalRange.value
|
||||
const dur = range.max - range.min
|
||||
if (dur <= 0)
|
||||
return chartWidth.value
|
||||
return ((viewEnd.value - viewStart.value) / dur) * chartWidth.value
|
||||
})
|
||||
|
||||
const isDragging = ref(false)
|
||||
let dragStartX = 0
|
||||
let dragStartY = 0
|
||||
let dragStartScrollTop = 0
|
||||
let dragViewStart = 0
|
||||
let dragViewEnd = 0
|
||||
|
||||
function onChartMouseDown(e: MouseEvent) {
|
||||
hasUserInteracted = true
|
||||
isDragging.value = true
|
||||
dragStartX = e.clientX
|
||||
dragStartY = e.clientY
|
||||
dragStartScrollTop = scrollAreaRef.value?.scrollTop ?? 0
|
||||
dragViewStart = viewStart.value
|
||||
dragViewEnd = viewEnd.value
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
function onChartMouseMove(e: MouseEvent) {
|
||||
if (!isDragging.value)
|
||||
return
|
||||
const dx = e.clientX - dragStartX
|
||||
const timeDelta = -(dx / chartWidth.value) * (dragViewEnd - dragViewStart)
|
||||
setViewport(dragViewStart + timeDelta, dragViewEnd + timeDelta)
|
||||
|
||||
const dy = e.clientY - dragStartY
|
||||
if (scrollAreaRef.value)
|
||||
scrollAreaRef.value.scrollTop = dragStartScrollTop - dy
|
||||
}
|
||||
|
||||
function onChartMouseUp() {
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
function onChartWheel(e: WheelEvent) {
|
||||
e.preventDefault()
|
||||
hasUserInteracted = true
|
||||
const rect = containerRef.value?.getBoundingClientRect()
|
||||
if (!rect)
|
||||
return
|
||||
|
||||
const absDx = Math.abs(e.deltaX)
|
||||
const absDy = Math.abs(e.deltaY)
|
||||
|
||||
if (absDx > absDy && absDx > 1) {
|
||||
const viewDur = viewEnd.value - viewStart.value
|
||||
const timeDelta = (e.deltaX / chartWidth.value) * viewDur
|
||||
setViewport(viewStart.value + timeDelta, viewEnd.value + timeDelta)
|
||||
return
|
||||
}
|
||||
|
||||
if (absDy > 1) {
|
||||
const factor = e.deltaY > 0 ? 1.15 : 1 / 1.15
|
||||
const mouseX = e.clientX - rect.left - LABEL_COL_WIDTH
|
||||
const pivot = xToTime(mouseX)
|
||||
setViewport(
|
||||
pivot - (pivot - viewStart.value) * factor,
|
||||
pivot + (viewEnd.value - pivot) * factor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type MinimapDragMode = 'left-handle' | 'right-handle' | 'area-select' | null
|
||||
let minimapDragMode: MinimapDragMode = null
|
||||
let minimapDragStartX = 0
|
||||
let minimapDragStartViewStart = 0
|
||||
let minimapDragStartViewEnd = 0
|
||||
|
||||
const HANDLE_HIT_WIDTH = 8
|
||||
|
||||
function minimapHitTest(offsetX: number): 'left-handle' | 'right-handle' | 'area-select' {
|
||||
const leftEdge = minimapViewportX.value
|
||||
const rightEdge = minimapViewportX.value + minimapViewportW.value
|
||||
if (Math.abs(offsetX - leftEdge) <= HANDLE_HIT_WIDTH)
|
||||
return 'left-handle'
|
||||
if (Math.abs(offsetX - rightEdge) <= HANDLE_HIT_WIDTH)
|
||||
return 'right-handle'
|
||||
return 'area-select'
|
||||
}
|
||||
|
||||
function onMinimapMouseDown(e: MouseEvent) {
|
||||
minimapDragMode = minimapHitTest(e.offsetX)
|
||||
minimapDragStartX = e.offsetX
|
||||
minimapDragStartViewStart = viewStart.value
|
||||
minimapDragStartViewEnd = viewEnd.value
|
||||
|
||||
if (minimapDragMode === 'area-select') {
|
||||
const range = globalRange.value
|
||||
const dur = range.max - range.min
|
||||
const t = range.min + (e.offsetX / chartWidth.value) * dur
|
||||
viewStart.value = t
|
||||
viewEnd.value = t
|
||||
}
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
function onMinimapMouseMove(e: MouseEvent) {
|
||||
if (!minimapDragMode)
|
||||
return
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const x = Math.max(0, Math.min(e.clientX - rect.left, chartWidth.value))
|
||||
const range = globalRange.value
|
||||
const dur = range.max - range.min
|
||||
const t = range.min + (x / chartWidth.value) * dur
|
||||
|
||||
if (minimapDragMode === 'left-handle') {
|
||||
setViewport(Math.min(t, minimapDragStartViewEnd - minZoomDuration), minimapDragStartViewEnd)
|
||||
}
|
||||
else if (minimapDragMode === 'right-handle') {
|
||||
setViewport(minimapDragStartViewStart, Math.max(t, minimapDragStartViewStart + minZoomDuration))
|
||||
}
|
||||
else {
|
||||
const t1 = range.min + (minimapDragStartX / chartWidth.value) * dur
|
||||
setViewport(Math.min(t1, t), Math.max(t1, t))
|
||||
}
|
||||
}
|
||||
|
||||
function onMinimapMouseUp() {
|
||||
if (minimapDragMode) {
|
||||
if (minimapDragMode === 'area-select' && viewEnd.value - viewStart.value < 1)
|
||||
autoFit()
|
||||
minimapDragMode = null
|
||||
}
|
||||
}
|
||||
|
||||
const minimapCursor = ref<string>('crosshair')
|
||||
function onMinimapHover(e: MouseEvent) {
|
||||
if (minimapDragMode)
|
||||
return
|
||||
const hit = minimapHitTest(e.offsetX)
|
||||
minimapCursor.value = hit === 'left-handle' || hit === 'right-handle' ? 'ew-resize' : 'crosshair'
|
||||
}
|
||||
|
||||
const tooltipStyle = computed(() => {
|
||||
if (!hoveredSpan.value)
|
||||
return {}
|
||||
const { x, y } = hoveredSpan.value
|
||||
const maxX = (typeof globalThis.window !== 'undefined' ? globalThis.window.innerWidth : 1920) - 300
|
||||
const maxY = (typeof globalThis.window !== 'undefined' ? globalThis.window.innerHeight : 1080) - 120
|
||||
return {
|
||||
left: `${Math.min(x + 12, maxX)}px`,
|
||||
top: `${Math.min(y - 8, maxY)}px`,
|
||||
}
|
||||
})
|
||||
|
||||
function onSpanHover(span: IOSpan, turn: IOTurn, e: MouseEvent) {
|
||||
hoveredSpan.value = { span, turn, x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
function onSpanLeave() {
|
||||
hoveredSpan.value = null
|
||||
}
|
||||
|
||||
function onSpanClick(span: IOSpan) {
|
||||
emit('selectSpan', span.id === props.selectedSpanId ? null : span.id)
|
||||
}
|
||||
|
||||
let hasUserInteracted = false
|
||||
|
||||
function autoFit() {
|
||||
const { min, max } = globalRange.value
|
||||
setViewport(min, max)
|
||||
hasUserInteracted = false
|
||||
}
|
||||
|
||||
watch(() => props.turns, () => {
|
||||
if (visibleSpans.value.length > 0 && !hasUserInteracted)
|
||||
autoFit()
|
||||
}, { deep: false })
|
||||
|
||||
defineExpose({ autoFit })
|
||||
|
||||
function fmtMs(ms: number): string {
|
||||
if (ms < 0.01)
|
||||
return '0ms'
|
||||
if (ms < 1)
|
||||
return `${(ms * 1000).toFixed(0)}µs`
|
||||
if (ms < 1000)
|
||||
return `${ms.toFixed(ms < 10 ? 1 : 0)}ms`
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
function spanDuration(span: IOSpan): string {
|
||||
if (!span.endTs)
|
||||
return 'live'
|
||||
return fmtMs(span.endTs - span.startTs)
|
||||
}
|
||||
|
||||
function spanLabel(span: IOSpan): string {
|
||||
const subsystemLabel = SUBSYSTEM_CONFIG_MAP.get(span.subsystem)?.label ?? ''
|
||||
const text = span.meta.text
|
||||
if (text && typeof text === 'string') {
|
||||
const short = text.length > 30 ? `${text.slice(0, 30)}…` : text
|
||||
return `${subsystemLabel} · ${short}`
|
||||
}
|
||||
return `${subsystemLabel} · ${span.name}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
:class="['flex-1 flex flex-col overflow-hidden', 'select-none']"
|
||||
@mousemove="onChartMouseMove"
|
||||
@mouseup="onChartMouseUp"
|
||||
@mouseleave="onChartMouseUp"
|
||||
>
|
||||
<!-- ═══ Minimap ═══ -->
|
||||
<div
|
||||
:class="[
|
||||
'relative flex-shrink-0',
|
||||
'border-b border-neutral-200 dark:border-neutral-700',
|
||||
'bg-neutral-50 dark:bg-neutral-900',
|
||||
]"
|
||||
:style="{ height: `${MINIMAP_HEIGHT}px`, marginLeft: `${LABEL_COL_WIDTH}px`, cursor: minimapCursor }"
|
||||
@mousedown="onMinimapMouseDown"
|
||||
@mousemove="(e) => { onMinimapMouseMove(e); onMinimapHover(e) }"
|
||||
@mouseup="onMinimapMouseUp"
|
||||
@mouseleave="onMinimapMouseUp"
|
||||
>
|
||||
<div
|
||||
v-for="{ span } in visibleSpans"
|
||||
:key="`mm-${span.id}`"
|
||||
:class="['absolute rounded-sm pointer-events-none']"
|
||||
:style="{
|
||||
left: `${minimapSpanX(span)}px`,
|
||||
width: `${minimapSpanW(span)}px`,
|
||||
top: '4px',
|
||||
height: `${MINIMAP_HEIGHT - 8}px`,
|
||||
backgroundColor: SUBSYSTEM_CONFIG_MAP.get(span.subsystem)?.color ?? '#888',
|
||||
opacity: 0.6,
|
||||
}"
|
||||
/>
|
||||
<!-- Viewport selection -->
|
||||
<div
|
||||
:class="['absolute top-0 bottom-0 bg-blue-400/10 pointer-events-none']"
|
||||
:style="{ left: `${minimapViewportX}px`, width: `${Math.max(minimapViewportW, 2)}px` }"
|
||||
/>
|
||||
<!-- Left handle -->
|
||||
<div
|
||||
:class="['absolute top-0 bottom-0 w-1 bg-blue-400 pointer-events-none']"
|
||||
:style="{ left: `${minimapViewportX}px` }"
|
||||
>
|
||||
<div :class="['absolute top-1/2 -translate-y-1/2 -left-0.5 w-2 h-4 rounded-sm bg-blue-400']" />
|
||||
</div>
|
||||
<!-- Right handle -->
|
||||
<div
|
||||
:class="['absolute top-0 bottom-0 w-1 bg-blue-400 pointer-events-none']"
|
||||
:style="{ left: `${minimapViewportX + Math.max(minimapViewportW, 2)}px` }"
|
||||
>
|
||||
<div :class="['absolute top-1/2 -translate-y-1/2 -left-0.5 w-2 h-4 rounded-sm bg-blue-400']" />
|
||||
</div>
|
||||
<!-- Reset button -->
|
||||
<button
|
||||
v-if="viewStart !== globalRange.min || viewEnd !== globalRange.max"
|
||||
:class="[
|
||||
'absolute right-1 top-1 z-10 pointer-events-auto',
|
||||
'text-2.5 px-1.5 py-0.5 rounded',
|
||||
'bg-neutral-200 dark:bg-neutral-700',
|
||||
'hover:bg-neutral-300 dark:hover:bg-neutral-600',
|
||||
'text-neutral-600 dark:text-neutral-300',
|
||||
]"
|
||||
@click.stop="autoFit()"
|
||||
>
|
||||
Reset zoom
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Time Axis ═══ -->
|
||||
<div
|
||||
:class="['relative flex-shrink-0 border-b border-neutral-200 dark:border-neutral-700']"
|
||||
:style="{ height: `${TIME_AXIS_HEIGHT}px`, marginLeft: `${LABEL_COL_WIDTH}px` }"
|
||||
>
|
||||
<div
|
||||
v-for="(tick, i) in ticks"
|
||||
:key="i"
|
||||
:class="['absolute top-0 bottom-0 flex items-end pb-1']"
|
||||
:style="{ left: `${tick.x}px` }"
|
||||
>
|
||||
<span :class="['text-2.5 text-neutral-400 whitespace-nowrap -translate-x-1/2']">{{ tick.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Main Waterfall ═══ -->
|
||||
<div
|
||||
ref="scrollAreaRef"
|
||||
:class="['flex-1 overflow-y-auto relative', isDragging ? 'cursor-grabbing' : 'cursor-grab']"
|
||||
@mousedown="onChartMouseDown"
|
||||
@wheel="onChartWheel"
|
||||
>
|
||||
<div :style="{ height: `${layout.totalHeight}px`, position: 'relative' }">
|
||||
<template v-for="(row, ri) in layout.rows" :key="row.type === 'span' ? row.span.id : `row-${ri}`">
|
||||
<!-- ─── Turn Separator ─── -->
|
||||
<div
|
||||
v-if="row.type === 'turn-separator'"
|
||||
:class="['absolute left-0 right-0 bg-neutral-200 dark:bg-neutral-700']"
|
||||
:style="{ top: `${row.y}px`, height: '1px' }"
|
||||
/>
|
||||
|
||||
<!-- ─── Span Row ─── -->
|
||||
<div
|
||||
v-else-if="row.type === 'span'"
|
||||
:class="[
|
||||
'absolute left-0 right-0 flex items-center',
|
||||
'border-b border-neutral-50 dark:border-neutral-800/50',
|
||||
row.span.id === selectedSpanId
|
||||
? 'bg-blue-50 dark:bg-blue-950/30'
|
||||
: hoveredTurnId === row.turn.id
|
||||
? 'bg-neutral-50/80 dark:bg-neutral-800/20'
|
||||
: '',
|
||||
]"
|
||||
:style="{ top: `${row.y}px`, height: `${ROW_HEIGHT}px` }"
|
||||
@mouseenter="hoveredTurnId = row.turn.id"
|
||||
@mouseleave="hoveredTurnId = null"
|
||||
>
|
||||
<!-- Row label -->
|
||||
<div
|
||||
:class="['flex-shrink-0 flex items-center gap-1 px-3 text-2.5 text-neutral-500 truncate']"
|
||||
:style="{ width: `${LABEL_COL_WIDTH}px` }"
|
||||
>
|
||||
<div
|
||||
:class="['w-1.5 h-1.5 rounded-sm flex-shrink-0']"
|
||||
:style="{ backgroundColor: SUBSYSTEM_CONFIG_MAP.get(row.subsystem)?.color }"
|
||||
/>
|
||||
<span :class="['truncate']">{{ spanLabel(row.span) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Span bar area -->
|
||||
<div :class="['flex-1 relative h-full overflow-hidden']">
|
||||
<!-- Grid lines -->
|
||||
<div
|
||||
v-for="(tick, ti) in ticks"
|
||||
:key="ti"
|
||||
:class="['absolute top-0 bottom-0 w-px bg-neutral-100 dark:bg-neutral-800/60']"
|
||||
:style="{ left: `${tick.x}px` }"
|
||||
/>
|
||||
|
||||
<!-- Span bar -->
|
||||
<div
|
||||
:class="[
|
||||
'absolute rounded-sm cursor-pointer',
|
||||
row.span.id === selectedSpanId ? 'ring-2 ring-white dark:ring-neutral-900 ring-offset-1' : '',
|
||||
]"
|
||||
:style="{
|
||||
left: `${spanBarX(row.span)}px`,
|
||||
width: `${spanBarWidth(row.span)}px`,
|
||||
top: `${ROW_PADDING}px`,
|
||||
height: `${ROW_HEIGHT - ROW_PADDING * 2}px`,
|
||||
backgroundColor: SUBSYSTEM_CONFIG_MAP.get(row.subsystem)?.color ?? '#888',
|
||||
opacity: row.span.endTs ? 0.85 : 0.5,
|
||||
}"
|
||||
@mouseenter="onSpanHover(row.span, row.turn, $event)"
|
||||
@mouseleave="onSpanLeave"
|
||||
@click.stop="onSpanClick(row.span)"
|
||||
>
|
||||
<!-- TTFT marker -->
|
||||
<div
|
||||
v-if="row.span.meta.firstTokenTs"
|
||||
:class="['absolute top-0 bottom-0 w-0.5 bg-white/60']"
|
||||
:style="{ left: `${timeToX(row.span.meta.firstTokenTs) - spanBarX(row.span)}px` }"
|
||||
/>
|
||||
<!-- Duration inside bar -->
|
||||
<span
|
||||
v-if="spanBarWidth(row.span) > 44"
|
||||
:class="['absolute inset-0 flex items-center px-1.5 text-2.5 text-white font-medium truncate pointer-events-none']"
|
||||
>
|
||||
{{ spanDuration(row.span) }}
|
||||
</span>
|
||||
<!-- Fade gradient on left edge when clipped -->
|
||||
<div
|
||||
v-if="isClippedLeft(row.span)"
|
||||
:class="['absolute left-0 top-0 bottom-0 w-4 pointer-events-none']"
|
||||
:style="{ background: `linear-gradient(to right, ${SUBSYSTEM_CONFIG_MAP.get(row.subsystem)?.color ?? '#888'}, transparent)` }"
|
||||
/>
|
||||
<!-- Fade gradient on right edge when clipped -->
|
||||
<div
|
||||
v-if="isClippedRight(row.span)"
|
||||
:class="['absolute right-0 top-0 bottom-0 w-4 pointer-events-none']"
|
||||
:style="{ background: `linear-gradient(to left, ${SUBSYSTEM_CONFIG_MAP.get(row.subsystem)?.color ?? '#888'}, transparent)` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Duration outside bar -->
|
||||
<span
|
||||
v-if="spanBarWidth(row.span) <= 44 && row.span.endTs"
|
||||
:class="['absolute text-2.5 whitespace-nowrap text-neutral-400 pointer-events-none']"
|
||||
:style="{
|
||||
left: `${spanBarX(row.span) + spanBarWidth(row.span) + 4}px`,
|
||||
top: `${ROW_PADDING}px`,
|
||||
lineHeight: `${ROW_HEIGHT - ROW_PADDING * 2}px`,
|
||||
}"
|
||||
>
|
||||
{{ spanDuration(row.span) }}
|
||||
</span>
|
||||
|
||||
<!-- In-flight pulse -->
|
||||
<div
|
||||
v-if="!row.span.endTs"
|
||||
:class="['absolute rounded-sm animate-pulse']"
|
||||
:style="{
|
||||
left: `${spanBarX(row.span) + spanBarWidth(row.span) - 4}px`,
|
||||
width: '8px',
|
||||
top: `${ROW_PADDING}px`,
|
||||
height: `${ROW_HEIGHT - ROW_PADDING * 2}px`,
|
||||
backgroundColor: SUBSYSTEM_CONFIG_MAP.get(row.subsystem)?.color ?? '#888',
|
||||
opacity: 0.3,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ─── Edge Indicators ─── -->
|
||||
<template v-for="ind in edgeIndicators" :key="`edge-${ind.spanId}`">
|
||||
<!-- Left edge: span is off-screen to the left -->
|
||||
<div
|
||||
v-if="ind.side === 'left'"
|
||||
:class="['absolute pointer-events-none']"
|
||||
:style="{
|
||||
left: `${LABEL_COL_WIDTH}px`,
|
||||
top: `${ind.y + ROW_PADDING}px`,
|
||||
height: `${ROW_HEIGHT - ROW_PADDING * 2}px`,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
:class="['w-0 h-0']"
|
||||
:style="{
|
||||
borderTop: `${(ROW_HEIGHT - ROW_PADDING * 2) / 2}px solid transparent`,
|
||||
borderBottom: `${(ROW_HEIGHT - ROW_PADDING * 2) / 2}px solid transparent`,
|
||||
borderRight: `6px solid ${SUBSYSTEM_CONFIG_MAP.get(ind.subsystem)?.color ?? '#888'}`,
|
||||
opacity: 0.5,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<!-- Right edge: span is off-screen to the right -->
|
||||
<div
|
||||
v-if="ind.side === 'right'"
|
||||
:class="['absolute pointer-events-none']"
|
||||
:style="{
|
||||
right: '0px',
|
||||
top: `${ind.y + ROW_PADDING}px`,
|
||||
height: `${ROW_HEIGHT - ROW_PADDING * 2}px`,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
:class="['w-0 h-0']"
|
||||
:style="{
|
||||
borderTop: `${(ROW_HEIGHT - ROW_PADDING * 2) / 2}px solid transparent`,
|
||||
borderBottom: `${(ROW_HEIGHT - ROW_PADDING * 2) / 2}px solid transparent`,
|
||||
borderLeft: `6px solid ${SUBSYSTEM_CONFIG_MAP.get(ind.subsystem)?.color ?? '#888'}`,
|
||||
opacity: 0.5,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ─── Gap Annotations ─── -->
|
||||
<div
|
||||
v-for="(gap, gi) in layout.gapAnnotations"
|
||||
:key="`gap-${gi}`"
|
||||
:class="['absolute pointer-events-none flex items-center']"
|
||||
:style="{
|
||||
left: `${LABEL_COL_WIDTH + timeToX(gap.startTs)}px`,
|
||||
width: `${Math.max(timeToX(gap.endTs) - timeToX(gap.startTs), 20)}px`,
|
||||
top: `${gap.y}px`,
|
||||
height: `${ROW_HEIGHT}px`,
|
||||
}"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'text-2.5 px-1 py-0.5 rounded whitespace-nowrap mx-auto',
|
||||
gap.durationMs > GAP_WARN_THRESHOLD_MS
|
||||
? 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 font-medium'
|
||||
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-500',
|
||||
]"
|
||||
>
|
||||
+{{ fmtMs(gap.durationMs) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-if="visibleSpans.length === 0"
|
||||
:class="['absolute inset-0 flex flex-col items-center justify-center text-neutral-400 text-sm']"
|
||||
>
|
||||
<div class="i-solar:chart-2-bold-duotone mb-3 h-16 w-16 opacity-20" />
|
||||
<span :class="['font-medium']">No trace data</span>
|
||||
<span :class="['text-xs mt-1 text-neutral-400/70']">Start recording and trigger a voice conversation</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Tooltip ═══ -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="hoveredSpan"
|
||||
:class="[
|
||||
'fixed z-[9999] pointer-events-none',
|
||||
'px-3 py-2 rounded-lg shadow-xl',
|
||||
'bg-neutral-800 dark:bg-neutral-950 text-white',
|
||||
'text-xs max-w-72 border border-neutral-700',
|
||||
]"
|
||||
:style="tooltipStyle"
|
||||
>
|
||||
<div :class="['flex items-center gap-1.5 mb-1']">
|
||||
<div :class="['w-2 h-2 rounded-sm']" :style="{ backgroundColor: SUBSYSTEM_CONFIG_MAP.get(hoveredSpan.span.subsystem)?.color }" />
|
||||
<span :class="['font-medium']">{{ SUBSYSTEM_CONFIG_MAP.get(hoveredSpan.span.subsystem)?.label }}</span>
|
||||
<span :class="['text-neutral-400']">{{ hoveredSpan.span.name }}</span>
|
||||
</div>
|
||||
<div :class="['flex items-center gap-2 text-neutral-300']">
|
||||
<span v-if="hoveredSpan.span.endTs">{{ fmtMs(hoveredSpan.span.endTs - hoveredSpan.span.startTs) }}</span>
|
||||
<span v-else :class="['text-amber-400']">In progress...</span>
|
||||
<span v-if="hoveredSpan.span.meta.ttftMs" :class="['text-purple-300']">TTFT {{ fmtMs(hoveredSpan.span.meta.ttftMs) }}</span>
|
||||
</div>
|
||||
<div v-if="hoveredSpan.span.meta.text" :class="['text-neutral-400 mt-1 break-words']">
|
||||
{{ hoveredSpan.span.meta.text.length > 80 ? `${hoveredSpan.span.meta.text.slice(0, 80)}…` : hoveredSpan.span.meta.text }}
|
||||
</div>
|
||||
<div v-if="hoveredSpan.span.meta.reason" :class="['text-amber-300/80 mt-0.5']">
|
||||
chunk: {{ hoveredSpan.span.meta.reason }}
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import type { IOSubsystem } from '@proj-airi/stage-shared'
|
||||
|
||||
import { Button } from '@proj-airi/ui'
|
||||
|
||||
import { SUBSYSTEM_CONFIG_MAP } from '../io-tracer-types'
|
||||
|
||||
defineProps<{
|
||||
isRecording: boolean
|
||||
turnCount: number
|
||||
spanCount: number
|
||||
hiddenSubsystems: Set<IOSubsystem>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleRecording: []
|
||||
clear: []
|
||||
autoFit: []
|
||||
toggleSubsystem: [subsystem: IOSubsystem]
|
||||
exportOtlp: []
|
||||
}>()
|
||||
|
||||
const ttsSubsystems: { subsystem: IOSubsystem, label: string }[] = [
|
||||
{ subsystem: 'tts', label: 'TTS' },
|
||||
{ subsystem: 'playback', label: 'Play' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['flex items-center gap-2', 'px-3 py-2', 'border-b border-neutral-200 dark:border-neutral-700']">
|
||||
<Button
|
||||
:class="[
|
||||
'flex items-center gap-1.5',
|
||||
isRecording ? 'text-red-500' : '',
|
||||
]"
|
||||
@click="emit('toggleRecording')"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'w-2.5 h-2.5 rounded-full',
|
||||
isRecording ? 'bg-red-500 animate-pulse' : 'bg-neutral-400',
|
||||
]"
|
||||
/>
|
||||
{{ isRecording ? 'Stop' : 'Record' }}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
:disabled="turnCount === 0"
|
||||
@click="emit('clear')"
|
||||
>
|
||||
<div class="i-solar:trash-bin-trash-bold-duotone h-4 w-4" />
|
||||
Clear
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
:disabled="turnCount === 0"
|
||||
@click="emit('autoFit')"
|
||||
>
|
||||
<div class="i-solar:maximize-square-bold-duotone h-4 w-4" />
|
||||
Fit
|
||||
</Button>
|
||||
|
||||
<!-- TTS Subsystem Toggles -->
|
||||
<div :class="['w-px h-4 bg-neutral-200 dark:bg-neutral-700 mx-1']" />
|
||||
<span :class="['text-2.5 text-neutral-400']">TTS:</span>
|
||||
<button
|
||||
v-for="item in ttsSubsystems"
|
||||
:key="item.subsystem"
|
||||
:class="[
|
||||
'text-2.5 px-1.5 py-0.5 rounded',
|
||||
'border',
|
||||
hiddenSubsystems.has(item.subsystem)
|
||||
? 'border-neutral-200 dark:border-neutral-700 text-neutral-400 bg-transparent'
|
||||
: 'border-transparent text-white',
|
||||
]"
|
||||
:style="hiddenSubsystems.has(item.subsystem) ? {} : { backgroundColor: SUBSYSTEM_CONFIG_MAP.get(item.subsystem)?.color }"
|
||||
@click="emit('toggleSubsystem', item.subsystem)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
|
||||
<div :class="['w-px h-4 bg-neutral-200 dark:bg-neutral-700 mx-1']" />
|
||||
|
||||
<Button
|
||||
:disabled="spanCount === 0"
|
||||
@click="emit('exportOtlp')"
|
||||
>
|
||||
<div class="i-solar:export-bold-duotone h-4 w-4" />
|
||||
Export OTLP
|
||||
</Button>
|
||||
|
||||
<div :class="['flex-1']" />
|
||||
|
||||
<span :class="['text-xs text-neutral-400']">
|
||||
{{ turnCount }} turn{{ turnCount !== 1 ? 's' : '' }}
|
||||
· {{ spanCount }} span{{ spanCount !== 1 ? 's' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,313 @@
|
||||
<script setup lang="ts">
|
||||
import type { IOSpan, IOTurn } from '@proj-airi/stage-shared'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { SUBSYSTEM_CONFIG_MAP } from '../io-tracer-types'
|
||||
|
||||
const props = defineProps<{
|
||||
span: IOSpan | undefined
|
||||
turn: IOTurn | undefined
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
selectSpan: [spanId: string]
|
||||
}>()
|
||||
|
||||
function fmtMs(ms: number): string {
|
||||
if (ms < 0.01)
|
||||
return '0ms'
|
||||
if (ms < 1)
|
||||
return `${(ms * 1000).toFixed(0)}µs`
|
||||
if (ms < 1000)
|
||||
return `${ms.toFixed(ms < 10 ? 1 : 0)}ms`
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
const duration = computed(() => {
|
||||
if (!props.span?.endTs)
|
||||
return null
|
||||
return props.span.endTs - props.span.startTs
|
||||
})
|
||||
|
||||
const relativeStart = computed(() => {
|
||||
if (!props.span || !props.turn)
|
||||
return 0
|
||||
return props.span.startTs - props.turn.startTs
|
||||
})
|
||||
|
||||
const relativeEnd = computed(() => {
|
||||
if (!props.span?.endTs || !props.turn)
|
||||
return null
|
||||
return props.span.endTs - props.turn.startTs
|
||||
})
|
||||
|
||||
const timingBar = computed(() => {
|
||||
if (!props.turn || !props.span)
|
||||
return null
|
||||
const turnDur = (props.turn.endTs ?? performance.now()) - props.turn.startTs
|
||||
if (turnDur <= 0)
|
||||
return null
|
||||
const start = (props.span.startTs - props.turn.startTs) / turnDur
|
||||
const end = ((props.span.endTs ?? performance.now()) - props.turn.startTs) / turnDur
|
||||
return { startPct: `${(start * 100).toFixed(1)}%`, widthPct: `${((end - start) * 100).toFixed(1)}%` }
|
||||
})
|
||||
|
||||
const relatedSpans = computed(() => {
|
||||
if (!props.turn || !props.span)
|
||||
return []
|
||||
return props.turn.spans
|
||||
.filter(s => s.id !== props.span!.id)
|
||||
.slice(0, 10)
|
||||
.map(s => ({
|
||||
id: s.id,
|
||||
lane: s.subsystem,
|
||||
name: s.name,
|
||||
label: SUBSYSTEM_CONFIG_MAP.get(s.subsystem)?.label ?? s.subsystem,
|
||||
color: SUBSYSTEM_CONFIG_MAP.get(s.subsystem)?.color ?? '#888',
|
||||
duration: s.endTs ? fmtMs(s.endTs - s.startTs) : 'live',
|
||||
}))
|
||||
})
|
||||
|
||||
const metaEntries = computed(() => {
|
||||
if (!props.span)
|
||||
return []
|
||||
const skip = new Set(['endTs'])
|
||||
return Object.entries(props.span.meta)
|
||||
.filter(([k]) => !skip.has(k))
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
value: typeof value === 'object' ? JSON.stringify(value) : String(value),
|
||||
isLong: typeof value === 'string' && value.length > 60,
|
||||
}))
|
||||
})
|
||||
|
||||
function copyValue(value: string) {
|
||||
navigator.clipboard.writeText(value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="props.span && props.turn"
|
||||
:class="[
|
||||
'w-72 flex-shrink-0',
|
||||
'border-l border-neutral-200 dark:border-neutral-700',
|
||||
'overflow-y-auto',
|
||||
'bg-white dark:bg-neutral-900',
|
||||
]"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div :class="['flex items-center justify-between', 'px-3 py-2', 'border-b border-neutral-200 dark:border-neutral-700']">
|
||||
<div :class="['flex items-center gap-2 min-w-0']">
|
||||
<div
|
||||
:class="['w-2.5 h-2.5 rounded-sm flex-shrink-0']"
|
||||
:style="{ backgroundColor: SUBSYSTEM_CONFIG_MAP.get(props.span.subsystem)?.color }"
|
||||
/>
|
||||
<span :class="['text-sm font-medium truncate']">
|
||||
{{ SUBSYSTEM_CONFIG_MAP.get(props.span.subsystem)?.label }}
|
||||
</span>
|
||||
<span :class="['text-xs text-neutral-400']">{{ props.span.name }}</span>
|
||||
</div>
|
||||
<button
|
||||
:class="['text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300', 'p-1 flex-shrink-0']"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<div class="i-solar:close-circle-bold-duotone h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Timing Bar Visualization -->
|
||||
<div
|
||||
v-if="timingBar"
|
||||
:class="['px-3 py-2', 'border-b border-neutral-100 dark:border-neutral-800']"
|
||||
>
|
||||
<div :class="['text-2.5 text-neutral-400 mb-1']">
|
||||
Position in turn
|
||||
</div>
|
||||
<div :class="['h-3 rounded-full bg-neutral-100 dark:bg-neutral-800 relative overflow-hidden']">
|
||||
<div
|
||||
:class="['absolute h-full rounded-full']"
|
||||
:style="{
|
||||
left: timingBar.startPct,
|
||||
width: timingBar.widthPct,
|
||||
backgroundColor: SUBSYSTEM_CONFIG_MAP.get(props.span.subsystem)?.color,
|
||||
opacity: 0.8,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="['px-3 py-2', 'text-xs', 'flex flex-col gap-3']">
|
||||
<!-- Timing Section -->
|
||||
<div>
|
||||
<div :class="['text-neutral-500 font-medium mb-1.5 uppercase tracking-wider text-2.5']">
|
||||
Timing
|
||||
</div>
|
||||
<div :class="['grid grid-cols-[auto_1fr] gap-x-3 gap-y-1']">
|
||||
<template v-if="duration !== null">
|
||||
<span :class="['text-neutral-400']">Duration</span>
|
||||
<span :class="['font-mono font-medium']">{{ fmtMs(duration) }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span :class="['text-neutral-400']">Status</span>
|
||||
<span :class="['text-amber-500 font-medium']">In progress</span>
|
||||
</template>
|
||||
<span :class="['text-neutral-400']">Start</span>
|
||||
<span :class="['font-mono']">+{{ fmtMs(relativeStart) }}</span>
|
||||
<template v-if="relativeEnd !== null">
|
||||
<span :class="['text-neutral-400']">End</span>
|
||||
<span :class="['font-mono']">+{{ fmtMs(relativeEnd) }}</span>
|
||||
</template>
|
||||
<template v-if="props.span.meta.ttftMs">
|
||||
<span :class="['text-purple-500']">TTFT</span>
|
||||
<span :class="['font-mono text-purple-500 font-medium']">{{ fmtMs(props.span.meta.ttftMs) }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text Content -->
|
||||
<div v-if="props.span.meta.text">
|
||||
<div :class="['text-neutral-500 font-medium mb-1.5 uppercase tracking-wider text-2.5']">
|
||||
Text
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
'p-2 rounded',
|
||||
'bg-neutral-50 dark:bg-neutral-800',
|
||||
'font-mono text-2.5 break-all whitespace-pre-wrap',
|
||||
'max-h-32 overflow-y-auto',
|
||||
'relative group',
|
||||
]"
|
||||
>
|
||||
{{ props.span.meta.text }}
|
||||
<button
|
||||
:class="[
|
||||
'absolute top-1 right-1',
|
||||
'opacity-0 group-hover:opacity-100 transition-opacity',
|
||||
'p-0.5 rounded bg-neutral-200 dark:bg-neutral-700',
|
||||
'text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300',
|
||||
]"
|
||||
title="Copy text"
|
||||
@click="copyValue(props.span.meta.text)"
|
||||
>
|
||||
<div class="i-solar:copy-bold-duotone h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<div v-if="metaEntries.length > 0">
|
||||
<div :class="['text-neutral-500 font-medium mb-1.5 uppercase tracking-wider text-2.5']">
|
||||
Attributes
|
||||
</div>
|
||||
<div :class="['flex flex-col gap-1']">
|
||||
<div
|
||||
v-for="entry in metaEntries"
|
||||
:key="entry.key"
|
||||
:class="['grid grid-cols-[auto_1fr] gap-x-2 items-start group']"
|
||||
>
|
||||
<span :class="['text-neutral-400 text-2.5']">{{ entry.key }}</span>
|
||||
<div :class="['flex items-start gap-1']">
|
||||
<span
|
||||
:class="[
|
||||
'font-mono text-2.5',
|
||||
entry.isLong ? 'break-all' : 'truncate',
|
||||
]"
|
||||
>{{ entry.value }}</span>
|
||||
<button
|
||||
:class="[
|
||||
'opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0',
|
||||
'text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300',
|
||||
]"
|
||||
title="Copy value"
|
||||
@click="copyValue(entry.value)"
|
||||
>
|
||||
<div class="i-solar:copy-bold-duotone h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Related Spans -->
|
||||
<div v-if="relatedSpans.length > 0">
|
||||
<div :class="['text-neutral-500 font-medium mb-1.5 uppercase tracking-wider text-2.5']">
|
||||
Related spans in turn
|
||||
</div>
|
||||
<div :class="['flex flex-col gap-0.5']">
|
||||
<button
|
||||
v-for="rs in relatedSpans"
|
||||
:key="rs.id"
|
||||
:class="[
|
||||
'flex items-center gap-1.5 px-1.5 py-1 rounded text-left',
|
||||
'hover:bg-neutral-50 dark:hover:bg-neutral-800',
|
||||
'transition-colors',
|
||||
]"
|
||||
@click="$emit('selectSpan', rs.id)"
|
||||
>
|
||||
<div
|
||||
:class="['w-1.5 h-1.5 rounded-sm flex-shrink-0']"
|
||||
:style="{ backgroundColor: rs.color }"
|
||||
/>
|
||||
<span :class="['text-2.5 text-neutral-500 flex-shrink-0']">{{ rs.label }}</span>
|
||||
<span :class="['text-2.5 truncate']">{{ rs.name }}</span>
|
||||
<span :class="['text-2.5 text-neutral-400 ml-auto flex-shrink-0 font-mono']">{{ rs.duration }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Span IDs -->
|
||||
<div>
|
||||
<div :class="['text-neutral-500 font-medium mb-1.5 uppercase tracking-wider text-2.5']">
|
||||
Identity
|
||||
</div>
|
||||
<div :class="['grid grid-cols-[auto_1fr] gap-x-3 gap-y-1']">
|
||||
<span :class="['text-neutral-400 text-2.5']">Trace</span>
|
||||
<span :class="['font-mono text-2.5']">{{ props.span.traceId.slice(0, 16) }}…</span>
|
||||
<span :class="['text-neutral-400 text-2.5']">Span</span>
|
||||
<span :class="['font-mono text-2.5']">{{ props.span.id.slice(0, 16) }}</span>
|
||||
<template v-if="props.span.parentSpanId">
|
||||
<span :class="['text-neutral-400 text-2.5']">Parent</span>
|
||||
<span :class="['font-mono text-2.5']">{{ props.span.parentSpanId.slice(0, 16) }}</span>
|
||||
</template>
|
||||
<template v-if="props.span.ttsCorrelationId">
|
||||
<span :class="['text-neutral-400 text-2.5']">Segment</span>
|
||||
<span :class="['font-mono text-2.5']">{{ props.span.ttsCorrelationId.slice(0, 16) }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Turn Info -->
|
||||
<div>
|
||||
<div :class="['text-neutral-500 font-medium mb-1.5 uppercase tracking-wider text-2.5']">
|
||||
Turn
|
||||
</div>
|
||||
<div :class="['grid grid-cols-[auto_1fr] gap-x-3 gap-y-1']">
|
||||
<span :class="['text-neutral-400 text-2.5']">Spans</span>
|
||||
<span :class="['text-2.5']">{{ props.turn.spans.length }}</span>
|
||||
<template v-if="props.turn.endTs">
|
||||
<span :class="['text-neutral-400 text-2.5']">Total</span>
|
||||
<span :class="['font-mono text-2.5']">{{ fmtMs(props.turn.endTs - props.turn.startTs) }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-else
|
||||
:class="[
|
||||
'w-72 flex-shrink-0',
|
||||
'border-l border-neutral-200 dark:border-neutral-700',
|
||||
'flex flex-col items-center justify-center',
|
||||
'text-neutral-400',
|
||||
'bg-white dark:bg-neutral-900',
|
||||
]"
|
||||
>
|
||||
<div class="i-solar:cursor-bold-duotone mb-2 h-8 w-8 opacity-30" />
|
||||
<span :class="['text-xs']">Click a span to inspect</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import type { IOSubsystem, IOTurn } from '@proj-airi/stage-shared'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { SUBSYSTEM_CONFIG_MAP } from '../io-tracer-types'
|
||||
|
||||
const props = defineProps<{
|
||||
turns: IOTurn[]
|
||||
}>()
|
||||
|
||||
interface SubsystemMetric {
|
||||
subsystem: IOSubsystem
|
||||
label: string
|
||||
color: string
|
||||
totalMs: number
|
||||
count: number
|
||||
}
|
||||
|
||||
function fmtMs(ms: number): string {
|
||||
if (ms < 0.01)
|
||||
return '—'
|
||||
if (ms < 1)
|
||||
return `${(ms * 1000).toFixed(0)}µs`
|
||||
if (ms < 1000)
|
||||
return `${ms.toFixed(ms < 10 ? 1 : 0)}ms`
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
const metrics = computed(() => {
|
||||
if (props.turns.length === 0)
|
||||
return null
|
||||
|
||||
let e2eTotal = 0
|
||||
let e2eCount = 0
|
||||
let ttftTotal = 0
|
||||
let ttftCount = 0
|
||||
|
||||
const subsystemAccum = new Map<IOSubsystem, { totalMs: number, count: number }>()
|
||||
|
||||
for (const turn of props.turns) {
|
||||
if (turn.endTs) {
|
||||
e2eTotal += turn.endTs - turn.startTs
|
||||
e2eCount++
|
||||
}
|
||||
|
||||
for (const span of turn.spans) {
|
||||
if (span.meta.ttftMs) {
|
||||
ttftTotal += span.meta.ttftMs
|
||||
ttftCount++
|
||||
}
|
||||
if (span.endTs) {
|
||||
const acc = subsystemAccum.get(span.subsystem) ?? { totalMs: 0, count: 0 }
|
||||
acc.totalMs += span.endTs - span.startTs
|
||||
acc.count++
|
||||
subsystemAccum.set(span.subsystem, acc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const subsystems: SubsystemMetric[] = []
|
||||
let maxMs = 0
|
||||
for (const [subsystem, acc] of subsystemAccum) {
|
||||
const config = SUBSYSTEM_CONFIG_MAP.get(subsystem)
|
||||
if (config) {
|
||||
const avg = acc.totalMs / acc.count
|
||||
if (avg > maxMs)
|
||||
maxMs = avg
|
||||
subsystems.push({
|
||||
subsystem,
|
||||
label: config.label,
|
||||
color: config.color,
|
||||
totalMs: avg,
|
||||
count: acc.count,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const bottleneckSubsystem = subsystems.reduce<SubsystemMetric | null>((max, l) => (!max || l.totalMs > max.totalMs) ? l : max, null)
|
||||
|
||||
return {
|
||||
e2eAvg: e2eCount > 0 ? e2eTotal / e2eCount : null,
|
||||
ttftAvg: ttftCount > 0 ? ttftTotal / ttftCount : null,
|
||||
subsystems,
|
||||
bottleneckSubsystem: bottleneckSubsystem?.subsystem ?? null,
|
||||
turnCount: props.turns.length,
|
||||
completedTurns: e2eCount,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="metrics"
|
||||
:class="[
|
||||
'flex items-center gap-4 px-3 py-1.5',
|
||||
'border-b border-neutral-200 dark:border-neutral-700',
|
||||
'bg-neutral-50/50 dark:bg-neutral-900/50',
|
||||
'text-xs',
|
||||
'overflow-x-auto flex-shrink-0',
|
||||
]"
|
||||
>
|
||||
<!-- E2E Latency -->
|
||||
<div :class="['flex items-center gap-1.5']">
|
||||
<span :class="['text-neutral-400']">E2E</span>
|
||||
<span :class="['font-mono font-medium']">
|
||||
{{ metrics.e2eAvg !== null ? fmtMs(metrics.e2eAvg) : '—' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- TTFT -->
|
||||
<div
|
||||
v-if="metrics.ttftAvg !== null"
|
||||
:class="['flex items-center gap-1.5']"
|
||||
>
|
||||
<span :class="['text-purple-500']">TTFT</span>
|
||||
<span :class="['font-mono font-medium text-purple-600 dark:text-purple-400']">
|
||||
{{ fmtMs(metrics.ttftAvg) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div :class="['w-px h-4 bg-neutral-200 dark:bg-neutral-700']" />
|
||||
|
||||
<!-- Per-Subsystem Averages -->
|
||||
<div
|
||||
v-for="ss in metrics.subsystems"
|
||||
:key="ss.subsystem"
|
||||
:class="['flex items-center gap-1']"
|
||||
>
|
||||
<div
|
||||
:class="['w-2 h-2 rounded-sm']"
|
||||
:style="{ backgroundColor: ss.color }"
|
||||
/>
|
||||
<span :class="['text-neutral-400']">{{ ss.label }}</span>
|
||||
<span
|
||||
:class="[
|
||||
'font-mono',
|
||||
ss.subsystem === metrics.bottleneckSubsystem ? 'font-medium text-red-500' : '',
|
||||
]"
|
||||
>
|
||||
{{ fmtMs(ss.totalMs) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="ss.subsystem === metrics.bottleneckSubsystem"
|
||||
:class="['text-2.5 text-red-400']"
|
||||
>
|
||||
bottleneck
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import type { IOTurn } from '@proj-airi/stage-shared'
|
||||
|
||||
import { SUBSYSTEM_CONFIGS } from '../io-tracer-types'
|
||||
|
||||
const props = defineProps<{
|
||||
turns: IOTurn[]
|
||||
selectedTurnId: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectTurn: [turnId: string | null]
|
||||
}>()
|
||||
|
||||
function formatMs(ms: number): string {
|
||||
if (ms < 1)
|
||||
return '<1ms'
|
||||
if (ms < 1000)
|
||||
return `${ms.toFixed(0)}ms`
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
function turnDuration(turn: IOTurn): string {
|
||||
if (!turn.endTs)
|
||||
return 'live'
|
||||
return formatMs(turn.endTs - turn.startTs)
|
||||
}
|
||||
|
||||
function spanCountBySubsystem(turn: IOTurn): { subsystem: string, count: number, color: string }[] {
|
||||
const counts = new Map<string, number>()
|
||||
for (const span of turn.spans) {
|
||||
counts.set(span.subsystem, (counts.get(span.subsystem) ?? 0) + 1)
|
||||
}
|
||||
return SUBSYSTEM_CONFIGS
|
||||
.filter(c => counts.has(c.subsystem))
|
||||
.map(c => ({ subsystem: c.label, count: counts.get(c.subsystem)!, color: c.color }))
|
||||
}
|
||||
|
||||
function getTtft(turn: IOTurn): number | undefined {
|
||||
for (const span of turn.spans) {
|
||||
if (span.subsystem === 'llm' && span.meta.ttftMs)
|
||||
return span.meta.ttftMs
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'w-56 flex-shrink-0',
|
||||
'border-r border-neutral-200 dark:border-neutral-700',
|
||||
'overflow-y-auto',
|
||||
'flex flex-col',
|
||||
]"
|
||||
>
|
||||
<div :class="['px-3 py-2', 'text-xs font-medium text-neutral-500', 'border-b border-neutral-200 dark:border-neutral-700']">
|
||||
Turns
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="props.selectedTurnId"
|
||||
:class="[
|
||||
'px-3 py-1.5 text-left text-xs',
|
||||
'border-b border-neutral-100 dark:border-neutral-800',
|
||||
'text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-950',
|
||||
'transition-colors',
|
||||
]"
|
||||
@click="emit('selectTurn', null)"
|
||||
>
|
||||
Show all turns
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="turns.length === 0"
|
||||
:class="['flex-1 flex items-center justify-center', 'text-xs text-neutral-400 p-4 text-center']"
|
||||
>
|
||||
No turns recorded yet
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="turn in [...turns].reverse()"
|
||||
:key="turn.id"
|
||||
:class="[
|
||||
'px-3 py-2 cursor-pointer',
|
||||
'border-b border-neutral-100 dark:border-neutral-800',
|
||||
'transition-colors',
|
||||
turn.id === props.selectedTurnId
|
||||
? 'bg-blue-50 dark:bg-blue-950/50'
|
||||
: 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50',
|
||||
]"
|
||||
@click="emit('selectTurn', turn.id === props.selectedTurnId ? null : turn.id)"
|
||||
>
|
||||
<div :class="['flex items-center justify-between mb-1']">
|
||||
<span :class="['text-xs font-mono font-medium']">
|
||||
#{{ turn.id.slice(0, 6) }}
|
||||
</span>
|
||||
<span
|
||||
:class="[
|
||||
'text-2.5 font-mono px-1 py-0.5 rounded',
|
||||
turn.endTs
|
||||
? 'bg-neutral-100 dark:bg-neutral-800 text-neutral-500'
|
||||
: 'bg-amber-100 dark:bg-amber-900/30 text-amber-600',
|
||||
]"
|
||||
>
|
||||
{{ turnDuration(turn) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="turn.inputText"
|
||||
:class="['text-2.5 text-neutral-500 truncate mb-1']"
|
||||
>
|
||||
{{ turn.inputText.slice(0, 50) }}{{ turn.inputText.length > 50 ? '...' : '' }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="getTtft(turn)"
|
||||
:class="['text-2.5 text-purple-500 mb-1']"
|
||||
>
|
||||
TTFT: {{ formatMs(getTtft(turn)!) }}
|
||||
</div>
|
||||
|
||||
<div :class="['flex flex-wrap gap-1']">
|
||||
<span
|
||||
v-for="item in spanCountBySubsystem(turn)"
|
||||
:key="item.subsystem"
|
||||
:class="['text-2.5 px-1 py-0.5 rounded']"
|
||||
:style="{ backgroundColor: `${item.color}15`, color: item.color }"
|
||||
>
|
||||
{{ item.subsystem }} {{ item.count }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import type { IOSubsystem } from '@proj-airi/stage-shared'
|
||||
|
||||
import { useIOTracerStore } from '@proj-airi/stage-ui/stores/devtools/io-tracer'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
|
||||
import IOTracerChart from './components/io-tracer-chart.vue'
|
||||
import IOTracerControls from './components/io-tracer-controls.vue'
|
||||
import IOTracerDetail from './components/io-tracer-detail.vue'
|
||||
import IOTracerMetrics from './components/io-tracer-metrics.vue'
|
||||
|
||||
const store = useIOTracerStore()
|
||||
const { turns, isRecording, selectedSpanId, selectedSpan, recordingStartTs, rawSpanCount } = storeToRefs(store)
|
||||
|
||||
const chartRef = ref<InstanceType<typeof IOTracerChart>>()
|
||||
const hiddenSubsystems = ref(new Set<IOSubsystem>())
|
||||
|
||||
function toggleRecording() {
|
||||
if (isRecording.value)
|
||||
store.stopRecording()
|
||||
else
|
||||
store.startRecording()
|
||||
}
|
||||
|
||||
function toggleSubsystem(subsystem: IOSubsystem) {
|
||||
const next = new Set(hiddenSubsystems.value)
|
||||
if (next.has(subsystem))
|
||||
next.delete(subsystem)
|
||||
else
|
||||
next.add(subsystem)
|
||||
hiddenSubsystems.value = next
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
store.stopRecording()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['flex flex-col h-full']">
|
||||
<IOTracerControls
|
||||
:is-recording="isRecording"
|
||||
:turn-count="turns.length"
|
||||
:span-count="rawSpanCount"
|
||||
:hidden-subsystems="hiddenSubsystems"
|
||||
@toggle-recording="toggleRecording"
|
||||
@clear="store.clear()"
|
||||
@auto-fit="chartRef?.autoFit()"
|
||||
@toggle-subsystem="toggleSubsystem"
|
||||
@export-otlp="store.exportOTLP()"
|
||||
/>
|
||||
|
||||
<IOTracerMetrics :turns="turns" />
|
||||
|
||||
<div :class="['flex flex-1 overflow-hidden']">
|
||||
<IOTracerChart
|
||||
ref="chartRef"
|
||||
:turns="turns"
|
||||
:selected-span-id="selectedSpanId"
|
||||
:time-origin="recordingStartTs"
|
||||
:hidden-subsystems="hiddenSubsystems"
|
||||
@select-span="store.selectSpan($event)"
|
||||
/>
|
||||
|
||||
<IOTracerDetail
|
||||
:span="selectedSpan?.span"
|
||||
:turn="selectedSpan?.turn"
|
||||
@close="store.selectSpan(null)"
|
||||
@select-span="store.selectSpan($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: settings
|
||||
titleKey: IO Tracer
|
||||
subtitleKey: Devtools
|
||||
</route>
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { IOSubsystem } from '@proj-airi/stage-shared'
|
||||
|
||||
import { IOSubsystems } from '@proj-airi/stage-shared'
|
||||
|
||||
export interface SubsystemConfig {
|
||||
subsystem: IOSubsystem
|
||||
label: string
|
||||
color: string
|
||||
bgColor: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export const SUBSYSTEM_CONFIGS: SubsystemConfig[] = [
|
||||
{ subsystem: IOSubsystems.ASR, label: 'ASR', color: '#3b82f6', bgColor: '#3b82f618', icon: 'i-lucide:mic' },
|
||||
{ subsystem: IOSubsystems.LLM, label: 'LLM', color: '#a855f7', bgColor: '#a855f718', icon: 'i-lucide:brain' },
|
||||
{ subsystem: IOSubsystems.TTS, label: 'TTS', color: '#22c55e', bgColor: '#22c55e18', icon: 'i-lucide:audio-lines' },
|
||||
{ subsystem: IOSubsystems.Playback, label: 'Playback', color: '#f87171', bgColor: '#f8717118', icon: 'i-lucide:play' },
|
||||
]
|
||||
|
||||
export const SUBSYSTEM_CONFIG_MAP = new Map(SUBSYSTEM_CONFIGS.map(c => [c.subsystem, c]))
|
||||
|
||||
/** Height of one span row in pixels */
|
||||
export const ROW_HEIGHT = 28
|
||||
/** Height of subsystem group header */
|
||||
export const SUBSYSTEM_HEADER_HEIGHT = 24
|
||||
/** Height of a collapsible turn header */
|
||||
export const TURN_HEADER_HEIGHT = 36
|
||||
/** Vertical padding inside each row for the span bar */
|
||||
export const ROW_PADDING = 4
|
||||
/** Width of the left label column */
|
||||
export const LABEL_COL_WIDTH = 140
|
||||
/** Height of the time axis ruler */
|
||||
export const TIME_AXIS_HEIGHT = 28
|
||||
/** Height of the minimap */
|
||||
export const MINIMAP_HEIGHT = 32
|
||||
|
||||
/** Gap detection threshold: gaps longer than this (ms) are highlighted */
|
||||
export const GAP_WARN_THRESHOLD_MS = 100
|
||||
Reference in New Issue
Block a user