chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
export { LineChart, type LineChartMultiSeries, type LineChartPoint } from './line-chart'
|
||||
export { StatusBar, type StatusBarSegment } from './status-bar'
|
||||
export { type WorkflowExecutionItem, WorkflowsList } from './workflows-list'
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { LineChart, type LineChartMultiSeries, type LineChartPoint } from './line-chart'
|
||||
+712
@@ -0,0 +1,712 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Button, cn } from '@sim/emcn'
|
||||
import { generateShortId } from '@sim/utils/id'
|
||||
import { formatDate, formatLatency } from '@/app/workspace/[workspaceId]/logs/utils'
|
||||
|
||||
export interface LineChartPoint {
|
||||
timestamp: string
|
||||
value: number
|
||||
}
|
||||
|
||||
export interface LineChartMultiSeries {
|
||||
id?: string
|
||||
label: string
|
||||
color: string
|
||||
data: LineChartPoint[]
|
||||
dashed?: boolean
|
||||
}
|
||||
|
||||
function LineChartComponent({
|
||||
data,
|
||||
label,
|
||||
color,
|
||||
unit,
|
||||
series,
|
||||
}: {
|
||||
data: LineChartPoint[]
|
||||
label: string
|
||||
color: string
|
||||
unit?: string
|
||||
series?: LineChartMultiSeries[]
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const uniqueId = useRef(`chart-${generateShortId(7)}`).current
|
||||
const [containerWidth, setContainerWidth] = useState<number | null>(null)
|
||||
const width = containerWidth ?? 0
|
||||
const height = 166
|
||||
const padding = { top: 16, right: 28, bottom: 26, left: 26 }
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
const element = containerRef.current
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const entry = entries[0]
|
||||
if (entry?.contentRect && entry.contentRect.width > 0) {
|
||||
const w = Math.max(280, Math.floor(entry.contentRect.width))
|
||||
setContainerWidth(w)
|
||||
}
|
||||
})
|
||||
ro.observe(element)
|
||||
const rect = element.getBoundingClientRect()
|
||||
if (rect?.width && rect.width > 0) setContainerWidth(Math.max(280, Math.floor(rect.width)))
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
const chartWidth = width - padding.left - padding.right
|
||||
const chartHeight = height - padding.top - padding.bottom
|
||||
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
|
||||
const [isDark, setIsDark] = useState<boolean>(true)
|
||||
const [hoverSeriesId, setHoverSeriesId] = useState<string | null>(null)
|
||||
const [activeSeriesId, setActiveSeriesId] = useState<string | null>(null)
|
||||
const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null)
|
||||
const [resolvedColors, setResolvedColors] = useState<Record<string, string>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
const el = document.documentElement
|
||||
const update = () => setIsDark(el.classList.contains('dark'))
|
||||
update()
|
||||
const observer = new MutationObserver(update)
|
||||
observer.observe(el, { attributes: true, attributeFilter: ['class'] })
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const resolveColor = (c: string): string => {
|
||||
if (!c.startsWith('var(')) return c
|
||||
|
||||
const tempEl = document.createElement('div')
|
||||
tempEl.style.color = c
|
||||
document.body.appendChild(tempEl)
|
||||
const computed = window.getComputedStyle(tempEl).color
|
||||
document.body.removeChild(tempEl)
|
||||
return computed
|
||||
}
|
||||
|
||||
const colorMap: Record<string, string> = { base: resolveColor(color) }
|
||||
const allSeriesToResolve = Array.isArray(series) && series.length > 0 ? series : []
|
||||
|
||||
for (const s of allSeriesToResolve) {
|
||||
const id = s.id || s.label || ''
|
||||
if (id) colorMap[id] = resolveColor(s.color)
|
||||
}
|
||||
|
||||
setResolvedColors(colorMap)
|
||||
}, [color, series])
|
||||
|
||||
const hasExternalWrapper = !label || label === ''
|
||||
|
||||
const allSeries = useMemo(
|
||||
() =>
|
||||
(Array.isArray(series) && series.length > 0
|
||||
? [{ id: 'base', label, color, data }, ...series]
|
||||
: [{ id: 'base', label, color, data }]
|
||||
).map((s, idx) => ({ ...s, id: s.id || s.label || String(idx) })),
|
||||
[series, label, color, data]
|
||||
)
|
||||
|
||||
const { maxValue, minValue, valueRange } = useMemo(() => {
|
||||
const flatValues = allSeries.flatMap((s) => s.data.map((d) => d.value))
|
||||
const rawMax = Math.max(...flatValues, 1)
|
||||
const rawMin = Math.min(...flatValues, 0)
|
||||
const paddedMax = rawMax === 0 ? 1 : rawMax * 1.1
|
||||
const paddedMin = Math.min(0, rawMin)
|
||||
const unitSuffixPre = (unit || '').trim().toLowerCase()
|
||||
let maxVal = Math.ceil(paddedMax)
|
||||
let minVal = Math.floor(paddedMin)
|
||||
if (unitSuffixPre === 'ms' || unitSuffixPre === 'latency') {
|
||||
minVal = 0
|
||||
if (paddedMax < 10) {
|
||||
maxVal = Math.ceil(paddedMax)
|
||||
} else if (paddedMax < 100) {
|
||||
maxVal = Math.ceil(paddedMax / 10) * 10
|
||||
} else if (paddedMax < 1000) {
|
||||
maxVal = Math.ceil(paddedMax / 50) * 50
|
||||
} else if (paddedMax < 10000) {
|
||||
maxVal = Math.ceil(paddedMax / 500) * 500
|
||||
} else {
|
||||
maxVal = Math.ceil(paddedMax / 1000) * 1000
|
||||
}
|
||||
}
|
||||
return {
|
||||
maxValue: maxVal,
|
||||
minValue: minVal,
|
||||
valueRange: maxVal - minVal || 1,
|
||||
}
|
||||
}, [allSeries, unit])
|
||||
|
||||
const yMin = padding.top + 3
|
||||
const yMax = padding.top + chartHeight - 3
|
||||
|
||||
const scaledPoints = useMemo(
|
||||
() =>
|
||||
data.map((d, i) => {
|
||||
const usableW = Math.max(1, chartWidth)
|
||||
const x = padding.left + (i / (data.length - 1 || 1)) * usableW
|
||||
const rawY = padding.top + chartHeight - ((d.value - minValue) / valueRange) * chartHeight
|
||||
const y = Math.max(yMin, Math.min(yMax, rawY))
|
||||
return { x, y }
|
||||
}),
|
||||
[data, chartWidth, chartHeight, minValue, valueRange, yMin, yMax, padding.left, padding.top]
|
||||
)
|
||||
|
||||
const scaledSeries = useMemo(
|
||||
() =>
|
||||
allSeries.map((s) => {
|
||||
const pts = s.data.map((d, i) => {
|
||||
const usableW = Math.max(1, chartWidth)
|
||||
const x = padding.left + (i / (s.data.length - 1 || 1)) * usableW
|
||||
const rawY = padding.top + chartHeight - ((d.value - minValue) / valueRange) * chartHeight
|
||||
const y = Math.max(yMin, Math.min(yMax, rawY))
|
||||
return { x, y }
|
||||
})
|
||||
return { ...s, pts }
|
||||
}),
|
||||
[
|
||||
allSeries,
|
||||
chartWidth,
|
||||
chartHeight,
|
||||
minValue,
|
||||
valueRange,
|
||||
yMin,
|
||||
yMax,
|
||||
padding.left,
|
||||
padding.top,
|
||||
]
|
||||
)
|
||||
|
||||
const getSeriesById = (id?: string | null) => scaledSeries.find((s) => s.id === id)
|
||||
const visibleSeries = useMemo(
|
||||
() => (activeSeriesId ? scaledSeries.filter((s) => s.id === activeSeriesId) : scaledSeries),
|
||||
[activeSeriesId, scaledSeries]
|
||||
)
|
||||
|
||||
const pathD = useMemo(() => {
|
||||
if (scaledPoints.length <= 1) return ''
|
||||
const p = scaledPoints
|
||||
const tension = 0.2
|
||||
let d = `M ${p[0].x} ${p[0].y}`
|
||||
for (let i = 0; i < p.length - 1; i++) {
|
||||
const p0 = p[i - 1] || p[i]
|
||||
const p1 = p[i]
|
||||
const p2 = p[i + 1]
|
||||
const p3 = p[i + 2] || p[i + 1]
|
||||
const cp1x = p1.x + ((p2.x - p0.x) / 6) * tension
|
||||
let cp1y = p1.y + ((p2.y - p0.y) / 6) * tension
|
||||
const cp2x = p2.x - ((p3.x - p1.x) / 6) * tension
|
||||
let cp2y = p2.y - ((p3.y - p1.y) / 6) * tension
|
||||
cp1y = Math.max(yMin, Math.min(yMax, cp1y))
|
||||
cp2y = Math.max(yMin, Math.min(yMax, cp2y))
|
||||
d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`
|
||||
}
|
||||
return d
|
||||
}, [scaledPoints, yMin, yMax])
|
||||
|
||||
const getCompactDateLabel = (timestamp?: string) => {
|
||||
if (!timestamp) return ''
|
||||
try {
|
||||
const f = formatDate(timestamp)
|
||||
return `${f.compactDate} ${f.compactTime}`
|
||||
} catch (e) {
|
||||
const d = new Date(timestamp)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
return d.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const currentHoverDate =
|
||||
hoverIndex !== null && data[hoverIndex] ? getCompactDateLabel(data[hoverIndex].timestamp) : ''
|
||||
|
||||
if (containerWidth === null) {
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn('w-full', !hasExternalWrapper && 'rounded-lg border bg-card p-4')}
|
||||
style={{ height }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center',
|
||||
!hasExternalWrapper && 'rounded-lg border bg-card p-4'
|
||||
)}
|
||||
style={{ width, height }}
|
||||
>
|
||||
<p className='text-[var(--text-muted)] text-sm'>No data</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'w-full overflow-hidden',
|
||||
!hasExternalWrapper && 'rounded-[11px] border bg-card p-4 shadow-sm'
|
||||
)}
|
||||
>
|
||||
{!hasExternalWrapper && (
|
||||
<div className='mb-3 flex items-center gap-3'>
|
||||
<h4 className='font-medium text-[var(--text-primary)] text-sm'>{label}</h4>
|
||||
{allSeries.length > 1 && (
|
||||
<div className='flex items-center gap-2'>
|
||||
{scaledSeries.slice(1).map((s) => {
|
||||
const isActive = activeSeriesId ? activeSeriesId === s.id : true
|
||||
const isHovered = hoverSeriesId === s.id
|
||||
const dimmed = activeSeriesId ? !isActive : false
|
||||
return (
|
||||
<Button
|
||||
key={`legend-${s.id}`}
|
||||
type='button'
|
||||
variant='ghost'
|
||||
aria-pressed={activeSeriesId === s.id}
|
||||
aria-label={`Toggle ${s.label}`}
|
||||
className='inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-transparent px-1.5 py-0.5 text-micro'
|
||||
style={{
|
||||
color: resolvedColors[s.id || ''] || s.color,
|
||||
opacity: dimmed ? 0.4 : isHovered ? 1 : 0.9,
|
||||
}}
|
||||
onMouseEnter={() => setHoverSeriesId(s.id || null)}
|
||||
onMouseLeave={() => setHoverSeriesId((prev) => (prev === s.id ? null : prev))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setActiveSeriesId((prev) => (prev === s.id ? null : s.id || null))
|
||||
}
|
||||
}}
|
||||
onClick={() =>
|
||||
setActiveSeriesId((prev) => (prev === s.id ? null : s.id || null))
|
||||
}
|
||||
>
|
||||
<span
|
||||
aria-hidden='true'
|
||||
className='inline-block size-[6px] rounded-xs'
|
||||
style={{ backgroundColor: resolvedColors[s.id || ''] || s.color }}
|
||||
/>
|
||||
<span className='text-[var(--text-muted)]'>{s.label}</span>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className='relative' style={{ width, height }}>
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className='overflow-hidden'
|
||||
onMouseMove={(e) => {
|
||||
if (scaledPoints.length === 0) return
|
||||
const rect = (e.currentTarget as SVGSVGElement).getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const clamped = Math.max(padding.left, Math.min(width - padding.right, x))
|
||||
const ratio = (clamped - padding.left) / (chartWidth || 1)
|
||||
const i = Math.round(ratio * (scaledPoints.length - 1))
|
||||
setHoverIndex(i)
|
||||
setHoverPos({ x: clamped, y: e.clientY - rect.top })
|
||||
const cursorY = e.clientY - rect.top
|
||||
if (activeSeriesId) {
|
||||
setHoverSeriesId(activeSeriesId)
|
||||
} else {
|
||||
let best: { id: string | null; dy: number } = {
|
||||
id: null,
|
||||
dy: Number.POSITIVE_INFINITY,
|
||||
}
|
||||
for (const s of scaledSeries.slice(1)) {
|
||||
const pt = s.pts[i]
|
||||
if (!pt) continue
|
||||
const dy = Math.abs(pt.y - cursorY)
|
||||
if (dy < best.dy) best = { id: s.id || null, dy }
|
||||
}
|
||||
setHoverSeriesId(best.dy <= 12 ? best.id : null)
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHoverIndex(null)
|
||||
setHoverPos(null)
|
||||
setHoverSeriesId(null)
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={`area-${uniqueId}`} x1='0' x2='0' y1='0' y2='1'>
|
||||
<stop
|
||||
offset='0%'
|
||||
stopColor={resolvedColors.base || color}
|
||||
stopOpacity={isDark ? 0.25 : 0.45}
|
||||
/>
|
||||
<stop
|
||||
offset='100%'
|
||||
stopColor={resolvedColors.base || color}
|
||||
stopOpacity={isDark ? 0.03 : 0.08}
|
||||
/>
|
||||
</linearGradient>
|
||||
<clipPath id={`clip-${uniqueId}`}>
|
||||
<rect
|
||||
x={padding.left - 3}
|
||||
y={yMin}
|
||||
width={Math.max(1, chartWidth + 6)}
|
||||
height={chartHeight - (yMin - padding.top) * 2}
|
||||
rx='2'
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<line
|
||||
x1={padding.left}
|
||||
y1={padding.top}
|
||||
x2={padding.left}
|
||||
y2={height - padding.bottom}
|
||||
stroke='hsl(var(--border))'
|
||||
strokeWidth='1'
|
||||
/>
|
||||
|
||||
{[0.25, 0.5, 0.75].map((p) => (
|
||||
<line
|
||||
key={`${uniqueId}-grid-${p}`}
|
||||
x1={padding.left}
|
||||
y1={padding.top + chartHeight * p}
|
||||
x2={width - padding.right}
|
||||
y2={padding.top + chartHeight * p}
|
||||
stroke='hsl(var(--muted))'
|
||||
strokeOpacity='0.35'
|
||||
strokeWidth='1'
|
||||
/>
|
||||
))}
|
||||
|
||||
{!activeSeriesId && scaledPoints.length > 1 && (
|
||||
<path
|
||||
d={`${pathD} L ${scaledPoints[scaledPoints.length - 1].x} ${height - padding.bottom} L ${scaledPoints[0].x} ${height - padding.bottom} Z`}
|
||||
fill={`url(#area-${uniqueId})`}
|
||||
stroke='none'
|
||||
clipPath={`url(#clip-${uniqueId})`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!activeSeriesId &&
|
||||
scaledPoints.length === 1 &&
|
||||
(() => {
|
||||
const strokeWidth = isDark ? 1.7 : 2.0
|
||||
const capExtension = strokeWidth / 2
|
||||
return (
|
||||
<rect
|
||||
x={padding.left - capExtension}
|
||||
y={scaledPoints[0].y}
|
||||
width={Math.max(1, chartWidth + capExtension * 2)}
|
||||
height={height - padding.bottom - scaledPoints[0].y}
|
||||
fill={`url(#area-${uniqueId})`}
|
||||
clipPath={`url(#clip-${uniqueId})`}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
|
||||
{visibleSeries.map((s, idx) => {
|
||||
const isActive = activeSeriesId ? activeSeriesId === s.id : true
|
||||
const isHovered = hoverSeriesId ? hoverSeriesId === s.id : false
|
||||
const baseOpacity = isActive ? 1 : 0.12
|
||||
const strokeOpacity = isHovered ? 1 : baseOpacity
|
||||
const sw = (() => {
|
||||
switch ((s.id || '').toLowerCase()) {
|
||||
case 'p50':
|
||||
return isDark ? 1.5 : 1.7
|
||||
case 'p90':
|
||||
return isDark ? 1.9 : 2.1
|
||||
case 'p99':
|
||||
return isDark ? 2.3 : 2.5
|
||||
default:
|
||||
return isDark ? 1.7 : 2.0
|
||||
}
|
||||
})()
|
||||
if (s.pts.length <= 1) {
|
||||
const y = s.pts[0]?.y
|
||||
if (y === undefined) return null
|
||||
return (
|
||||
<line
|
||||
key={s.id}
|
||||
x1={padding.left}
|
||||
y1={y}
|
||||
x2={width - padding.right}
|
||||
y2={y}
|
||||
stroke={resolvedColors[s.id || ''] || s.color}
|
||||
strokeWidth={sw}
|
||||
strokeLinecap='round'
|
||||
opacity={strokeOpacity}
|
||||
strokeDasharray={s.dashed ? '5 4' : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const p = (() => {
|
||||
const p = s.pts
|
||||
const tension = 0.2
|
||||
let d = `M ${p[0].x} ${p[0].y}`
|
||||
for (let i = 0; i < p.length - 1; i++) {
|
||||
const p0 = p[i - 1] || p[i]
|
||||
const p1 = p[i]
|
||||
const p2 = p[i + 1]
|
||||
const p3 = p[i + 2] || p[i + 1]
|
||||
const cp1x = p1.x + ((p2.x - p0.x) / 6) * tension
|
||||
let cp1y = p1.y + ((p2.y - p0.y) / 6) * tension
|
||||
const cp2x = p2.x - ((p3.x - p1.x) / 6) * tension
|
||||
let cp2y = p2.y - ((p3.y - p1.y) / 6) * tension
|
||||
cp1y = Math.max(yMin, Math.min(yMax, cp1y))
|
||||
cp2y = Math.max(yMin, Math.min(yMax, cp2y))
|
||||
d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`
|
||||
}
|
||||
return d
|
||||
})()
|
||||
return (
|
||||
<path
|
||||
key={s.id}
|
||||
d={p}
|
||||
fill='none'
|
||||
stroke={resolvedColors[s.id || ''] || s.color}
|
||||
strokeWidth={sw}
|
||||
strokeLinecap='round'
|
||||
clipPath={`url(#clip-${uniqueId})`}
|
||||
style={{ mixBlendMode: isDark ? 'screen' : 'normal' }}
|
||||
strokeDasharray={s.dashed ? '5 4' : undefined}
|
||||
opacity={strokeOpacity}
|
||||
onClick={() => setActiveSeriesId((prev) => (prev === s.id ? null : s.id || null))}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{hoverIndex !== null &&
|
||||
scaledPoints[hoverIndex] &&
|
||||
scaledPoints.length > 1 &&
|
||||
(() => {
|
||||
const guideSeries =
|
||||
getSeriesById(activeSeriesId) || getSeriesById(hoverSeriesId) || scaledSeries[0]
|
||||
const active = guideSeries
|
||||
const pt = active.pts[hoverIndex] || scaledPoints[hoverIndex]
|
||||
return (
|
||||
<g pointerEvents='none' clipPath={`url(#clip-${uniqueId})`}>
|
||||
<line
|
||||
x1={pt.x}
|
||||
y1={padding.top}
|
||||
x2={pt.x}
|
||||
y2={height - padding.bottom}
|
||||
stroke={resolvedColors[active.id || ''] || active.color}
|
||||
strokeOpacity='0.35'
|
||||
strokeDasharray='3 3'
|
||||
/>
|
||||
{activeSeriesId &&
|
||||
(() => {
|
||||
const s = getSeriesById(activeSeriesId)
|
||||
const spt = s?.pts?.[hoverIndex]
|
||||
if (!s || !spt) return null
|
||||
return (
|
||||
<circle
|
||||
cx={spt.x}
|
||||
cy={spt.y}
|
||||
r='3'
|
||||
fill={resolvedColors[s.id || ''] || s.color}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</g>
|
||||
)
|
||||
})()}
|
||||
|
||||
{(() => {
|
||||
if (data.length < 2) return null
|
||||
const usableW = Math.max(1, chartWidth)
|
||||
const firstTs = new Date(data[0].timestamp)
|
||||
const lastTs = new Date(data[data.length - 1].timestamp)
|
||||
const spanMs = Math.abs(lastTs.getTime() - firstTs.getTime())
|
||||
|
||||
const approxLabelWidth = 64
|
||||
const desired = Math.min(8, Math.max(3, Math.floor(usableW / approxLabelWidth)))
|
||||
const rawIdx = Array.from({ length: desired }, (_, i) =>
|
||||
Math.round((i * (data.length - 1)) / Math.max(1, desired - 1))
|
||||
)
|
||||
const seen = new Set<number>()
|
||||
const idx = rawIdx.filter((i) => {
|
||||
if (seen.has(i)) return false
|
||||
seen.add(i)
|
||||
return true
|
||||
})
|
||||
|
||||
const formatTick = (d: Date) => {
|
||||
if (spanMs <= 36 * 60 * 60 * 1000) {
|
||||
return d.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
}
|
||||
if (spanMs <= 90 * 24 * 60 * 60 * 1000) {
|
||||
return d.toLocaleString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
return d.toLocaleString('en-US', { month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
return idx.map((i) => {
|
||||
const x = padding.left + (i / (data.length - 1 || 1)) * usableW
|
||||
const tsSource = data[i]?.timestamp
|
||||
if (!tsSource) return null
|
||||
const ts = new Date(tsSource)
|
||||
const labelStr = Number.isNaN(ts.getTime()) ? '' : formatTick(ts)
|
||||
return (
|
||||
<text
|
||||
key={`${uniqueId}-x-axis-${i}`}
|
||||
x={x}
|
||||
y={height - padding.bottom + 14}
|
||||
fontSize='9'
|
||||
textAnchor='middle'
|
||||
fill='var(--text-tertiary)'
|
||||
>
|
||||
{labelStr}
|
||||
</text>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
|
||||
{(() => {
|
||||
const unitSuffix = (unit || '').trim()
|
||||
const showInTicks = unitSuffix === '%'
|
||||
const isLatency = unitSuffix.toLowerCase() === 'latency'
|
||||
const fmtCompact = (v: number) => {
|
||||
if (isLatency) {
|
||||
if (v === 0) return '0'
|
||||
return formatLatency(v)
|
||||
}
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
.format(v)
|
||||
.toLowerCase()
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<text
|
||||
x={padding.left - 8}
|
||||
y={padding.top}
|
||||
textAnchor='end'
|
||||
fontSize='9'
|
||||
fill='var(--text-tertiary)'
|
||||
>
|
||||
{fmtCompact(maxValue)}
|
||||
{showInTicks && !isLatency ? unit : ''}
|
||||
</text>
|
||||
<text
|
||||
x={padding.left - 8}
|
||||
y={height - padding.bottom}
|
||||
textAnchor='end'
|
||||
fontSize='9'
|
||||
fill='var(--text-tertiary)'
|
||||
>
|
||||
{fmtCompact(minValue)}
|
||||
{showInTicks && !isLatency ? unit : ''}
|
||||
</text>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
|
||||
<line
|
||||
x1={padding.left}
|
||||
y1={height - padding.bottom}
|
||||
x2={width - padding.right}
|
||||
y2={height - padding.bottom}
|
||||
stroke='hsl(var(--border))'
|
||||
strokeWidth='1'
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{hoverIndex !== null &&
|
||||
scaledPoints[hoverIndex] &&
|
||||
(() => {
|
||||
const active =
|
||||
getSeriesById(activeSeriesId) || getSeriesById(hoverSeriesId) || scaledSeries[0]
|
||||
const pt = active.pts[hoverIndex] || scaledPoints[hoverIndex]
|
||||
const toDisplay = activeSeriesId
|
||||
? [getSeriesById(activeSeriesId)!]
|
||||
: scaledSeries.length > 1
|
||||
? scaledSeries.slice(1)
|
||||
: [scaledSeries[0]]
|
||||
|
||||
const fmt = (v?: number) => {
|
||||
if (typeof v !== 'number' || !Number.isFinite(v)) return '—'
|
||||
const u = unit || ''
|
||||
if (u.includes('%')) return `${v.toFixed(1)}%`
|
||||
if (u.toLowerCase() === 'latency') return formatLatency(v)
|
||||
if (u.toLowerCase().includes('ms')) return `${Math.round(v)}ms`
|
||||
if (u.toLowerCase().includes('exec')) return `${Math.round(v)}`
|
||||
return `${Math.round(v)}${u}`
|
||||
}
|
||||
|
||||
const longest = toDisplay.reduce((m, s) => {
|
||||
const seriesIndex = allSeries.findIndex((x) => x.id === s.id)
|
||||
const v = allSeries[seriesIndex]?.data?.[hoverIndex]?.value
|
||||
const valueStr = fmt(v)
|
||||
const labelStr = s.label || String(s.id || '')
|
||||
const len = `${labelStr} ${valueStr}`.length
|
||||
return Math.max(m, len)
|
||||
}, 0)
|
||||
const tooltipMaxW = Math.min(220, Math.max(80, 7 * longest + 24))
|
||||
const anchorX = hoverPos?.x ?? pt.x
|
||||
const margin = 10
|
||||
const preferRight = anchorX + margin + tooltipMaxW <= width - padding.right
|
||||
const left = preferRight
|
||||
? Math.max(
|
||||
padding.left,
|
||||
Math.min(anchorX + margin, width - padding.right - tooltipMaxW)
|
||||
)
|
||||
: Math.max(
|
||||
padding.left,
|
||||
Math.min(anchorX - margin - tooltipMaxW, width - padding.right - tooltipMaxW)
|
||||
)
|
||||
const anchorY = hoverPos?.y ?? pt.y
|
||||
const top = Math.min(Math.max(anchorY - 26, padding.top), height - padding.bottom - 18)
|
||||
return (
|
||||
<div
|
||||
className='pointer-events-none absolute rounded-lg border border-[var(--border-1)] bg-[var(--surface-1)] px-2 py-1.5 font-medium text-xs shadow-lg'
|
||||
style={{ left, top }}
|
||||
>
|
||||
{currentHoverDate && (
|
||||
<div className='mb-1 text-[var(--text-tertiary)] text-micro'>
|
||||
{currentHoverDate}
|
||||
</div>
|
||||
)}
|
||||
{toDisplay.map((s) => {
|
||||
const seriesIndex = allSeries.findIndex((x) => x.id === s.id)
|
||||
const val = allSeries[seriesIndex]?.data?.[hoverIndex]?.value
|
||||
const seriesLabel = s.label || s.id
|
||||
const showLabel =
|
||||
seriesLabel && seriesLabel !== 'base' && seriesLabel.trim() !== ''
|
||||
return (
|
||||
<div key={`tt-${s.id}`} className='flex items-center gap-2'>
|
||||
<span
|
||||
className='inline-block size-[6px] rounded-xs'
|
||||
style={{ backgroundColor: resolvedColors[s.id || ''] || s.color }}
|
||||
/>
|
||||
{showLabel && (
|
||||
<span className='text-[var(--text-secondary)]'>{seriesLabel}</span>
|
||||
)}
|
||||
<span className='text-[var(--text-primary)]'>{fmt(val)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized LineChart component to prevent re-renders when parent updates.
|
||||
*/
|
||||
export const LineChart = memo(LineChartComponent)
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export type { StatusBarSegment } from './status-bar'
|
||||
export { StatusBar } from './status-bar'
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
import { memo, useMemo, useState } from 'react'
|
||||
import { handleKeyboardActivation } from '@sim/emcn'
|
||||
import {
|
||||
type SegmentSelectionMode,
|
||||
useDashboardSegments,
|
||||
} from '@/app/workspace/[workspaceId]/logs/components/dashboard/dashboard-segments-context'
|
||||
|
||||
export interface StatusBarSegment {
|
||||
successRate: number
|
||||
hasExecutions: boolean
|
||||
totalExecutions: number
|
||||
successfulExecutions: number
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
interface StatusBarInnerProps {
|
||||
segments: StatusBarSegment[]
|
||||
selectedSegmentIndices: number[] | null
|
||||
onSegmentClick: (
|
||||
workflowId: string,
|
||||
index: number,
|
||||
timestamp: string,
|
||||
mode: SegmentSelectionMode
|
||||
) => void
|
||||
workflowId: string
|
||||
segmentDurationMs: number
|
||||
preferBelow?: boolean
|
||||
}
|
||||
|
||||
function StatusBarInner({
|
||||
segments,
|
||||
selectedSegmentIndices,
|
||||
onSegmentClick,
|
||||
workflowId,
|
||||
segmentDurationMs,
|
||||
preferBelow = false,
|
||||
}: StatusBarInnerProps) {
|
||||
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
|
||||
|
||||
const labels = useMemo(() => {
|
||||
return segments.map((segment) => {
|
||||
const start = new Date(segment.timestamp)
|
||||
const end = new Date(start.getTime() + (segmentDurationMs || 0))
|
||||
const rangeLabel = Number.isNaN(start.getTime())
|
||||
? ''
|
||||
: `${start.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })} – ${end.toLocaleString('en-US', { hour: 'numeric', minute: '2-digit' })}`
|
||||
return {
|
||||
rangeLabel,
|
||||
successLabel: `${segment.successRate.toFixed(1)}%`,
|
||||
countsLabel: `${segment.successfulExecutions ?? 0}/${segment.totalExecutions ?? 0} succeeded`,
|
||||
}
|
||||
})
|
||||
}, [segments, segmentDurationMs])
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<div
|
||||
className='flex select-none items-stretch gap-0.5'
|
||||
onMouseLeave={() => setHoverIndex(null)}
|
||||
>
|
||||
{segments.map((segment, i) => {
|
||||
const isSelected = Array.isArray(selectedSegmentIndices)
|
||||
? selectedSegmentIndices.includes(i)
|
||||
: false
|
||||
|
||||
let color: string
|
||||
let hoverBrightness: string
|
||||
if (!segment.hasExecutions) {
|
||||
color = 'bg-gray-300/60 dark:bg-gray-500/40'
|
||||
hoverBrightness = 'hover-hover:brightness-200'
|
||||
} else if (segment.successRate === 100) {
|
||||
color = 'bg-emerald-400/90'
|
||||
hoverBrightness = 'hover-hover:brightness-106'
|
||||
} else if (segment.successRate >= 95) {
|
||||
color = 'bg-amber-400/90'
|
||||
hoverBrightness = 'hover-hover:brightness-106'
|
||||
} else {
|
||||
color = 'bg-red-400/90'
|
||||
hoverBrightness = 'hover-hover:brightness-106'
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
aria-pressed={isSelected}
|
||||
className={`h-6 flex-1 rounded-[3px] ${color} ${hoverBrightness} cursor-pointer transition-all ${
|
||||
isSelected
|
||||
? 'relative z-10 scale-105 shadow-sm ring-1 ring-[var(--text-secondary)]'
|
||||
: 'relative z-0'
|
||||
}`}
|
||||
aria-label={`Segment ${i + 1}`}
|
||||
onMouseEnter={() => setHoverIndex(i)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const mode = e.shiftKey ? 'range' : e.metaKey || e.ctrlKey ? 'toggle' : 'single'
|
||||
onSegmentClick(workflowId, i, segment.timestamp, mode)
|
||||
}}
|
||||
onKeyDown={(event) =>
|
||||
handleKeyboardActivation(event, () =>
|
||||
onSegmentClick(workflowId, i, segment.timestamp, 'single')
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{hoverIndex !== null && segments[hoverIndex] && (
|
||||
<div
|
||||
className={`-translate-x-1/2 pointer-events-none absolute z-20 w-max whitespace-nowrap rounded-lg border border-[var(--border-1)] bg-[var(--surface-1)] px-2 py-1.5 text-center text-xs shadow-lg ${
|
||||
preferBelow ? '' : '-translate-y-full'
|
||||
}`}
|
||||
style={{
|
||||
left: `${((hoverIndex + 0.5) / (segments.length || 1)) * 100}%`,
|
||||
top: preferBelow ? '100%' : 0,
|
||||
marginTop: preferBelow ? 8 : -8,
|
||||
}}
|
||||
>
|
||||
{segments[hoverIndex].hasExecutions ? (
|
||||
<div>
|
||||
<div className='font-semibold text-[var(--text-primary)]'>
|
||||
{labels[hoverIndex].successLabel}
|
||||
</div>
|
||||
<div className='text-[var(--text-secondary)]'>{labels[hoverIndex].countsLabel}</div>
|
||||
{labels[hoverIndex].rangeLabel && (
|
||||
<div className='mt-0.5 text-[var(--text-tertiary)]'>
|
||||
{labels[hoverIndex].rangeLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-[var(--text-secondary)]'>{labels[hoverIndex].rangeLabel}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom equality function for the memoized StatusBar body.
|
||||
* Performs structural comparison of segments array to avoid re-renders
|
||||
* when poll data returns new object references with identical content.
|
||||
*/
|
||||
function areStatusBarPropsEqual(prev: StatusBarInnerProps, next: StatusBarInnerProps): boolean {
|
||||
if (prev.workflowId !== next.workflowId) return false
|
||||
if (prev.segmentDurationMs !== next.segmentDurationMs) return false
|
||||
if (prev.preferBelow !== next.preferBelow) return false
|
||||
if (prev.onSegmentClick !== next.onSegmentClick) return false
|
||||
|
||||
if (prev.selectedSegmentIndices !== next.selectedSegmentIndices) {
|
||||
if (!prev.selectedSegmentIndices || !next.selectedSegmentIndices) return false
|
||||
if (prev.selectedSegmentIndices.length !== next.selectedSegmentIndices.length) return false
|
||||
for (let i = 0; i < prev.selectedSegmentIndices.length; i++) {
|
||||
if (prev.selectedSegmentIndices[i] !== next.selectedSegmentIndices[i]) return false
|
||||
}
|
||||
}
|
||||
|
||||
if (prev.segments !== next.segments) {
|
||||
if (prev.segments.length !== next.segments.length) return false
|
||||
for (let i = 0; i < prev.segments.length; i++) {
|
||||
const ps = prev.segments[i]
|
||||
const ns = next.segments[i]
|
||||
if (
|
||||
ps.successRate !== ns.successRate ||
|
||||
ps.hasExecutions !== ns.hasExecutions ||
|
||||
ps.totalExecutions !== ns.totalExecutions ||
|
||||
ps.successfulExecutions !== ns.successfulExecutions ||
|
||||
ps.timestamp !== ns.timestamp
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const MemoizedStatusBar = memo(StatusBarInner, areStatusBarPropsEqual)
|
||||
|
||||
export interface StatusBarProps {
|
||||
segments: StatusBarSegment[]
|
||||
workflowId: string
|
||||
preferBelow?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Status bar for a single workflow row. Reads segment selection state from
|
||||
* DashboardSegmentsContext and delegates to a structurally-memoized body so
|
||||
* only bars whose selection actually changed re-render.
|
||||
*/
|
||||
export function StatusBar({ segments, workflowId, preferBelow = false }: StatusBarProps) {
|
||||
const { selectedSegments, onSegmentClick, segmentDurationMs } = useDashboardSegments()
|
||||
|
||||
return (
|
||||
<MemoizedStatusBar
|
||||
segments={segments}
|
||||
selectedSegmentIndices={selectedSegments[workflowId] || null}
|
||||
onSegmentClick={onSegmentClick}
|
||||
workflowId={workflowId}
|
||||
segmentDurationMs={segmentDurationMs}
|
||||
preferBelow={preferBelow}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export type { WorkflowExecutionItem } from './workflows-list'
|
||||
export { WorkflowsList } from './workflows-list'
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
import { memo } from 'react'
|
||||
import { cn, handleKeyboardActivation } from '@sim/emcn'
|
||||
import { Workflow } from '@sim/emcn/icons'
|
||||
import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components'
|
||||
import { DELETED_WORKFLOW_LABEL } from '@/app/workspace/[workspaceId]/logs/utils'
|
||||
import { StatusBar, type StatusBarSegment } from '..'
|
||||
|
||||
export interface WorkflowExecutionItem {
|
||||
workflowId: string
|
||||
workflowName: string
|
||||
segments: StatusBarSegment[]
|
||||
overallSuccessRate: number
|
||||
}
|
||||
|
||||
interface WorkflowsListProps {
|
||||
filteredExecutions: WorkflowExecutionItem[]
|
||||
expandedWorkflowId: string | null
|
||||
onToggleWorkflow: (workflowId: string) => void
|
||||
searchQuery: string
|
||||
}
|
||||
|
||||
function WorkflowsListInner({
|
||||
filteredExecutions,
|
||||
expandedWorkflowId,
|
||||
onToggleWorkflow,
|
||||
searchQuery,
|
||||
}: WorkflowsListProps) {
|
||||
return (
|
||||
<div className='flex h-full flex-col overflow-hidden rounded-md bg-[var(--surface-2)] dark:bg-[var(--surface-1)]'>
|
||||
{/* Table header */}
|
||||
<div className='flex-shrink-0 rounded-t-[6px] bg-[var(--surface-3)] px-6 py-2.5 dark:bg-[var(--surface-3)]'>
|
||||
<div className='flex items-center gap-4'>
|
||||
<span className='w-[160px] flex-shrink-0 font-medium text-[var(--text-tertiary)] text-caption'>
|
||||
Workflow
|
||||
</span>
|
||||
<span className='flex-1 font-medium text-[var(--text-tertiary)] text-caption'>Logs</span>
|
||||
<span className='w-[100px] flex-shrink-0 pl-4 font-medium text-[var(--text-tertiary)] text-caption'>
|
||||
Success Rate
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table body - scrollable */}
|
||||
<div className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden'>
|
||||
{filteredExecutions.length === 0 ? (
|
||||
<div className='flex items-center justify-center py-8'>
|
||||
<span className='text-[var(--text-secondary)] text-small'>
|
||||
{searchQuery ? `No workflows found matching "${searchQuery}"` : 'No workflows found'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{filteredExecutions.map((workflow, idx) => {
|
||||
const isSelected = expandedWorkflowId === workflow.workflowId
|
||||
const isDeletedWorkflow = workflow.workflowName === DELETED_WORKFLOW_LABEL
|
||||
const canToggle = !isDeletedWorkflow
|
||||
|
||||
return (
|
||||
<div
|
||||
key={workflow.workflowId}
|
||||
role='button'
|
||||
aria-disabled={!canToggle}
|
||||
tabIndex={canToggle ? 0 : undefined}
|
||||
className={cn(
|
||||
'flex h-[44px] items-center gap-4 px-6 hover-hover:bg-[var(--surface-3)] dark:hover-hover:bg-[var(--surface-4)]',
|
||||
canToggle ? 'cursor-pointer' : 'cursor-default',
|
||||
isSelected && 'bg-[var(--surface-3)] dark:bg-[var(--surface-4)]'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (canToggle) {
|
||||
onToggleWorkflow(workflow.workflowId)
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!canToggle) return
|
||||
handleKeyboardActivation(event, () => onToggleWorkflow(workflow.workflowId))
|
||||
}}
|
||||
>
|
||||
{/* Workflow name with icon */}
|
||||
<div className='flex w-[160px] flex-shrink-0 items-center gap-2 pr-2'>
|
||||
<Workflow className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<FloatingOverflowText
|
||||
label={workflow.workflowName}
|
||||
className='block truncate font-medium text-[var(--text-primary)] text-caption'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status bar - takes most of the space */}
|
||||
<div className='flex-1'>
|
||||
<StatusBar
|
||||
segments={workflow.segments}
|
||||
workflowId={workflow.workflowId}
|
||||
preferBelow={idx < 2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Success rate */}
|
||||
<span className='w-[100px] flex-shrink-0 pl-4 font-medium text-[var(--text-primary)] text-caption'>
|
||||
{workflow.overallSuccessRate.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const WorkflowsList = memo(WorkflowsListInner)
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
/**
|
||||
* Selection mode for a segment click: plain click, cmd/ctrl+click, or shift+click.
|
||||
*/
|
||||
export type SegmentSelectionMode = 'single' | 'toggle' | 'range'
|
||||
|
||||
export interface DashboardSegmentsContextValue {
|
||||
/** Selected segment indices keyed by workflow id. */
|
||||
selectedSegments: Record<string, number[]>
|
||||
/** Handles a segment click for selecting time segments. */
|
||||
onSegmentClick: (
|
||||
workflowId: string,
|
||||
segmentIndex: number,
|
||||
timestamp: string,
|
||||
mode: SegmentSelectionMode
|
||||
) => void
|
||||
/** Duration of a single segment in milliseconds. */
|
||||
segmentDurationMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature-local context for dashboard segment selection state, provided by
|
||||
* the dashboard and consumed by StatusBar without threading props through
|
||||
* intermediate components.
|
||||
*/
|
||||
export const DashboardSegmentsContext = createContext<DashboardSegmentsContextValue | null>(null)
|
||||
|
||||
/**
|
||||
* Returns the dashboard segment selection context.
|
||||
* @throws Error when used outside DashboardSegmentsContext.Provider
|
||||
*/
|
||||
export function useDashboardSegments(): DashboardSegmentsContextValue {
|
||||
const context = useContext(DashboardSegmentsContext)
|
||||
if (!context) {
|
||||
throw new Error('useDashboardSegments must be used within DashboardSegmentsContext.Provider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { Loader } from '@sim/emcn'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
DashboardSegmentsContext,
|
||||
type SegmentSelectionMode,
|
||||
} from '@/app/workspace/[workspaceId]/logs/components/dashboard/dashboard-segments-context'
|
||||
import { useLogFilters } from '@/app/workspace/[workspaceId]/logs/hooks/use-log-filters'
|
||||
import { formatLatency } from '@/app/workspace/[workspaceId]/logs/utils'
|
||||
import type { DashboardStatsResponse, WorkflowStats } from '@/hooks/queries/logs'
|
||||
import { useWorkflows } from '@/hooks/queries/workflows'
|
||||
import { LineChart, WorkflowsList } from './components'
|
||||
|
||||
interface WorkflowExecution {
|
||||
workflowId: string
|
||||
workflowName: string
|
||||
segments: {
|
||||
successRate: number
|
||||
timestamp: string
|
||||
hasExecutions: boolean
|
||||
totalExecutions: number
|
||||
successfulExecutions: number
|
||||
avgDurationMs?: number
|
||||
p50Ms?: number
|
||||
p90Ms?: number
|
||||
p99Ms?: number
|
||||
}[]
|
||||
overallSuccessRate: number
|
||||
}
|
||||
|
||||
function DashboardFallback() {
|
||||
return <div className='mt-6 flex min-h-0 flex-1 flex-col bg-[var(--bg)] pb-6' />
|
||||
}
|
||||
|
||||
interface DashboardProps {
|
||||
stats?: DashboardStatsResponse
|
||||
isLoading: boolean
|
||||
error?: Error | null
|
||||
/**
|
||||
* Debounced search term. Comes pre-debounced from the parent (same value the
|
||||
* dashboard stats query uses) so the in-memory workflow list filtering and the
|
||||
* stats query stay in sync while typing.
|
||||
*/
|
||||
searchQuery: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts server WorkflowStats to the internal WorkflowExecution format.
|
||||
*/
|
||||
function toWorkflowExecution(wf: WorkflowStats): WorkflowExecution {
|
||||
return {
|
||||
workflowId: wf.workflowId,
|
||||
workflowName: wf.workflowName,
|
||||
overallSuccessRate: wf.overallSuccessRate,
|
||||
segments: wf.segments.map((seg) => ({
|
||||
timestamp: seg.timestamp,
|
||||
totalExecutions: seg.totalExecutions,
|
||||
successfulExecutions: seg.successfulExecutions,
|
||||
hasExecutions: seg.totalExecutions > 0,
|
||||
successRate:
|
||||
seg.totalExecutions > 0 ? (seg.successfulExecutions / seg.totalExecutions) * 100 : 100,
|
||||
avgDurationMs: seg.avgDurationMs,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function DashboardInner({ stats, isLoading, error, searchQuery }: DashboardProps) {
|
||||
const [selectedSegments, setSelectedSegments] = useState<Record<string, number[]>>({})
|
||||
const [lastAnchorIndices, setLastAnchorIndices] = useState<Record<string, number>>({})
|
||||
const lastAnchorIndicesRef = useRef<Record<string, number>>({})
|
||||
|
||||
const { workflowIds, toggleWorkflowId, timeRange } = useLogFilters()
|
||||
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>()
|
||||
const { data: allWorkflowList = [], isPending: isWorkflowsPending } = useWorkflows(workspaceId)
|
||||
|
||||
const expandedWorkflowId = workflowIds.length === 1 ? workflowIds[0] : null
|
||||
|
||||
const { rawExecutions, aggregateSegments, segmentMs } = useMemo(() => {
|
||||
if (!stats) {
|
||||
return { rawExecutions: [], aggregateSegments: [], segmentMs: 0 }
|
||||
}
|
||||
|
||||
return {
|
||||
rawExecutions: stats.workflows.map(toWorkflowExecution),
|
||||
aggregateSegments: stats.aggregateSegments,
|
||||
segmentMs: stats.segmentMs,
|
||||
}
|
||||
}, [stats])
|
||||
|
||||
/**
|
||||
* Stabilize execution objects: reuse previous references for workflows
|
||||
* whose segment data hasn't structurally changed between polls.
|
||||
* This prevents cascading re-renders through WorkflowsList → StatusBar.
|
||||
*/
|
||||
const prevExecutionsRef = useRef<WorkflowExecution[]>([])
|
||||
|
||||
const executions = useMemo(() => {
|
||||
const prevMap = new Map(prevExecutionsRef.current.map((e) => [e.workflowId, e]))
|
||||
let anyChanged = false
|
||||
|
||||
const result = rawExecutions.map((exec) => {
|
||||
const prev = prevMap.get(exec.workflowId)
|
||||
if (!prev) {
|
||||
anyChanged = true
|
||||
return exec
|
||||
}
|
||||
if (
|
||||
prev.overallSuccessRate !== exec.overallSuccessRate ||
|
||||
prev.workflowName !== exec.workflowName ||
|
||||
prev.segments.length !== exec.segments.length
|
||||
) {
|
||||
anyChanged = true
|
||||
return exec
|
||||
}
|
||||
|
||||
for (let i = 0; i < prev.segments.length; i++) {
|
||||
const ps = prev.segments[i]
|
||||
const ns = exec.segments[i]
|
||||
if (
|
||||
ps.totalExecutions !== ns.totalExecutions ||
|
||||
ps.successfulExecutions !== ns.successfulExecutions ||
|
||||
ps.timestamp !== ns.timestamp ||
|
||||
ps.avgDurationMs !== ns.avgDurationMs ||
|
||||
ps.p50Ms !== ns.p50Ms ||
|
||||
ps.p90Ms !== ns.p90Ms ||
|
||||
ps.p99Ms !== ns.p99Ms
|
||||
) {
|
||||
anyChanged = true
|
||||
return exec
|
||||
}
|
||||
}
|
||||
|
||||
return prev
|
||||
})
|
||||
|
||||
if (
|
||||
!anyChanged &&
|
||||
result.length === prevExecutionsRef.current.length &&
|
||||
result.every((r, i) => r === prevExecutionsRef.current[i])
|
||||
) {
|
||||
return prevExecutionsRef.current
|
||||
}
|
||||
|
||||
return result
|
||||
}, [rawExecutions])
|
||||
prevExecutionsRef.current = executions
|
||||
|
||||
const lastExecutionByWorkflow = useMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
for (const wf of executions) {
|
||||
for (let i = wf.segments.length - 1; i >= 0; i--) {
|
||||
if (wf.segments[i].totalExecutions > 0) {
|
||||
map.set(wf.workflowId, new Date(wf.segments[i].timestamp).getTime())
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [executions])
|
||||
|
||||
const filteredExecutions = useMemo(() => {
|
||||
let filtered = executions
|
||||
|
||||
if (workflowIds.length > 0) {
|
||||
filtered = filtered.filter((wf) => workflowIds.includes(wf.workflowId))
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase().trim()
|
||||
filtered = filtered.filter((wf) => wf.workflowName.toLowerCase().includes(query))
|
||||
}
|
||||
|
||||
return filtered.slice().sort((a, b) => {
|
||||
const timeA = lastExecutionByWorkflow.get(a.workflowId) ?? 0
|
||||
const timeB = lastExecutionByWorkflow.get(b.workflowId) ?? 0
|
||||
|
||||
if (!timeA && !timeB) return a.workflowName.localeCompare(b.workflowName)
|
||||
if (!timeA) return 1
|
||||
if (!timeB) return -1
|
||||
|
||||
return timeB - timeA
|
||||
})
|
||||
}, [executions, lastExecutionByWorkflow, workflowIds, searchQuery])
|
||||
|
||||
const globalDetails = useMemo(() => {
|
||||
if (!aggregateSegments.length) return null
|
||||
|
||||
const hasSelection = Object.keys(selectedSegments).length > 0
|
||||
const hasWorkflowFilter = expandedWorkflowId !== null
|
||||
|
||||
const segmentsToUse = hasSelection
|
||||
? (() => {
|
||||
const allSelectedIndices = new Set<number>()
|
||||
Object.values(selectedSegments).forEach((indices) => {
|
||||
indices.forEach((idx) => allSelectedIndices.add(idx))
|
||||
})
|
||||
|
||||
return Array.from(allSelectedIndices)
|
||||
.sort((a, b) => a - b)
|
||||
.map((idx) => {
|
||||
let totalExecutions = 0
|
||||
let successfulExecutions = 0
|
||||
let weightedLatencySum = 0
|
||||
let latencyCount = 0
|
||||
const timestamp = aggregateSegments[idx]?.timestamp || ''
|
||||
|
||||
Object.entries(selectedSegments).forEach(([workflowId, indices]) => {
|
||||
if (!indices.includes(idx)) return
|
||||
if (hasWorkflowFilter && workflowId !== expandedWorkflowId) return
|
||||
|
||||
const workflow = filteredExecutions.find((w) => w.workflowId === workflowId)
|
||||
const segment = workflow?.segments[idx]
|
||||
if (!segment) return
|
||||
|
||||
totalExecutions += segment.totalExecutions || 0
|
||||
successfulExecutions += segment.successfulExecutions || 0
|
||||
|
||||
if (segment.avgDurationMs && segment.totalExecutions) {
|
||||
weightedLatencySum += segment.avgDurationMs * segment.totalExecutions
|
||||
latencyCount += segment.totalExecutions
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
timestamp,
|
||||
totalExecutions,
|
||||
successfulExecutions,
|
||||
avgDurationMs: latencyCount > 0 ? weightedLatencySum / latencyCount : 0,
|
||||
}
|
||||
})
|
||||
})()
|
||||
: hasWorkflowFilter
|
||||
? (() => {
|
||||
const workflow = filteredExecutions.find((w) => w.workflowId === expandedWorkflowId)
|
||||
if (!workflow) return aggregateSegments
|
||||
|
||||
return workflow.segments.map((segment) => ({
|
||||
timestamp: segment.timestamp,
|
||||
totalExecutions: segment.totalExecutions || 0,
|
||||
successfulExecutions: segment.successfulExecutions || 0,
|
||||
avgDurationMs: segment.avgDurationMs ?? 0,
|
||||
}))
|
||||
})()
|
||||
: aggregateSegments
|
||||
|
||||
const executionCounts = segmentsToUse.map((s) => ({
|
||||
timestamp: s.timestamp,
|
||||
value: s.totalExecutions,
|
||||
}))
|
||||
|
||||
const failureCounts = segmentsToUse.map((s) => ({
|
||||
timestamp: s.timestamp,
|
||||
value: s.totalExecutions - s.successfulExecutions,
|
||||
}))
|
||||
|
||||
const latencies = segmentsToUse.map((s) => ({
|
||||
timestamp: s.timestamp,
|
||||
value: s.avgDurationMs ?? 0,
|
||||
}))
|
||||
|
||||
const totalRuns = segmentsToUse.reduce((sum, s) => sum + s.totalExecutions, 0)
|
||||
const totalErrors = segmentsToUse.reduce(
|
||||
(sum, s) => sum + (s.totalExecutions - s.successfulExecutions),
|
||||
0
|
||||
)
|
||||
|
||||
let weightedLatencySum = 0
|
||||
let latencyCount = 0
|
||||
for (const s of segmentsToUse) {
|
||||
if (s.avgDurationMs && s.totalExecutions > 0) {
|
||||
weightedLatencySum += s.avgDurationMs * s.totalExecutions
|
||||
latencyCount += s.totalExecutions
|
||||
}
|
||||
}
|
||||
const avgLatency = latencyCount > 0 ? weightedLatencySum / latencyCount : 0
|
||||
|
||||
return {
|
||||
executionCounts,
|
||||
failureCounts,
|
||||
latencies,
|
||||
totalRuns,
|
||||
totalErrors,
|
||||
avgLatency,
|
||||
}
|
||||
}, [aggregateSegments, selectedSegments, filteredExecutions, expandedWorkflowId])
|
||||
|
||||
const handleToggleWorkflow = useCallback(
|
||||
(workflowId: string) => {
|
||||
toggleWorkflowId(workflowId)
|
||||
},
|
||||
[toggleWorkflowId]
|
||||
)
|
||||
|
||||
lastAnchorIndicesRef.current = lastAnchorIndices
|
||||
|
||||
/**
|
||||
* Handles segment click for selecting time segments.
|
||||
* @param workflowId - The workflow containing the segment
|
||||
* @param segmentIndex - Index of the clicked segment
|
||||
* @param _timestamp - Timestamp of the segment (unused)
|
||||
* @param mode - Selection mode: 'single', 'toggle' (cmd+click), or 'range' (shift+click)
|
||||
*/
|
||||
const handleSegmentClick = useCallback(
|
||||
(workflowId: string, segmentIndex: number, _timestamp: string, mode: SegmentSelectionMode) => {
|
||||
if (mode === 'toggle') {
|
||||
setSelectedSegments((prev) => {
|
||||
const currentSegments = prev[workflowId] || []
|
||||
const exists = currentSegments.includes(segmentIndex)
|
||||
const nextSegments = exists
|
||||
? currentSegments.filter((i) => i !== segmentIndex)
|
||||
: [...currentSegments, segmentIndex].sort((a, b) => a - b)
|
||||
|
||||
if (nextSegments.length === 0) {
|
||||
const { [workflowId]: _, ...rest } = prev
|
||||
return rest
|
||||
}
|
||||
|
||||
return { ...prev, [workflowId]: nextSegments }
|
||||
})
|
||||
|
||||
setLastAnchorIndices((prev) => ({ ...prev, [workflowId]: segmentIndex }))
|
||||
} else if (mode === 'single') {
|
||||
setSelectedSegments((prev) => {
|
||||
const currentSegments = prev[workflowId] || []
|
||||
const isOnlySelectedSegment =
|
||||
currentSegments.length === 1 && currentSegments[0] === segmentIndex
|
||||
const isOnlyWorkflowSelected = Object.keys(prev).length === 1 && prev[workflowId]
|
||||
|
||||
if (isOnlySelectedSegment && isOnlyWorkflowSelected) {
|
||||
setLastAnchorIndices({})
|
||||
return {}
|
||||
}
|
||||
|
||||
setLastAnchorIndices({ [workflowId]: segmentIndex })
|
||||
return { [workflowId]: [segmentIndex] }
|
||||
})
|
||||
} else if (mode === 'range') {
|
||||
setSelectedSegments((prev) => {
|
||||
const currentSegments = prev[workflowId] || []
|
||||
const anchor = lastAnchorIndicesRef.current[workflowId] ?? segmentIndex
|
||||
const [start, end] =
|
||||
anchor < segmentIndex ? [anchor, segmentIndex] : [segmentIndex, anchor]
|
||||
const range = Array.from({ length: end - start + 1 }, (_, i) => start + i)
|
||||
const union = new Set([...currentSegments, ...range])
|
||||
return { ...prev, [workflowId]: Array.from(union).sort((a, b) => a - b) }
|
||||
})
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const segmentsContextValue = useMemo(
|
||||
() => ({
|
||||
selectedSegments,
|
||||
onSegmentClick: handleSegmentClick,
|
||||
segmentDurationMs: segmentMs,
|
||||
}),
|
||||
[selectedSegments, handleSegmentClick, segmentMs]
|
||||
)
|
||||
|
||||
const resetKey = `${JSON.stringify(stats?.workflows?.map((w) => w.workflowId))}-${timeRange}-${workflowIds.join(',')}-${searchQuery}`
|
||||
const prevResetKeyRef = useRef(resetKey)
|
||||
if (resetKey !== prevResetKeyRef.current) {
|
||||
prevResetKeyRef.current = resetKey
|
||||
if (Object.keys(selectedSegments).length > 0) setSelectedSegments({})
|
||||
if (Object.keys(lastAnchorIndices).length > 0) setLastAnchorIndices({})
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <DashboardFallback />
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className='mt-6 flex flex-1 items-center justify-center'>
|
||||
<div className='text-[var(--text-error)]'>
|
||||
<p className='font-medium text-small'>Error loading data</p>
|
||||
<p className='text-caption'>{error.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isWorkflowsPending && allWorkflowList.length === 0) {
|
||||
return (
|
||||
<div className='mt-6 flex flex-1 items-center justify-center'>
|
||||
<div className='text-center text-[var(--text-secondary)]'>
|
||||
<p className='font-medium text-small'>No workflows</p>
|
||||
<p className='mt-1 text-caption'>Create a workflow to see its execution history here</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mt-6 flex min-h-0 flex-1 flex-col pb-6'>
|
||||
<div className='mb-4 flex-shrink-0'>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-3'>
|
||||
<div className='flex flex-col overflow-hidden rounded-md bg-[var(--surface-2)] dark:bg-[var(--surface-2)]'>
|
||||
<div className='flex min-w-0 items-center justify-between gap-2 bg-[var(--surface-3)] px-4 py-[9px] dark:bg-[var(--surface-3)]'>
|
||||
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-sm'>
|
||||
Runs
|
||||
</span>
|
||||
{globalDetails && (
|
||||
<span className='flex-shrink-0 font-medium text-[var(--text-secondary)] text-sm'>
|
||||
{globalDetails.totalRuns}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex-1 overflow-y-auto rounded-t-[6px] bg-[var(--surface-2)] px-3.5 py-2.5 dark:bg-[var(--surface-1)]'>
|
||||
{globalDetails ? (
|
||||
<LineChart
|
||||
data={globalDetails.executionCounts}
|
||||
label=''
|
||||
color='var(--success)'
|
||||
unit=''
|
||||
/>
|
||||
) : (
|
||||
<div className='flex h-[166px] items-center justify-center'>
|
||||
<Loader className='size-[16px] text-[var(--text-secondary)]' animate />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col overflow-hidden rounded-md bg-[var(--surface-2)] dark:bg-[var(--surface-2)]'>
|
||||
<div className='flex min-w-0 items-center justify-between gap-2 bg-[var(--surface-3)] px-4 py-[9px] dark:bg-[var(--surface-3)]'>
|
||||
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-sm'>
|
||||
Errors
|
||||
</span>
|
||||
{globalDetails && (
|
||||
<span className='flex-shrink-0 font-medium text-[var(--text-secondary)] text-sm'>
|
||||
{globalDetails.totalErrors}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex-1 overflow-y-auto rounded-t-[6px] bg-[var(--surface-2)] px-3.5 py-2.5 dark:bg-[var(--surface-1)]'>
|
||||
{globalDetails ? (
|
||||
<LineChart
|
||||
data={globalDetails.failureCounts}
|
||||
label=''
|
||||
color='var(--text-error)'
|
||||
unit=''
|
||||
/>
|
||||
) : (
|
||||
<div className='flex h-[166px] items-center justify-center'>
|
||||
<Loader className='size-[16px] text-[var(--text-secondary)]' animate />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col overflow-hidden rounded-md bg-[var(--surface-2)] dark:bg-[var(--surface-2)]'>
|
||||
<div className='flex min-w-0 items-center justify-between gap-2 bg-[var(--surface-3)] px-4 py-[9px] dark:bg-[var(--surface-3)]'>
|
||||
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-sm'>
|
||||
Latency
|
||||
</span>
|
||||
{globalDetails && (
|
||||
<span className='flex-shrink-0 font-medium text-[var(--text-secondary)] text-sm'>
|
||||
{formatLatency(globalDetails.avgLatency)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex-1 overflow-y-auto rounded-t-[6px] bg-[var(--surface-2)] px-3.5 py-2.5 dark:bg-[var(--surface-1)]'>
|
||||
{globalDetails ? (
|
||||
<LineChart
|
||||
data={globalDetails.latencies}
|
||||
label=''
|
||||
color='var(--caution)'
|
||||
unit='latency'
|
||||
/>
|
||||
) : (
|
||||
<div className='flex h-[166px] items-center justify-center'>
|
||||
<Loader className='size-[16px] text-[var(--text-secondary)]' animate />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='min-h-0 flex-1 overflow-hidden'>
|
||||
<DashboardSegmentsContext.Provider value={segmentsContextValue}>
|
||||
<WorkflowsList
|
||||
filteredExecutions={filteredExecutions as WorkflowExecution[]}
|
||||
expandedWorkflowId={expandedWorkflowId}
|
||||
onToggleWorkflow={handleToggleWorkflow}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
</DashboardSegmentsContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(DashboardInner)
|
||||
@@ -0,0 +1 @@
|
||||
export { default, default as Dashboard } from './dashboard'
|
||||
@@ -0,0 +1,6 @@
|
||||
export { Dashboard } from './dashboard'
|
||||
export { LogDetails, LogDetailsContent } from './log-details'
|
||||
export { ExecutionSnapshot } from './log-details/components/execution-snapshot'
|
||||
export { FileCards } from './log-details/components/file-download'
|
||||
export { TraceView } from './log-details/components/trace-view'
|
||||
export { LogRowContextMenu } from './log-row-context-menu'
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
cn,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Duplicate,
|
||||
Loader,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalDescription,
|
||||
ModalHeader,
|
||||
} from '@sim/emcn'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Preview } from '@/app/workspace/[workspaceId]/w/components/preview'
|
||||
import { useExecutionSnapshot } from '@/hooks/queries/logs'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
interface TraceSpan {
|
||||
blockId?: string
|
||||
input?: unknown
|
||||
output?: unknown
|
||||
status?: string
|
||||
duration?: number
|
||||
children?: TraceSpan[]
|
||||
}
|
||||
|
||||
interface MigratedWorkflowState extends WorkflowState {
|
||||
_migrated: true
|
||||
_note?: string
|
||||
}
|
||||
|
||||
function isMigratedWorkflowState(state: WorkflowState): state is MigratedWorkflowState {
|
||||
return (state as MigratedWorkflowState)._migrated === true
|
||||
}
|
||||
|
||||
interface ExecutionSnapshotProps {
|
||||
executionId: string
|
||||
traceSpans?: TraceSpan[]
|
||||
className?: string
|
||||
height?: string | number
|
||||
width?: string | number
|
||||
isModal?: boolean
|
||||
isOpen?: boolean
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export function ExecutionSnapshot({
|
||||
executionId,
|
||||
traceSpans,
|
||||
className,
|
||||
height = '100%',
|
||||
width = '100%',
|
||||
isModal = false,
|
||||
isOpen = false,
|
||||
onClose = () => {},
|
||||
}: ExecutionSnapshotProps) {
|
||||
const { data, isLoading, error } = useExecutionSnapshot(executionId)
|
||||
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 })
|
||||
|
||||
function closeMenu() {
|
||||
setIsMenuOpen(false)
|
||||
}
|
||||
|
||||
function handleCanvasContextMenu(e: React.MouseEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setMenuPosition({ x: e.clientX, y: e.clientY })
|
||||
setIsMenuOpen(true)
|
||||
}
|
||||
|
||||
function handleCopyExecutionId() {
|
||||
navigator.clipboard.writeText(executionId)
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
const workflowState = data?.workflowState as WorkflowState | undefined
|
||||
const childWorkflowSnapshots = data?.childWorkflowSnapshots as
|
||||
| Record<string, WorkflowState>
|
||||
| undefined
|
||||
|
||||
const renderContent = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center justify-center', className)}
|
||||
style={{ height, width }}
|
||||
>
|
||||
<div className='flex items-center gap-2 text-[var(--text-secondary)]'>
|
||||
<Loader className='size-[16px]' animate />
|
||||
<span className='text-small'>Loading run snapshot…</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center justify-center', className)}
|
||||
style={{ height, width }}
|
||||
>
|
||||
<div className='flex items-center gap-2 text-[var(--text-error)]'>
|
||||
<AlertCircle className='size-[16px]' />
|
||||
<span className='text-small'>Failed to load run snapshot: {error.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data || !workflowState) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center justify-center', className)}
|
||||
style={{ height, width }}
|
||||
>
|
||||
<div className='flex items-center gap-2 text-[var(--text-secondary)]'>
|
||||
<Loader className='size-[16px]' animate />
|
||||
<span className='text-small'>Loading run snapshot…</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMigratedWorkflowState(workflowState)) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-col items-center justify-center gap-4 p-8', className)}
|
||||
style={{ height, width }}
|
||||
>
|
||||
<div className='flex items-center gap-3 text-[var(--text-warning)]'>
|
||||
<AlertCircle className='size-[20px]' />
|
||||
<span className='font-medium text-base'>Logged State Not Found</span>
|
||||
</div>
|
||||
<div className='max-w-md text-center text-[var(--text-secondary)] text-small'>
|
||||
This log was migrated from the old logging system. The workflow state at the time of
|
||||
this run is not available.
|
||||
</div>
|
||||
<div className='text-[var(--text-tertiary)] text-caption'>
|
||||
Note: {workflowState._note}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Preview
|
||||
key={executionId}
|
||||
workflowState={workflowState}
|
||||
traceSpans={traceSpans}
|
||||
childWorkflowSnapshots={childWorkflowSnapshots}
|
||||
className={className}
|
||||
height={height}
|
||||
width={width}
|
||||
onCanvasContextMenu={handleCanvasContextMenu}
|
||||
showBorder={!isModal}
|
||||
autoSelectLeftmost
|
||||
showBlockCloseButton={!isModal}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const canvasContextMenu =
|
||||
typeof document !== 'undefined'
|
||||
? createPortal(
|
||||
<DropdownMenu open={isMenuOpen} onOpenChange={closeMenu} modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${menuPosition.x}px`,
|
||||
top: `${menuPosition.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
side='bottom'
|
||||
sideOffset={4}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DropdownMenuItem onSelect={handleCopyExecutionId}>
|
||||
<Duplicate />
|
||||
Copy Run ID
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
document.body
|
||||
)
|
||||
: null
|
||||
|
||||
if (isModal) {
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ModalContent size='full' className='flex h-[90vh] flex-col'>
|
||||
<ModalHeader>Workflow State</ModalHeader>
|
||||
|
||||
<ModalBody className='!p-0 min-h-0 flex-1 overflow-hidden'>
|
||||
<ModalDescription className='sr-only'>
|
||||
View the workflow state snapshot for this execution
|
||||
</ModalDescription>
|
||||
{renderContent()}
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
{canvasContextMenu}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderContent()}
|
||||
{canvasContextMenu}
|
||||
</>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { ExecutionSnapshot } from './execution-snapshot'
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@sim/emcn'
|
||||
import { Download } from '@sim/emcn/icons'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { extractWorkspaceIdFromExecutionKey, getViewerUrl } from '@/lib/uploads/utils/file-utils'
|
||||
|
||||
const logger = createLogger('FileCards')
|
||||
|
||||
interface FileData {
|
||||
id?: string
|
||||
name: string
|
||||
size: number
|
||||
type: string
|
||||
key: string
|
||||
url: string
|
||||
storageProvider?: 's3' | 'blob' | 'local'
|
||||
bucketName?: string
|
||||
}
|
||||
|
||||
interface FileCardsProps {
|
||||
files: FileData[]
|
||||
isExecutionFile?: boolean
|
||||
workspaceId?: string
|
||||
}
|
||||
|
||||
interface FileCardProps {
|
||||
file: FileData
|
||||
isExecutionFile?: boolean
|
||||
workspaceId?: string
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`
|
||||
}
|
||||
|
||||
function FileCard({ file, isExecutionFile = false, workspaceId }: FileCardProps) {
|
||||
const router = useRouter()
|
||||
|
||||
const handleDownload = () => {
|
||||
try {
|
||||
logger.info(`Initiating download for file: ${file.name}`)
|
||||
|
||||
if (file.key.startsWith('url/')) {
|
||||
if (file.url) {
|
||||
window.open(file.url, '_blank')
|
||||
logger.info(`Opened URL-type file directly: ${file.url}`)
|
||||
return
|
||||
}
|
||||
throw new Error('URL is required for URL-type files')
|
||||
}
|
||||
|
||||
let resolvedWorkspaceId = workspaceId
|
||||
if (!resolvedWorkspaceId && isExecutionFile) {
|
||||
resolvedWorkspaceId = extractWorkspaceIdFromExecutionKey(file.key) || undefined
|
||||
} else if (!resolvedWorkspaceId) {
|
||||
const segments = file.key.split('/')
|
||||
if (segments.length >= 2 && /^[a-f0-9-]{36}$/.test(segments[0])) {
|
||||
resolvedWorkspaceId = segments[0]
|
||||
}
|
||||
}
|
||||
|
||||
if (isExecutionFile) {
|
||||
const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=execution`
|
||||
window.open(serveUrl, '_blank')
|
||||
logger.info(`Opened execution file serve URL: ${serveUrl}`)
|
||||
} else {
|
||||
const viewerUrl = resolvedWorkspaceId ? getViewerUrl(file.key, resolvedWorkspaceId) : null
|
||||
|
||||
if (viewerUrl) {
|
||||
router.push(viewerUrl)
|
||||
logger.info(`Navigated to viewer URL: ${viewerUrl}`)
|
||||
} else {
|
||||
logger.warn(
|
||||
`Could not construct viewer URL for file: ${file.name}, falling back to serve URL`
|
||||
)
|
||||
const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`
|
||||
window.open(serveUrl, '_blank')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to download file ${file.name}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-1 rounded-md bg-[var(--surface-1)] px-2 py-1.5'>
|
||||
<div className='flex min-w-0 items-center justify-between gap-2'>
|
||||
<span className='min-w-0 flex-1 truncate font-medium text-[var(--text-secondary)] text-caption'>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className='flex-shrink-0 font-medium text-[var(--text-tertiary)] text-caption'>
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-medium text-[var(--text-subtle)] text-xs'>{file.type}</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='!h-[20px] !px-1.5 !py-0 text-xs'
|
||||
onClick={handleDownload}
|
||||
>
|
||||
<Download className='mr-1 size-[10px]' />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FileCards({ files, isExecutionFile = false, workspaceId }: FileCardsProps) {
|
||||
if (!files || files.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mt-1 flex flex-col gap-1.5 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2.5 py-2 dark:bg-transparent'>
|
||||
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
|
||||
Files ({files.length})
|
||||
</span>
|
||||
{files.map((file, index) => (
|
||||
<FileCard
|
||||
key={file.id || `file-${index}`}
|
||||
file={file}
|
||||
isExecutionFile={isExecutionFile}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { FileCards } from './file-download'
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { TraceView } from './trace-view'
|
||||
+1127
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
export type { LogDetailsTab } from './log-details'
|
||||
export { LogDetails, LogDetailsContent, WorkflowOutputSection } from './log-details'
|
||||
@@ -0,0 +1,816 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Chip,
|
||||
ChipInput,
|
||||
ChipModalTabs,
|
||||
Code,
|
||||
cn,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
Duplicate,
|
||||
Eye,
|
||||
handleKeyboardActivation,
|
||||
Redo,
|
||||
Search as SearchIcon,
|
||||
Tooltip,
|
||||
useCopyToClipboard,
|
||||
} from '@sim/emcn'
|
||||
import { Workflow, Wrench } from '@sim/emcn/icons'
|
||||
import { formatDuration } from '@sim/utils/formatting'
|
||||
import { ArrowDown, ArrowUp, Check, ChevronUp, Clipboard, Search, X } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type { WorkflowLogRow } from '@/lib/api/contracts/logs'
|
||||
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
|
||||
import { apportionCredits, dollarsToCredits } from '@/lib/billing/credits/conversion'
|
||||
import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage'
|
||||
import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans'
|
||||
import type { TraceSpan } from '@/lib/logs/types'
|
||||
import { sendMothershipMessage } from '@/lib/mothership/events'
|
||||
import {
|
||||
ExecutionSnapshot,
|
||||
FileCards,
|
||||
TraceView,
|
||||
} from '@/app/workspace/[workspaceId]/logs/components'
|
||||
import { useLogDetailsResize } from '@/app/workspace/[workspaceId]/logs/hooks'
|
||||
import {
|
||||
logDetailsTabParam,
|
||||
logDetailsTabUrlKeys,
|
||||
} from '@/app/workspace/[workspaceId]/logs/search-params'
|
||||
import {
|
||||
DELETED_WORKFLOW_LABEL,
|
||||
formatDate,
|
||||
getDisplayStatus,
|
||||
StatusBadge,
|
||||
TriggerBadge,
|
||||
} from '@/app/workspace/[workspaceId]/logs/utils'
|
||||
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { formatCost } from '@/providers/utils'
|
||||
import { useLogDetailsUIStore } from '@/stores/logs/store'
|
||||
import { MAX_LOG_DETAILS_WIDTH_RATIO, MIN_LOG_DETAILS_WIDTH } from '@/stores/logs/utils'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
|
||||
/**
|
||||
* Renders an already-apportioned integer credit value. `dollars` is only used
|
||||
* to distinguish a genuine zero ("0 credits") from a sub-credit charge that
|
||||
* rounded down to zero ("<1 credit"); the credit figure itself is authoritative.
|
||||
*/
|
||||
function creditLabel(credits: number, dollars: number): string {
|
||||
if (credits <= 0) return dollars > 0 ? '<1 credit' : '0 credits'
|
||||
return `${credits.toLocaleString()} ${credits === 1 ? 'credit' : 'credits'}`
|
||||
}
|
||||
|
||||
export const WorkflowOutputSection = memo(
|
||||
function WorkflowOutputSection({ output }: { output: Record<string, unknown> }) {
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const { copied, copy } = useCopyToClipboard({ resetMs: 1500 })
|
||||
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
|
||||
|
||||
const {
|
||||
isSearchActive,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
matchCount,
|
||||
currentMatchIndex,
|
||||
activateSearch,
|
||||
closeSearch,
|
||||
goToNextMatch,
|
||||
goToPreviousMatch,
|
||||
handleMatchCountChange,
|
||||
searchInputRef,
|
||||
} = useCodeViewerFeatures({ contentRef })
|
||||
|
||||
const jsonString = useMemo(() => JSON.stringify(output, null, 2), [output])
|
||||
|
||||
function handleContextMenu(e: React.MouseEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY })
|
||||
setIsContextMenuOpen(true)
|
||||
}
|
||||
|
||||
function handleCopy() {
|
||||
copy(jsonString)
|
||||
setIsContextMenuOpen(false)
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
activateSearch()
|
||||
setIsContextMenuOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative flex min-w-0 flex-col overflow-hidden'>
|
||||
<div ref={contentRef} onContextMenu={handleContextMenu} className='relative'>
|
||||
<Code.Viewer
|
||||
code={jsonString}
|
||||
language='json'
|
||||
className='!bg-[var(--surface-4)] dark:!bg-[var(--surface-3)] max-h-[300px] min-h-0 max-w-full rounded-md border-0 [word-break:break-all]'
|
||||
wrapText
|
||||
searchQuery={isSearchActive ? searchQuery : undefined}
|
||||
currentMatchIndex={currentMatchIndex}
|
||||
onMatchCountChange={handleMatchCountChange}
|
||||
/>
|
||||
{/* Glass action buttons overlay */}
|
||||
{!isSearchActive && (
|
||||
<div className='absolute top-[7px] right-[6px] z-10 flex gap-1'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='default'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCopy()
|
||||
}}
|
||||
className='size-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover-hover:bg-[var(--surface-3)]'
|
||||
>
|
||||
{copied ? (
|
||||
<Check className='size-[10px] text-[var(--text-success)]' />
|
||||
) : (
|
||||
<Clipboard className='size-[10px]' />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>{copied ? 'Copied' : 'Copy'}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='default'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
activateSearch()
|
||||
}}
|
||||
className='size-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover-hover:bg-[var(--surface-3)]'
|
||||
>
|
||||
<Search className='size-[10px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>Search</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search Overlay */}
|
||||
{isSearchActive && (
|
||||
<div
|
||||
role='presentation'
|
||||
className='absolute top-0 right-0 z-30 flex h-[34px] items-center gap-1.5 rounded-sm border border-[var(--border)] bg-[var(--surface-1)] px-1.5 shadow-sm'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ChipInput
|
||||
ref={searchInputRef}
|
||||
type='text'
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder='Search...'
|
||||
className='mr-0.5 w-[94px]'
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-[45px] text-center text-xs',
|
||||
matchCount > 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]'
|
||||
)}
|
||||
>
|
||||
{matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : '0/0'}
|
||||
</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='!p-1'
|
||||
onClick={goToPreviousMatch}
|
||||
disabled={matchCount === 0}
|
||||
aria-label='Previous match'
|
||||
>
|
||||
<ArrowUp className='size-[12px]' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='!p-1'
|
||||
onClick={goToNextMatch}
|
||||
disabled={matchCount === 0}
|
||||
aria-label='Next match'
|
||||
>
|
||||
<ArrowDown className='size-[12px]' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='!p-1'
|
||||
onClick={closeSearch}
|
||||
aria-label='Close search'
|
||||
>
|
||||
<X className='size-[12px]' />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Context Menu - rendered in portal to avoid transform/overflow clipping */}
|
||||
{typeof document !== 'undefined' &&
|
||||
createPortal(
|
||||
<DropdownMenu
|
||||
open={isContextMenuOpen}
|
||||
onOpenChange={() => setIsContextMenuOpen(false)}
|
||||
modal={false}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${contextMenuPosition.x}px`,
|
||||
top: `${contextMenuPosition.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
side='bottom'
|
||||
sideOffset={4}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DropdownMenuItem onSelect={handleCopy}>
|
||||
<Duplicate />
|
||||
Copy
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={handleSearch}>
|
||||
<SearchIcon />
|
||||
Search
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
(prev, next) => prev.output === next.output
|
||||
)
|
||||
|
||||
export type LogDetailsTab = 'overview' | 'trace'
|
||||
|
||||
interface LogDetailsContentProps {
|
||||
log: WorkflowLogRow
|
||||
onActiveTabChange?: (tab: LogDetailsTab) => void
|
||||
}
|
||||
|
||||
export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentProps) {
|
||||
const [isExecutionSnapshotOpen, setIsExecutionSnapshotOpen] = useState(false)
|
||||
const [activeTab, setActiveTab] = useQueryState(logDetailsTabParam.key, {
|
||||
...logDetailsTabParam.parser,
|
||||
...logDetailsTabUrlKeys,
|
||||
})
|
||||
const { copied: copiedRunId, copy: copyRunId } = useCopyToClipboard({ resetMs: 1500 })
|
||||
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const router = useRouter()
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>()
|
||||
|
||||
const { config: permissionConfig } = usePermissionConfig()
|
||||
|
||||
const isInitialTabMountRef = useRef(true)
|
||||
/**
|
||||
* Honors a deep-linked tab on first mount; resets to overview only when
|
||||
* switching to a different log.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isInitialTabMountRef.current) {
|
||||
isInitialTabMountRef.current = false
|
||||
} else {
|
||||
setActiveTab('overview')
|
||||
}
|
||||
if (scrollAreaRef.current) {
|
||||
scrollAreaRef.current.scrollTop = 0
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- stable nuqs setter; reset tab when switching logs
|
||||
}, [log.id])
|
||||
|
||||
const isLikelyExecution = !!log.executionId && log.trigger !== 'mothership'
|
||||
const isWorkflowExecutionLog =
|
||||
(log.trigger === 'manual' && !!log.duration) || !!log.executionData?.traceSpans
|
||||
|
||||
const hasCostInfo = !!(isWorkflowExecutionLog && log.cost)
|
||||
const showWorkflowState =
|
||||
isWorkflowExecutionLog &&
|
||||
!!log.executionId &&
|
||||
log.trigger !== 'mothership' &&
|
||||
!permissionConfig.hideTraceSpans
|
||||
|
||||
const showTraceTab = !permissionConfig.hideTraceSpans && isLikelyExecution
|
||||
// double-cast-allowed: contract schema makes duration/startTime optional for legacy persisted JSON; runtime data always supplies them.
|
||||
const traceSpans = log.executionData?.traceSpans as unknown as TraceSpan[] | undefined
|
||||
|
||||
const resolvedTab: LogDetailsTab = activeTab === 'trace' && !showTraceTab ? 'overview' : activeTab
|
||||
|
||||
useLayoutEffect(() => {
|
||||
onActiveTabChange?.(resolvedTab)
|
||||
}, [resolvedTab, onActiveTabChange])
|
||||
|
||||
const workflowOutput = useMemo(() => {
|
||||
const executionData = log.executionData as { finalOutput?: Record<string, unknown> } | undefined
|
||||
if (!executionData?.finalOutput) return null
|
||||
return filterHiddenOutputKeys(executionData.finalOutput) as Record<string, unknown>
|
||||
}, [log.executionData])
|
||||
|
||||
const workflowInput = useMemo(() => {
|
||||
const executionData = log.executionData as { workflowInput?: unknown } | undefined
|
||||
const raw = executionData?.workflowInput
|
||||
if (raw === undefined || raw === null) return null
|
||||
if (typeof raw === 'object' && !Array.isArray(raw)) {
|
||||
return raw as Record<string, unknown>
|
||||
}
|
||||
return { input: raw } as Record<string, unknown>
|
||||
}, [log.executionData])
|
||||
|
||||
// Cost breakdown, sourced solely from the usage_log ledger (single source of
|
||||
// truth). Line items (Base Run / per-model / per-integration) get integer
|
||||
// credits apportioned with a single round at the total so rows always
|
||||
// reconcile (never round-then-sum, which drifts). Pre-ledger runs that only
|
||||
// have the cost_total projection show the total alone — no itemization, no
|
||||
// parallel jsonb reconstruction.
|
||||
const costBreakdown = useMemo((): {
|
||||
rows: Array<{ key: string; label: string; credits: number; dollars: number }>
|
||||
totalCredits: number
|
||||
totalDollars: number
|
||||
tokens: { input: number; output: number }
|
||||
} | null => {
|
||||
const ledger = log.costLedger
|
||||
if (ledger && ledger.items.length > 0) {
|
||||
const credits = apportionCredits(
|
||||
ledger.items.map((item, i) => ({ key: String(i), dollars: item.cost }))
|
||||
)
|
||||
const rows = ledger.items.map((item, i) => ({
|
||||
key: String(i),
|
||||
label:
|
||||
item.category === 'fixed' && item.description === 'execution_fee'
|
||||
? 'Base Run'
|
||||
: item.description,
|
||||
credits: credits[String(i)] ?? 0,
|
||||
dollars: item.cost,
|
||||
}))
|
||||
return {
|
||||
rows,
|
||||
totalCredits: dollarsToCredits(ledger.total),
|
||||
totalDollars: ledger.total,
|
||||
tokens: {
|
||||
input: ledger.items.reduce((s, it) => s + (it.inputTokens ?? 0), 0),
|
||||
output: ledger.items.reduce((s, it) => s + (it.outputTokens ?? 0), 0),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Total-only (pre-ledger runs with just the cost_total projection).
|
||||
const total = log.cost?.total
|
||||
if (total == null) return null
|
||||
return {
|
||||
rows: [],
|
||||
totalCredits: dollarsToCredits(total),
|
||||
totalDollars: total,
|
||||
tokens: { input: 0, output: 0 },
|
||||
}
|
||||
}, [log.costLedger, log.cost])
|
||||
|
||||
const formattedTimestamp = formatDate(log.createdAt)
|
||||
const logStatus = getDisplayStatus(log.status)
|
||||
|
||||
/**
|
||||
* Troubleshooting hands the failed run off to Chat, tagging it by
|
||||
* `executionId`. A real Chat run can't be debugged from inside itself, so
|
||||
* mothership-triggered logs are excluded — `isLikelyExecution` already encodes
|
||||
* "has an executionId and isn't a mothership run".
|
||||
*/
|
||||
const canTroubleshoot = log.status === 'failed' && isLikelyExecution
|
||||
|
||||
/**
|
||||
* Hands the failed run to Chat. When a chat is already mounted (e.g. the run
|
||||
* is being viewed inside Chat's resource panel) it consumes the tagged
|
||||
* message directly; otherwise a one-shot handoff is persisted and we navigate
|
||||
* to a fresh chat that picks it up on mount. Navigation is gated on a
|
||||
* successful store, so a failed write never strands the user on an empty chat.
|
||||
*/
|
||||
const handleTroubleshoot = useCallback(() => {
|
||||
if (!log.executionId) return
|
||||
const workflowName = log.workflow?.name?.trim() || null
|
||||
const context: ChatContext = {
|
||||
kind: 'logs',
|
||||
executionId: log.executionId,
|
||||
label: workflowName ?? 'this run',
|
||||
}
|
||||
const message = workflowName
|
||||
? `The "${workflowName}" workflow run failed. Investigate the error in this run and help me fix it.`
|
||||
: 'This workflow run failed. Investigate the error in this run and help me fix it.'
|
||||
if (sendMothershipMessage(message, [context])) return
|
||||
if (MothershipHandoffStorage.store({ message, contexts: [context] }, workspaceId)) {
|
||||
router.push(`/workspace/${workspaceId}/home`)
|
||||
}
|
||||
}, [log.executionId, log.workflow?.name, workspaceId, router])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='mt-4 flex min-h-0 flex-1 flex-col'>
|
||||
<ChipModalTabs
|
||||
tabs={[
|
||||
{ value: 'overview', label: 'Overview' },
|
||||
...(showTraceTab ? [{ value: 'trace', label: 'Trace' }] : []),
|
||||
]}
|
||||
value={resolvedTab}
|
||||
onChange={(v) => setActiveTab(v as LogDetailsTab)}
|
||||
/>
|
||||
|
||||
{/* Overview Tab */}
|
||||
{resolvedTab === 'overview' && (
|
||||
<div ref={scrollAreaRef} className='mt-4 min-h-0 flex-1 overflow-y-auto'>
|
||||
<div className='flex flex-col gap-2.5 pb-4'>
|
||||
{/* Timestamp + Workflow header */}
|
||||
<div className='grid grid-cols-2 gap-x-3 pb-0.5'>
|
||||
<div className='flex min-w-0 flex-col gap-0.5'>
|
||||
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
|
||||
Timestamp
|
||||
</span>
|
||||
<span className='font-medium text-[var(--text-secondary)] text-sm tabular-nums'>
|
||||
{formattedTimestamp
|
||||
? `${formattedTimestamp.compactDate} ${formattedTimestamp.compactTime}`
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex min-w-0 flex-col gap-0.5'>
|
||||
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
|
||||
{log.trigger === 'mothership' ? 'Job' : 'Workflow'}
|
||||
</span>
|
||||
<div className='flex min-w-0 items-center gap-1.5'>
|
||||
<Workflow className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='min-w-0 truncate font-medium text-[var(--text-secondary)] text-sm'>
|
||||
{log.trigger === 'mothership'
|
||||
? log.jobTitle || 'Untitled Job'
|
||||
: log.workflow?.name ||
|
||||
(!log.workflowId ? DELETED_WORKFLOW_LABEL : 'Unknown')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details Section */}
|
||||
<div className='divide-y divide-[var(--border)] overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface-2)] dark:bg-transparent'>
|
||||
{/* Run ID — click to copy */}
|
||||
{log.executionId && (
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
aria-label='Copy run ID'
|
||||
className='flex h-10 min-w-0 cursor-pointer items-center justify-between gap-4 px-3 transition-colors hover-hover:bg-[var(--surface-active)]'
|
||||
onClick={() => copyRunId(log.executionId!)}
|
||||
onKeyDown={(event) =>
|
||||
handleKeyboardActivation(event, () => copyRunId(log.executionId!))
|
||||
}
|
||||
>
|
||||
<span className='flex-shrink-0 font-medium text-[var(--text-tertiary)] text-caption'>
|
||||
Run ID
|
||||
</span>
|
||||
<span className='min-w-0 truncate font-medium text-[var(--text-secondary)] text-caption tabular-nums'>
|
||||
{copiedRunId ? 'Copied!' : log.executionId}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Level */}
|
||||
<div className='flex h-10 items-center justify-between px-3'>
|
||||
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
|
||||
Level
|
||||
</span>
|
||||
<StatusBadge status={logStatus} />
|
||||
</div>
|
||||
|
||||
{/* Trigger */}
|
||||
<div className='flex h-10 items-center justify-between px-3'>
|
||||
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
|
||||
Trigger
|
||||
</span>
|
||||
{log.trigger ? (
|
||||
<TriggerBadge trigger={log.trigger} />
|
||||
) : (
|
||||
<span className='font-medium text-[var(--text-secondary)] text-caption'>
|
||||
None
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div className='flex h-10 items-center justify-between px-3'>
|
||||
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
|
||||
Duration
|
||||
</span>
|
||||
<span className='font-medium text-[var(--text-secondary)] text-caption tabular-nums'>
|
||||
{formatDuration(log.duration, { precision: 2 }) || '—'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Version */}
|
||||
{log.deploymentVersion && (
|
||||
<div className='flex h-10 items-center gap-2 px-3'>
|
||||
<span className='flex-shrink-0 font-medium text-[var(--text-tertiary)] text-caption'>
|
||||
Version
|
||||
</span>
|
||||
<div className='flex w-0 flex-1 justify-end'>
|
||||
<Badge variant='green' size='sm' className='max-w-full truncate'>
|
||||
{log.deploymentVersionName || `v${log.deploymentVersion}`}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Snapshot */}
|
||||
{showWorkflowState && (
|
||||
<div className='flex h-10 items-center justify-between px-3'>
|
||||
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
|
||||
Snapshot
|
||||
</span>
|
||||
<Chip leftIcon={Eye} flush onClick={() => setIsExecutionSnapshotOpen(true)}>
|
||||
View Snapshot
|
||||
</Chip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Troubleshoot */}
|
||||
{canTroubleshoot && (
|
||||
<div className='flex h-10 items-center justify-between px-3'>
|
||||
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
|
||||
Troubleshoot
|
||||
</span>
|
||||
<Chip leftIcon={Wrench} flush onClick={handleTroubleshoot}>
|
||||
Troubleshoot in Chat
|
||||
</Chip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Workflow Input */}
|
||||
{isWorkflowExecutionLog && workflowInput && !permissionConfig.hideTraceSpans && (
|
||||
<div className='flex flex-col gap-1.5 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2.5 py-2 dark:bg-transparent'>
|
||||
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
|
||||
Workflow Input
|
||||
</span>
|
||||
<WorkflowOutputSection output={workflowInput} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Workflow Output */}
|
||||
{isWorkflowExecutionLog && workflowOutput && !permissionConfig.hideTraceSpans && (
|
||||
<div className='flex flex-col gap-1.5 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2.5 py-2 dark:bg-transparent'>
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium text-caption',
|
||||
workflowOutput.error
|
||||
? 'text-[var(--text-error)]'
|
||||
: 'text-[var(--text-tertiary)]'
|
||||
)}
|
||||
>
|
||||
Workflow Output
|
||||
</span>
|
||||
<WorkflowOutputSection output={workflowOutput} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Files */}
|
||||
{log.files && log.files.length > 0 && <FileCards files={log.files} isExecutionFile />}
|
||||
|
||||
{/* Cost Breakdown */}
|
||||
{hasCostInfo && costBreakdown && (
|
||||
<div className='divide-y divide-[var(--border)] overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface-2)] dark:bg-transparent'>
|
||||
{costBreakdown.rows.map((row) => (
|
||||
<div key={row.key} className='flex h-10 items-center justify-between px-3'>
|
||||
<span className='min-w-0 truncate font-medium text-[var(--text-tertiary)] text-caption'>
|
||||
{row.label}
|
||||
</span>
|
||||
<span className='flex-shrink-0 font-medium text-[var(--text-secondary)] text-caption tabular-nums'>
|
||||
{creditLabel(row.credits, row.dollars)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className='flex h-10 items-center justify-between px-3'>
|
||||
<span className='font-medium text-[var(--text-secondary)] text-caption'>
|
||||
Total
|
||||
</span>
|
||||
<span className='font-semibold text-[var(--text-primary)] text-caption tabular-nums'>
|
||||
{creditLabel(costBreakdown.totalCredits, costBreakdown.totalDollars)}
|
||||
</span>
|
||||
</div>
|
||||
{(costBreakdown.tokens.input > 0 || costBreakdown.tokens.output > 0) && (
|
||||
<div className='flex h-10 items-center justify-between px-3'>
|
||||
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
|
||||
Tokens
|
||||
</span>
|
||||
<span className='font-medium text-[var(--text-secondary)] text-caption tabular-nums'>
|
||||
{costBreakdown.tokens.input} in · {costBreakdown.tokens.output} out
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className='px-3 py-2'>
|
||||
<p className='font-medium text-[var(--text-tertiary)] text-xs'>
|
||||
Total includes a {formatCost(BASE_EXECUTION_CHARGE)} base charge plus model
|
||||
and tool usage.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trace Tab */}
|
||||
{showTraceTab && resolvedTab === 'trace' && (
|
||||
<div className='mt-3 min-h-0 flex-1 overflow-hidden focus-visible:outline-none'>
|
||||
{traceSpans?.length ? (
|
||||
<TraceView traceSpans={traceSpans} runCostDollars={log.cost?.total} />
|
||||
) : log.executionData ? (
|
||||
<div className='flex h-full items-center justify-center px-4 text-center'>
|
||||
<span className='font-medium text-[var(--text-tertiary)] text-sm'>
|
||||
No trace data available for this run
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex h-full items-center justify-center px-4 text-center'>
|
||||
<span className='font-medium text-[var(--text-tertiary)] text-sm'>
|
||||
Loading trace…
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Frozen Canvas Modal */}
|
||||
{log.executionId && (
|
||||
<ExecutionSnapshot
|
||||
executionId={log.executionId}
|
||||
traceSpans={traceSpans}
|
||||
isModal
|
||||
isOpen={isExecutionSnapshotOpen}
|
||||
onClose={() => setIsExecutionSnapshotOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface LogDetailsProps {
|
||||
log: WorkflowLogRow | null
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onNavigateNext?: () => void
|
||||
onNavigatePrev?: () => void
|
||||
hasNext?: boolean
|
||||
hasPrev?: boolean
|
||||
onRetryExecution?: () => void
|
||||
isRetryPending?: boolean
|
||||
onActiveTabChange?: (tab: LogDetailsTab) => void
|
||||
}
|
||||
|
||||
export const LogDetails = memo(function LogDetails({
|
||||
log,
|
||||
isOpen,
|
||||
onClose,
|
||||
onNavigateNext,
|
||||
onNavigatePrev,
|
||||
hasNext = false,
|
||||
hasPrev = false,
|
||||
onRetryExecution,
|
||||
isRetryPending = false,
|
||||
onActiveTabChange,
|
||||
}: LogDetailsProps) {
|
||||
const activeTabRef = useRef<LogDetailsTab>('overview')
|
||||
|
||||
const handleActiveTabChange = useCallback(
|
||||
(tab: LogDetailsTab) => {
|
||||
activeTabRef.current = tab
|
||||
onActiveTabChange?.(tab)
|
||||
},
|
||||
[onActiveTabChange]
|
||||
)
|
||||
|
||||
const panelWidth = useLogDetailsUIStore((state) => state.panelWidth)
|
||||
const { handleMouseDown } = useLogDetailsResize()
|
||||
|
||||
const maxVw = `${MAX_LOG_DETAILS_WIDTH_RATIO * 100}vw`
|
||||
const effectiveWidth = `clamp(min(${MIN_LOG_DETAILS_WIDTH}px, ${maxVw}), ${panelWidth}px, ${maxVw})`
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose()
|
||||
}
|
||||
|
||||
if (!isOpen) return
|
||||
|
||||
// Trace tab owns arrow keys for span navigation.
|
||||
if (activeTabRef.current === 'trace') return
|
||||
|
||||
if (e.key === 'ArrowUp' && hasPrev && onNavigatePrev) {
|
||||
e.preventDefault()
|
||||
onNavigatePrev()
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown' && hasNext && onNavigateNext) {
|
||||
e.preventDefault()
|
||||
onNavigateNext()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isOpen, onClose, hasPrev, hasNext, onNavigatePrev, onNavigateNext])
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Resize Handle - positioned outside the panel */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className='absolute top-0 bottom-0 z-[var(--z-dropdown)] w-[8px] cursor-ew-resize'
|
||||
style={{ right: `calc(${effectiveWidth} - 4px)` }}
|
||||
onMouseDown={handleMouseDown}
|
||||
role='separator'
|
||||
aria-label='Resize log details panel'
|
||||
aria-orientation='vertical'
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-0 right-0 bottom-0 z-[var(--z-dropdown)] overflow-hidden border-l bg-[var(--bg)] shadow-md transition-transform duration-200 ease-out',
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
)}
|
||||
style={{ width: effectiveWidth }}
|
||||
aria-label='Log details sidebar'
|
||||
>
|
||||
{log && (
|
||||
<div className='flex h-full flex-col px-3.5 pt-3'>
|
||||
{/* Header */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className='font-medium text-[var(--text-primary)] text-sm'>Log Details</h2>
|
||||
<div className='flex items-center gap-[1px]'>
|
||||
{log.status === 'failed' &&
|
||||
(log.workflow?.id || log.workflowId) &&
|
||||
log.trigger !== 'mothership' && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='!p-1'
|
||||
onClick={() => onRetryExecution?.()}
|
||||
disabled={isRetryPending}
|
||||
aria-label='Retry execution'
|
||||
>
|
||||
<Redo className='size-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='bottom'>Retry</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='!p-1'
|
||||
onClick={() => hasPrev && onNavigatePrev?.()}
|
||||
disabled={!hasPrev}
|
||||
aria-label='Previous log'
|
||||
>
|
||||
<ChevronUp className='size-[14px]' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='!p-1'
|
||||
onClick={() => hasNext && onNavigateNext?.()}
|
||||
disabled={!hasNext}
|
||||
aria-label='Next log'
|
||||
>
|
||||
<ChevronUp className='size-[14px] rotate-180' />
|
||||
</Button>
|
||||
<Button variant='ghost' className='!p-1' onClick={onClose} aria-label='Close'>
|
||||
<X className='size-[14px]' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LogDetailsContent log={log} onActiveTabChange={handleActiveTabChange} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
import type React from 'react'
|
||||
import { AgentSkillsIcon, WorkflowIcon } from '@/components/icons'
|
||||
import { formatCreditCost } from '@/lib/billing/credits/conversion'
|
||||
import { perceivedBrightness } from '@/lib/colors'
|
||||
import type { TraceSpan } from '@/lib/logs/types'
|
||||
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
|
||||
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
|
||||
import { getBlock, getBlockByToolName } from '@/blocks'
|
||||
import { PROVIDER_DEFINITIONS } from '@/providers/models'
|
||||
import { normalizeToolId } from '@/tools/normalize'
|
||||
|
||||
/**
|
||||
* Extracts the bare tool name from an MCP tool id of the form
|
||||
* `mcp-{serverId}-{toolName}`. Returns null when the id is not MCP-shaped.
|
||||
* Kept local to avoid importing from `@/lib/mcp/utils`, which pulls in
|
||||
* `next/server` and breaks client bundles.
|
||||
*/
|
||||
function tryParseMcpToolName(toolId: string): string | null {
|
||||
if (!toolId.startsWith('mcp-')) return null
|
||||
const parts = toolId.split('-')
|
||||
if (parts.length < 3) return null
|
||||
const toolName = parts.slice(2).join('-')
|
||||
return toolName.length > 0 ? toolName : null
|
||||
}
|
||||
|
||||
export const DEFAULT_BLOCK_COLOR = '#6b7280'
|
||||
|
||||
export interface BlockIconAndColor {
|
||||
icon: React.ComponentType<{ className?: string }> | null
|
||||
bgColor: string
|
||||
}
|
||||
|
||||
export function isIterationType(type: string): boolean {
|
||||
const lower = type?.toLowerCase() || ''
|
||||
return lower === 'loop-iteration' || lower === 'parallel-iteration'
|
||||
}
|
||||
|
||||
export function hasErrorInTree(span: TraceSpan): boolean {
|
||||
if (span.status === 'error') return true
|
||||
if (span.children?.length) return span.children.some(hasErrorInTree)
|
||||
if (span.toolCalls?.length) return span.toolCalls.some((tc) => tc.error)
|
||||
return false
|
||||
}
|
||||
|
||||
export function hasUnhandledErrorInTree(span: TraceSpan): boolean {
|
||||
if (span.status === 'error' && !span.errorHandled) return true
|
||||
if (span.children?.length) return span.children.some(hasUnhandledErrorInTree)
|
||||
if (span.toolCalls?.length && !span.errorHandled) return span.toolCalls.some((tc) => tc.error)
|
||||
return false
|
||||
}
|
||||
|
||||
export function getBlockIconAndColor(
|
||||
type: string,
|
||||
toolName?: string,
|
||||
provider?: string
|
||||
): BlockIconAndColor {
|
||||
const lowerType = type.toLowerCase()
|
||||
if (lowerType === 'tool' && toolName) {
|
||||
if (tryParseMcpToolName(toolName)) {
|
||||
const mcpBlock = getBlock('mcp')
|
||||
if (mcpBlock) return { icon: mcpBlock.icon, bgColor: mcpBlock.bgColor }
|
||||
}
|
||||
const normalized = normalizeToolId(toolName)
|
||||
if (normalized === 'load_skill' || normalized === 'load_user_skill')
|
||||
return { icon: AgentSkillsIcon, bgColor: '#8B5CF6' }
|
||||
const toolBlock = getBlockByToolName(normalized)
|
||||
if (toolBlock) return { icon: toolBlock.icon, bgColor: toolBlock.bgColor }
|
||||
}
|
||||
if (lowerType === 'loop' || lowerType === 'loop-iteration')
|
||||
return { icon: LoopTool.icon, bgColor: LoopTool.bgColor }
|
||||
if (lowerType === 'parallel' || lowerType === 'parallel-iteration')
|
||||
return { icon: ParallelTool.icon, bgColor: ParallelTool.bgColor }
|
||||
if (lowerType === 'workflow') return { icon: WorkflowIcon, bgColor: '#6366F1' }
|
||||
if (lowerType === 'model' && provider) {
|
||||
const providerDef = PROVIDER_DEFINITIONS[provider]
|
||||
if (providerDef?.icon)
|
||||
return { icon: providerDef.icon, bgColor: providerDef.color ?? DEFAULT_BLOCK_COLOR }
|
||||
}
|
||||
const blockType = lowerType === 'model' ? 'agent' : lowerType
|
||||
const blockConfig = getBlock(blockType)
|
||||
if (blockConfig) return { icon: blockConfig.icon, bgColor: blockConfig.bgColor }
|
||||
return { icon: null, bgColor: DEFAULT_BLOCK_COLOR }
|
||||
}
|
||||
|
||||
/**
|
||||
* Max YIQ weighted sum (255 × (0.299 + 0.587 + 0.114) × 1000). `perceivedBrightness`
|
||||
* is that sum normalized to 0–1, so the original integer cutoffs map exactly to
|
||||
* `cutoff / MAX_YIQ_SUM` here.
|
||||
*/
|
||||
const MAX_YIQ_SUM = 255_000
|
||||
|
||||
/** Returns 'text-white' for dark backgrounds, dark text for light ones. */
|
||||
export function iconColorClass(bgColor: string): string {
|
||||
const brightness = perceivedBrightness(bgColor)
|
||||
return brightness !== null && brightness > 160_000 / MAX_YIQ_SUM ? 'text-[#111111]' : 'text-white'
|
||||
}
|
||||
|
||||
/**
|
||||
* Near-black bgColors disappear against the dark-mode surface (--bg: #1b1b1b).
|
||||
* Below the brightness threshold we fall back to the neutral block color used
|
||||
* for blocks with no distinct identity; everything brighter passes through.
|
||||
*/
|
||||
export function adjustBgForContrast(bgColor: string): string {
|
||||
const brightness = perceivedBrightness(bgColor)
|
||||
return brightness !== null && brightness < 30_000 / MAX_YIQ_SUM ? DEFAULT_BLOCK_COLOR : bgColor
|
||||
}
|
||||
|
||||
export function parseTime(value?: string | number | null): number {
|
||||
if (!value) return 0
|
||||
const ms = typeof value === 'number' ? value : new Date(value).getTime()
|
||||
return Number.isFinite(ms) ? ms : 0
|
||||
}
|
||||
|
||||
export function formatTokenCount(value: number | undefined): string | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
|
||||
return value.toLocaleString('en-US')
|
||||
}
|
||||
|
||||
export function formatTtft(ms: number | undefined): string | undefined {
|
||||
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return undefined
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
export function formatTps(
|
||||
outputTokens: number | undefined,
|
||||
durationMs: number
|
||||
): string | undefined {
|
||||
if (typeof outputTokens !== 'number' || !(outputTokens > 0)) return undefined
|
||||
if (!(durationMs > 0)) return undefined
|
||||
const tps = Math.round(outputTokens / (durationMs / 1000))
|
||||
return tps > 0 ? `${tps.toLocaleString('en-US')} tok/s` : undefined
|
||||
}
|
||||
|
||||
export function getDisplayName(span: TraceSpan): string {
|
||||
if (span.type?.toLowerCase() === 'tool') {
|
||||
const mcpToolName = tryParseMcpToolName(span.name)
|
||||
if (mcpToolName) return mcpToolName
|
||||
return normalizeToolId(span.name)
|
||||
}
|
||||
return span.name
|
||||
}
|
||||
|
||||
export function formatCostAmount(value: number | undefined): string | undefined {
|
||||
return formatCreditCost(value, { emptyForZeroOrLess: true })
|
||||
}
|
||||
|
||||
export function formatTokensSummary(tokens: TraceSpan['tokens']): string | undefined {
|
||||
if (!tokens) return undefined
|
||||
const parts: string[] = []
|
||||
const input = formatTokenCount(tokens.input)
|
||||
const output = formatTokenCount(tokens.output)
|
||||
const total = formatTokenCount(tokens.total)
|
||||
const cacheRead = formatTokenCount(tokens.cacheRead)
|
||||
const cacheWrite = formatTokenCount(tokens.cacheWrite)
|
||||
const reasoning = formatTokenCount(tokens.reasoning)
|
||||
if (input) parts.push(`${input} in`)
|
||||
if (cacheRead) parts.push(`${cacheRead} cached`)
|
||||
if (cacheWrite) parts.push(`${cacheWrite} cache write`)
|
||||
if (output) parts.push(`${output} out`)
|
||||
if (reasoning) parts.push(`${reasoning} reasoning`)
|
||||
if (total) parts.push(`${total} total`)
|
||||
return parts.length > 0 ? parts.join(' · ') : undefined
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { LogRowContextMenu } from './log-row-context-menu'
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
Duplicate,
|
||||
Eye,
|
||||
Link,
|
||||
ListFilter,
|
||||
Redo,
|
||||
SquareArrowUpRight,
|
||||
X,
|
||||
} from '@sim/emcn'
|
||||
import type { WorkflowLogSummary } from '@/lib/api/contracts/logs'
|
||||
|
||||
interface LogRowContextMenuProps {
|
||||
isOpen: boolean
|
||||
position: { x: number; y: number }
|
||||
onClose: () => void
|
||||
log: WorkflowLogSummary | null
|
||||
onCopyExecutionId: () => void
|
||||
onCopyLink: () => void
|
||||
onOpenWorkflow: () => void
|
||||
onOpenPreview: () => void
|
||||
onToggleWorkflowFilter: () => void
|
||||
onClearAllFilters: () => void
|
||||
onCancelExecution: () => void
|
||||
onRetryExecution: () => void
|
||||
isRetryPending?: boolean
|
||||
isFilteredByThisWorkflow: boolean
|
||||
hasActiveFilters: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Context menu for log rows.
|
||||
* Provides quick actions for copying data, navigation, and filtering.
|
||||
*/
|
||||
export const LogRowContextMenu = memo(function LogRowContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
onClose,
|
||||
log,
|
||||
onCopyExecutionId,
|
||||
onCopyLink,
|
||||
onOpenWorkflow,
|
||||
onOpenPreview,
|
||||
onToggleWorkflowFilter,
|
||||
onClearAllFilters,
|
||||
onCancelExecution,
|
||||
onRetryExecution,
|
||||
isRetryPending = false,
|
||||
isFilteredByThisWorkflow,
|
||||
hasActiveFilters,
|
||||
}: LogRowContextMenuProps) {
|
||||
const hasExecutionId = Boolean(log?.executionId)
|
||||
const hasWorkflow = Boolean(log?.workflow?.id || log?.workflowId)
|
||||
const isCancellable =
|
||||
(log?.status === 'running' || log?.status === 'pending') && hasExecutionId && hasWorkflow
|
||||
const isRetryable = log?.status === 'failed' && hasWorkflow && log?.trigger !== 'mothership'
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
side='bottom'
|
||||
sideOffset={4}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{isRetryable && (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={onRetryExecution} disabled={isRetryPending}>
|
||||
<Redo />
|
||||
{isRetryPending ? 'Retrying...' : 'Retry'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{isCancellable && (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={onCancelExecution}>
|
||||
<X />
|
||||
Cancel Run
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem disabled={!hasExecutionId} onSelect={onCopyExecutionId}>
|
||||
<Duplicate />
|
||||
Copy Run ID
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!hasExecutionId} onSelect={onCopyLink}>
|
||||
<Link />
|
||||
Copy Link
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled={!hasWorkflow} onSelect={onOpenWorkflow}>
|
||||
<SquareArrowUpRight />
|
||||
Open Workflow
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!hasExecutionId} onSelect={onOpenPreview}>
|
||||
<Eye />
|
||||
Open Snapshot
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
{!isFilteredByThisWorkflow && (
|
||||
<DropdownMenuItem disabled={!hasWorkflow} onSelect={onToggleWorkflowFilter}>
|
||||
<ListFilter />
|
||||
Filter by Workflow
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasActiveFilters && (
|
||||
<DropdownMenuItem onSelect={onClearAllFilters}>
|
||||
<X />
|
||||
Clear Filters
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user