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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -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'
@@ -0,0 +1 @@
export { LineChart, type LineChartMultiSeries, type LineChartPoint } from './line-chart'
@@ -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)
@@ -0,0 +1,2 @@
export type { StatusBarSegment } from './status-bar'
export { StatusBar } from './status-bar'
@@ -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}
/>
)
}
@@ -0,0 +1,2 @@
export type { WorkflowExecutionItem } from './workflows-list'
export { WorkflowsList } from './workflows-list'
@@ -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)
@@ -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'
@@ -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}
</>
)
}
@@ -0,0 +1 @@
export { ExecutionSnapshot } from './execution-snapshot'
@@ -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>
)
}
@@ -0,0 +1 @@
export { FileCards } from './file-download'
@@ -0,0 +1 @@
export { TraceView } from './trace-view'
@@ -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 01, 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'
@@ -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>
)
})