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>
)
})
@@ -0,0 +1,75 @@
/**
* @vitest-environment jsdom
*/
import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/emcn', () => ({
Button: ({ children, ...props }: { children: ReactNode } & Record<string, unknown>) => (
<button {...props}>{children}</button>
),
}))
vi.mock('@/app/workspace/[workspaceId]/components', async () => {
const errorModule = await import('@/app/workspace/[workspaceId]/components/error')
return errorModule
})
import LogsError from './error'
let container: HTMLDivElement
let root: Root
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
act(() => {
root = createRoot(container)
})
})
afterEach(() => {
act(() => {
root.unmount()
})
container.remove()
})
function findButtonByText(text: string): HTMLButtonElement {
const button = Array.from(container.querySelectorAll('button')).find(
(el) => el.textContent?.trim() === text
)
if (!button) throw new Error(`Button with text "${text}" not found`)
return button as HTMLButtonElement
}
describe('LogsError boundary', () => {
it('renders the title and description from the shared ErrorState', () => {
const error = Object.assign(new Error('boom'), { digest: 'abc123' })
act(() => {
root.render(<LogsError error={error} reset={vi.fn()} />)
})
expect(container.textContent).toContain('Failed to load logs')
expect(container.textContent).toContain(
'Something went wrong while loading the logs. Please try again.'
)
})
it('calls reset when the refresh action is clicked', () => {
const reset = vi.fn()
const error = Object.assign(new Error('boom'), { digest: 'abc123' })
act(() => {
root.render(<LogsError error={error} reset={reset} />)
})
act(() => {
findButtonByText('Refresh').click()
})
expect(reset).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,15 @@
'use client'
import { type ErrorBoundaryProps, ErrorState } from '@/app/workspace/[workspaceId]/components'
export default function LogsError({ error, reset }: ErrorBoundaryProps) {
return (
<ErrorState
error={error}
reset={reset}
title='Failed to load logs'
description='Something went wrong while loading the logs. Please try again.'
loggerName='LogsError'
/>
)
}
@@ -0,0 +1 @@
export { useLogDetailsResize } from './use-log-details-resize'
@@ -0,0 +1,52 @@
import { useCallback, useEffect, useState } from 'react'
import { useLogDetailsUIStore } from '@/stores/logs/store'
/**
* Hook for handling log details panel resize via mouse drag.
* @returns Resize state and mouse event handler.
*/
export function useLogDetailsResize() {
const setPanelWidth = useLogDetailsUIStore((state) => state.setPanelWidth)
const setIsResizing = useLogDetailsUIStore((state) => state.setIsResizing)
const [isResizing, setLocalIsResizing] = useState(false)
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault()
setLocalIsResizing(true)
setIsResizing(true)
},
[setIsResizing]
)
useEffect(() => {
if (!isResizing) return
const handleMouseMove = (e: MouseEvent) => {
const newWidth = window.innerWidth - e.clientX
setPanelWidth(newWidth)
}
const handleMouseUp = () => {
setLocalIsResizing(false)
setIsResizing(false)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
document.body.style.cursor = 'ew-resize'
document.body.style.userSelect = 'none'
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
}, [isResizing, setPanelWidth, setIsResizing])
return {
isResizing,
handleMouseDown,
}
}
@@ -0,0 +1,206 @@
'use client'
import { useCallback, useMemo } from 'react'
import { debounce, useQueryStates } from 'nuqs'
import {
logFilterParsers,
logFilterUrlKeys,
} from '@/app/workspace/[workspaceId]/logs/search-params'
import type { LogLevel, TimeRange, TriggerType } from '@/stores/logs/filters/types'
const DEFAULT_TIME_RANGE: TimeRange = 'All time'
/** Debounce window for `search` URL writes; keystrokes stay instant in the input. */
const SEARCH_DEBOUNCE_MS = 300 as const
/**
* The logs filter state, sourced entirely from typed URL query params via nuqs.
*
* This replaces the former hand-rolled `useFilterStore` URL sync (`syncWithURL`
* / `initializeFromURL` / `popstate`). The URL is now the single source of
* truth — initialization, serialization, and back/forward navigation are all
* handled by nuqs. The action surface mirrors the previous store so consumers
* migrate with minimal churn.
*/
export interface UseLogFilters {
timeRange: TimeRange
startDate: string | undefined
endDate: string | undefined
level: LogLevel
workflowIds: string[]
folderIds: string[]
triggers: TriggerType[]
searchQuery: string
setTimeRange: (timeRange: TimeRange) => void
setDateRange: (startDate: string | undefined, endDate: string | undefined) => void
clearDateRange: () => void
setLevel: (level: LogLevel) => void
setWorkflowIds: (workflowIds: string[]) => void
toggleWorkflowId: (workflowId: string) => void
setFolderIds: (folderIds: string[]) => void
toggleFolderId: (folderId: string) => void
setSearchQuery: (query: string) => void
setTriggers: (triggers: TriggerType[]) => void
toggleTrigger: (trigger: TriggerType) => void
resetFilters: () => void
}
/**
* Hook exposing the logs filter state and actions backed by URL query params.
* `startDate`/`endDate` are only retained while the time range is "Custom range"
* to match the prior store semantics.
*/
export function useLogFilters(): UseLogFilters {
const [filters, setFilters] = useQueryStates(logFilterParsers, logFilterUrlKeys)
const setTimeRange = useCallback(
(timeRange: TimeRange) => {
if (timeRange === 'Custom range') {
setFilters({ timeRange })
} else {
setFilters({ timeRange, startDate: null, endDate: null })
}
},
[setFilters]
)
const setDateRange = useCallback(
(startDate: string | undefined, endDate: string | undefined) => {
setFilters({
timeRange: 'Custom range',
startDate: startDate ?? null,
endDate: endDate ?? null,
})
},
[setFilters]
)
const clearDateRange = useCallback(() => {
setFilters({ timeRange: DEFAULT_TIME_RANGE, startDate: null, endDate: null })
}, [setFilters])
const setLevel = useCallback((level: LogLevel) => setFilters({ level }), [setFilters])
const setWorkflowIds = useCallback(
(workflowIds: string[]) => setFilters({ workflowIds }),
[setFilters]
)
const toggleWorkflowId = useCallback(
(workflowId: string) => {
setFilters((prev) => {
const current = prev.workflowIds
const next = current.includes(workflowId)
? current.filter((id) => id !== workflowId)
: [...current, workflowId]
return { workflowIds: next }
})
},
[setFilters]
)
const setFolderIds = useCallback((folderIds: string[]) => setFilters({ folderIds }), [setFilters])
const toggleFolderId = useCallback(
(folderId: string) => {
setFilters((prev) => {
const current = prev.folderIds
const next = current.includes(folderId)
? current.filter((id) => id !== folderId)
: [...current, folderId]
return { folderIds: next }
})
},
[setFilters]
)
/**
* Debounces only the search param's URL write; the returned `filters.search`
* value still updates instantly so the controlled input stays responsive.
* Clearing flushes immediately so the param drops out without lingering.
*/
const setSearchQuery = useCallback(
(query: string) => {
const trimmed = query.trim()
const next = trimmed.length > 0 ? trimmed : null
setFilters(
{ search: next },
next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) }
)
},
[setFilters]
)
const setTriggers = useCallback(
(triggers: TriggerType[]) => setFilters({ triggers }),
[setFilters]
)
const toggleTrigger = useCallback(
(trigger: TriggerType) => {
setFilters((prev) => {
const current = prev.triggers
const next = current.includes(trigger)
? current.filter((t) => t !== trigger)
: [...current, trigger]
return { triggers: next }
})
},
[setFilters]
)
const resetFilters = useCallback(() => {
setFilters({
timeRange: DEFAULT_TIME_RANGE,
startDate: null,
endDate: null,
level: 'all',
workflowIds: [],
folderIds: [],
triggers: [],
search: null,
})
}, [setFilters])
return useMemo(
() => ({
timeRange: filters.timeRange,
startDate:
filters.timeRange === 'Custom range' ? (filters.startDate ?? undefined) : undefined,
endDate: filters.timeRange === 'Custom range' ? (filters.endDate ?? undefined) : undefined,
level: filters.level,
workflowIds: filters.workflowIds,
folderIds: filters.folderIds,
triggers: filters.triggers,
searchQuery: filters.search,
setTimeRange,
setDateRange,
clearDateRange,
setLevel,
setWorkflowIds,
toggleWorkflowId,
setFolderIds,
toggleFolderId,
setSearchQuery,
setTriggers,
toggleTrigger,
resetFilters,
}),
[
filters,
setTimeRange,
setDateRange,
clearDateRange,
setLevel,
setWorkflowIds,
toggleWorkflowId,
setFolderIds,
toggleFolderId,
setSearchQuery,
setTriggers,
toggleTrigger,
resetFilters,
]
)
}
@@ -0,0 +1,266 @@
import { useCallback, useRef, useState } from 'react'
import type { ParsedFilter } from '@/lib/logs/query-parser'
import { parseQuery } from '@/lib/logs/query-parser'
import type {
Suggestion,
SuggestionGroup,
SuggestionSection,
} from '@/app/workspace/[workspaceId]/logs/types'
interface UseSearchStateOptions {
onFiltersChange: (filters: ParsedFilter[], textSearch: string) => void
getSuggestions: (input: string) => SuggestionGroup | null
debounceMs?: number
initialQuery?: string
}
export function useSearchState({
onFiltersChange,
getSuggestions,
debounceMs = 100,
initialQuery,
}: UseSearchStateOptions) {
const [initialParsed] = useState(() =>
initialQuery ? parseQuery(initialQuery) : { filters: [] as ParsedFilter[], textSearch: '' }
)
const [appliedFilters, setAppliedFilters] = useState<ParsedFilter[]>(initialParsed.filters)
const [currentInput, setCurrentInput] = useState('')
const [textSearch, setTextSearch] = useState<string>(initialParsed.textSearch)
const [isOpen, setIsOpen] = useState(false)
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
const [sections, setSections] = useState<SuggestionSection[]>([])
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const [highlightedBadgeIndex, setHighlightedBadgeIndex] = useState<number | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const debounceRef = useRef<NodeJS.Timeout | null>(null)
const updateSuggestions = useCallback(
(input: string) => {
const suggestionGroup = getSuggestions(input)
if (suggestionGroup && suggestionGroup.suggestions.length > 0) {
setSuggestions(suggestionGroup.suggestions)
setSections(suggestionGroup.sections || [])
setIsOpen(true)
setHighlightedIndex(0)
} else {
setIsOpen(false)
setSuggestions([])
setSections([])
setHighlightedIndex(-1)
}
},
[getSuggestions]
)
const handleInputChange = useCallback(
(value: string) => {
setCurrentInput(value)
setHighlightedBadgeIndex(null)
if (debounceRef.current) {
clearTimeout(debounceRef.current)
}
debounceRef.current = setTimeout(() => {
updateSuggestions(value)
}, debounceMs)
},
[updateSuggestions, debounceMs]
)
const handleSuggestionSelect = useCallback(
(suggestion: Suggestion) => {
if (suggestion.category === 'show-all') {
setTextSearch(suggestion.value)
setCurrentInput('')
setIsOpen(false)
onFiltersChange(appliedFilters, suggestion.value)
return
}
if (suggestion.category === 'filters' && suggestion.value.endsWith(':')) {
setCurrentInput(suggestion.value)
updateSuggestions(suggestion.value)
return
}
const newFilter: ParsedFilter = {
field: suggestion.value.split(':')[0],
operator: '=',
value: suggestion.value.includes(':')
? suggestion.value.split(':').slice(1).join(':').replace(/"/g, '')
: suggestion.value.replace(/"/g, ''),
originalValue: suggestion.value.includes(':')
? suggestion.value.split(':').slice(1).join(':')
: suggestion.value,
}
const updatedFilters = [...appliedFilters, newFilter]
setAppliedFilters(updatedFilters)
setCurrentInput('')
setTextSearch('')
onFiltersChange(updatedFilters, '')
if (inputRef.current) {
inputRef.current.focus()
}
setTimeout(() => {
updateSuggestions('')
}, 50)
},
[appliedFilters, onFiltersChange, updateSuggestions]
)
const removeBadge = useCallback(
(index: number) => {
const updatedFilters = appliedFilters.filter((_, i) => i !== index)
setAppliedFilters(updatedFilters)
onFiltersChange(updatedFilters, textSearch)
if (inputRef.current) {
inputRef.current.focus()
}
},
[appliedFilters, textSearch, onFiltersChange]
)
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === 'Backspace' && currentInput === '') {
event.preventDefault()
if (highlightedBadgeIndex !== null) {
removeBadge(highlightedBadgeIndex)
setHighlightedBadgeIndex(null)
} else if (appliedFilters.length > 0) {
setHighlightedBadgeIndex(appliedFilters.length - 1)
}
return
}
if (
highlightedBadgeIndex !== null &&
!['ArrowDown', 'ArrowUp', 'Enter'].includes(event.key)
) {
setHighlightedBadgeIndex(null)
}
if (event.key === 'Enter') {
event.preventDefault()
if (isOpen && highlightedIndex >= 0 && suggestions[highlightedIndex]) {
handleSuggestionSelect(suggestions[highlightedIndex])
} else if (currentInput.trim()) {
setTextSearch(currentInput.trim())
setCurrentInput('')
setIsOpen(false)
onFiltersChange(appliedFilters, currentInput.trim())
}
return
}
if (!isOpen) return
switch (event.key) {
case 'ArrowDown': {
event.preventDefault()
setHighlightedIndex((prev) => Math.min(prev + 1, suggestions.length - 1))
break
}
case 'ArrowUp': {
event.preventDefault()
setHighlightedIndex((prev) => Math.max(prev - 1, 0))
break
}
case 'Escape': {
event.preventDefault()
setIsOpen(false)
setHighlightedIndex(-1)
break
}
case 'Tab': {
if (highlightedIndex >= 0 && suggestions[highlightedIndex]) {
event.preventDefault()
handleSuggestionSelect(suggestions[highlightedIndex])
}
break
}
}
},
[
currentInput,
appliedFilters,
highlightedBadgeIndex,
isOpen,
highlightedIndex,
suggestions,
handleSuggestionSelect,
removeBadge,
onFiltersChange,
]
)
const handleFocus = useCallback(() => {
updateSuggestions(currentInput)
}, [currentInput, updateSuggestions])
const handleBlur = useCallback(() => {
setTimeout(() => {
setIsOpen(false)
setHighlightedIndex(-1)
}, 150)
}, [])
const clearAll = useCallback(() => {
setAppliedFilters([])
setCurrentInput('')
setTextSearch('')
setIsOpen(false)
onFiltersChange([], '')
if (inputRef.current) {
inputRef.current.focus()
}
}, [onFiltersChange])
const initializeFromQuery = useCallback((query: string, filters: ParsedFilter[]) => {
setAppliedFilters(filters)
setTextSearch(query)
setCurrentInput('')
}, [])
return {
appliedFilters,
currentInput,
textSearch,
isOpen,
suggestions,
sections,
highlightedIndex,
highlightedBadgeIndex,
inputRef,
dropdownRef,
handleInputChange,
handleSuggestionSelect,
handleKeyDown,
handleFocus,
handleBlur,
removeBadge,
clearAll,
initializeFromQuery,
setHighlightedIndex,
setHighlightedBadgeIndex,
}
}
@@ -0,0 +1,38 @@
'use client'
import { Library, RefreshCw } from '@sim/emcn'
import { Download } from '@sim/emcn/icons'
import {
type ChromeActionSpec,
ResourceChromeFallback,
} from '@/app/workspace/[workspaceId]/components'
const COLUMNS = [
{ id: 'workflow', header: 'Workflow' },
{ id: 'date', header: 'Date' },
{ id: 'status', header: 'Status' },
{ id: 'cost', header: 'Cost' },
{ id: 'trigger', header: 'Trigger' },
{ id: 'duration', header: 'Duration' },
]
const ACTIONS: ChromeActionSpec[] = [
{ text: 'Export', icon: Download },
{ text: 'Refresh', icon: RefreshCw },
{ text: 'Logs', active: true },
{ text: 'Dashboard' },
]
export default function LogsLoading() {
return (
<ResourceChromeFallback
icon={Library}
title='Logs'
columns={COLUMNS}
actions={ACTIONS}
searchPlaceholder='Search logs...'
hasSort
hasFilter
/>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import LogsLoading from '@/app/workspace/[workspaceId]/logs/loading'
import Logs from '@/app/workspace/[workspaceId]/logs/logs'
export const metadata: Metadata = {
title: 'Logs',
}
/**
* Logs page entry. `Logs` reads URL query params via nuqs (which uses
* `useSearchParams` internally), so it must sit under a Suspense boundary. The
* fallback renders the real chrome so a suspend never shows a blank frame; the
* route-level `loading.tsx` covers the navigation/chunk-load transition.
*/
export default function LogsPage() {
return (
<Suspense fallback={<LogsLoading />}>
<Logs />
</Suspense>
)
}
@@ -0,0 +1,145 @@
import { createParser, parseAsArrayOf, parseAsString, parseAsStringLiteral } from 'nuqs/server'
import {
CORE_TRIGGER_TYPES,
type LogLevel,
type TimeRange,
type TriggerType,
} from '@/stores/logs/filters/types'
/**
* Co-located, typed URL query-param definitions for the logs feature. The
* client hook (`useLogFilters`) consumes this typed param definition as the
* single source of truth.
*
* The encoding here intentionally preserves the exact wire format the logs page
* shipped before nuqs: `timeRange` uses kebab tokens, `level` / `workflowIds` /
* `folderIds` / `triggers` are comma-joined, and `search` is trimmed.
*/
const DEFAULT_TIME_RANGE: TimeRange = 'All time'
/** Maps a {@link TimeRange} label to its stable URL token and back. */
const TIME_RANGE_TO_TOKEN: Record<TimeRange, string> = {
'All time': 'all-time',
'Past 30 minutes': 'past-30-minutes',
'Past hour': 'past-hour',
'Past 6 hours': 'past-6-hours',
'Past 12 hours': 'past-12-hours',
'Past 24 hours': 'past-24-hours',
'Past 3 days': 'past-3-days',
'Past 7 days': 'past-7-days',
'Past 14 days': 'past-14-days',
'Past 30 days': 'past-30-days',
'Custom range': 'custom',
}
const TOKEN_TO_TIME_RANGE: Record<string, TimeRange> = Object.fromEntries(
Object.entries(TIME_RANGE_TO_TOKEN).map(([label, token]) => [token, label as TimeRange])
) as Record<string, TimeRange>
/**
* Parser for the `timeRange` param. Serializes labels to kebab tokens and
* tolerantly maps unknown tokens back to the default ("All time").
*/
export const parseAsTimeRange = createParser<TimeRange>({
parse(value) {
return TOKEN_TO_TIME_RANGE[value] ?? DEFAULT_TIME_RANGE
},
serialize(value) {
return TIME_RANGE_TO_TOKEN[value] ?? 'all-time'
},
})
const VALID_LEVELS = ['error', 'info', 'running', 'pending'] as const
/**
* Parser for the `level` param. `level` is a comma-joined list of statuses on
* the wire but is surfaced as a single `LogLevel` value ("all", a single status,
* or a comma-joined string) to match the existing store contract.
*/
export const parseAsLogLevel = createParser<LogLevel>({
parse(value) {
const levels = value
.split(',')
.filter((l): l is (typeof VALID_LEVELS)[number] =>
(VALID_LEVELS as readonly string[]).includes(l)
)
if (levels.length === 0) return 'all'
if (levels.length === 1) return levels[0]
return levels.join(',') as LogLevel
},
serialize(value) {
return value
},
})
const CORE_TRIGGER_SET = new Set<string>(CORE_TRIGGER_TYPES)
/**
* Parser for the `triggers` param, restricted to known core trigger types.
* Surfaced as `TriggerType[]` to match the consumer contract — unknown tokens
* are dropped (mirrors the prior `parseTriggerArrayFromURL` behavior).
*/
export const parseAsTriggers = createParser<TriggerType[]>({
parse(value) {
const triggers = value.split(',').filter((t): t is TriggerType => CORE_TRIGGER_SET.has(t))
return triggers
},
serialize(value) {
return value.join(',')
},
eq(a, b) {
return a.length === b.length && a.every((v, i) => v === b[i])
},
}).withDefault([])
/**
* The nuqs parser map for every URL-synced logs filter. `clearOnDefault` keeps
* the URL clean (params drop out when they hold their default value) and
* `history: 'replace'` matches the prior `history.replaceState` behavior so
* filter changes don't pollute the browser back stack.
*/
export const logFilterParsers = {
timeRange: parseAsTimeRange.withDefault(DEFAULT_TIME_RANGE),
startDate: parseAsString,
endDate: parseAsString,
level: parseAsLogLevel.withDefault('all'),
workflowIds: parseAsArrayOf(parseAsString).withDefault([]),
folderIds: parseAsArrayOf(parseAsString).withDefault([]),
triggers: parseAsTriggers,
search: parseAsString.withDefault(''),
} as const
/** Shared nuqs options for the logs filters: clean URLs, no back-stack churn. */
export const logFilterUrlKeys = {
history: 'replace',
clearOnDefault: true,
} as const
/**
* Read-only deep link to a specific execution. Resolves to a log row and opens
* the details sidebar on load. Intentionally NOT stripped — the link stays
* shareable — so it carries no `clearOnDefault`/`history` options here.
*/
export const executionIdParam = {
key: 'executionId',
parser: parseAsString,
} as const
const LOG_DETAILS_TABS = ['overview', 'trace'] as const
/**
* Active tab of the log-details sidebar (`overview` / `trace`). Deep-linkable so
* a shared link can land on the trace view; `replace` keeps it off the back
* stack and `clearOnDefault` drops it from the URL when on the default tab.
*/
export const logDetailsTabParam = {
key: 'tab',
parser: parseAsStringLiteral(LOG_DETAILS_TABS).withDefault('overview'),
} as const
/** Tab change is view-state, not a destination: replace, clean URL on default. */
export const logDetailsTabUrlKeys = {
history: 'replace',
clearOnDefault: true,
} as const
@@ -0,0 +1,31 @@
export interface Suggestion {
id: string
value: string
label: string
description?: string
color?: string
category?:
| 'filters'
| 'level'
| 'trigger'
| 'cost'
| 'date'
| 'duration'
| 'workflow'
| 'folder'
| 'workflowId'
| 'executionId'
| 'show-all'
}
export interface SuggestionSection {
title: string
suggestions: Suggestion[]
}
export interface SuggestionGroup {
type: 'filter-keys' | 'filter-values' | 'multi-section'
filterKey?: string
suggestions: Suggestion[]
sections?: SuggestionSection[]
}
@@ -0,0 +1,263 @@
import React from 'react'
import { Badge } from '@sim/emcn'
import { formatDuration, formatRelativeTime } from '@sim/utils/formatting'
import { format } from 'date-fns'
import type { WorkflowLogDetail } from '@/lib/api/contracts/logs'
import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
import { getBlock } from '@/blocks/registry'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
export const LOG_COLUMNS = {
workflow: { width: 'w-[22%]', minWidth: 'min-w-[140px]', label: 'Workflow' },
date: { width: 'w-[18%]', minWidth: 'min-w-[140px]', label: 'Date' },
status: { width: 'w-[12%]', minWidth: 'min-w-[100px]', label: 'Status' },
cost: { width: 'w-[14%]', minWidth: 'min-w-[90px]', label: 'Cost' },
trigger: { width: 'w-[14%]', minWidth: 'min-w-[110px]', label: 'Trigger' },
duration: { width: 'w-[20%]', minWidth: 'min-w-[100px]', label: 'Duration' },
} as const
export const DELETED_WORKFLOW_LABEL = 'Deleted Workflow'
export type LogStatus = 'error' | 'pending' | 'running' | 'info' | 'cancelled' | 'cancelling'
/**
* Maps raw status string to LogStatus for display.
* @param status - Raw status from API
* @returns Normalized LogStatus value
*/
export function getDisplayStatus(status: string | null | undefined): LogStatus {
switch (status) {
case 'running':
return 'running'
case 'pending':
return 'pending'
case 'cancelling':
return 'cancelling'
case 'cancelled':
return 'cancelled'
case 'failed':
return 'error'
default:
return 'info'
}
}
export const STATUS_CONFIG: Record<
LogStatus,
{
variant: React.ComponentProps<typeof Badge>['variant']
label: string
color: string
/** Whether this status appears as a filter option. Intermediary states (e.g. cancelling) are excluded. */
filterable: boolean
}
> = {
error: { variant: 'red', label: 'Error', color: 'var(--text-error)', filterable: true },
pending: { variant: 'amber', label: 'Pending', color: '#f59e0b', filterable: true },
running: { variant: 'amber', label: 'Running', color: '#f59e0b', filterable: true },
cancelling: { variant: 'amber', label: 'Cancelling...', color: '#f59e0b', filterable: false },
cancelled: { variant: 'orange', label: 'Cancelled', color: '#f97316', filterable: true },
info: {
variant: 'gray',
label: 'Info',
color: 'var(--terminal-status-info-color)',
filterable: true,
},
}
const TRIGGER_VARIANT_MAP: Record<string, React.ComponentProps<typeof Badge>['variant']> = {
manual: 'gray-secondary',
api: 'blue',
schedule: 'green',
chat: 'purple',
webhook: 'orange',
mcp: 'cyan',
copilot: 'pink',
mothership: 'pink',
workflow: 'blue-secondary',
}
interface StatusBadgeProps {
status: LogStatus
}
/**
* Renders a colored badge indicating log execution status.
* @param props - Component props containing the status
* @returns A Badge with dot indicator and status label
*/
export function StatusBadge({ status }: StatusBadgeProps) {
const config = STATUS_CONFIG[status]
return React.createElement(
Badge,
{ variant: config.variant, dot: true, size: 'sm' },
config.label
)
}
interface TriggerBadgeProps {
trigger: string
}
/**
* Renders a colored badge indicating the workflow trigger type.
* Core triggers display with their designated colors; integrations show with icons.
* @param props - Component props containing the trigger type
* @returns A Badge with appropriate styling for the trigger type
*/
export function TriggerBadge({ trigger }: TriggerBadgeProps) {
const metadata = getIntegrationMetadata(trigger)
const isIntegration = !(CORE_TRIGGER_TYPES as readonly string[]).includes(trigger)
const block = isIntegration ? getBlock(trigger) : null
const IconComponent = block?.icon
const coreVariant = TRIGGER_VARIANT_MAP[trigger]
if (coreVariant) {
return React.createElement(
Badge,
{ variant: coreVariant, size: 'sm', className: 'whitespace-nowrap' },
metadata.label
)
}
if (IconComponent) {
return React.createElement(
Badge,
{
variant: 'gray-secondary',
size: 'sm',
icon: IconComponent,
className: 'whitespace-nowrap',
},
metadata.label
)
}
return React.createElement(
Badge,
{ variant: 'gray-secondary', size: 'sm', className: 'whitespace-nowrap' },
metadata.label
)
}
interface LogWithDuration {
totalDurationMs?: number | string
duration?: number | string
}
/**
* Parse duration from various log data formats.
* Handles both numeric and string duration values.
* @param log - Log object containing duration information
* @returns Duration in milliseconds or null if not available
*/
export function parseDuration(log: LogWithDuration): number | null {
let durationCandidate: number | null = null
if (typeof log.totalDurationMs === 'number') {
durationCandidate = log.totalDurationMs
} else if (typeof log.duration === 'number') {
durationCandidate = log.duration
} else if (typeof log.totalDurationMs === 'string') {
durationCandidate = Number.parseInt(String(log.totalDurationMs).replace(/[^0-9]/g, ''), 10)
} else if (typeof log.duration === 'string') {
durationCandidate = Number.parseInt(String(log.duration).replace(/[^0-9]/g, ''), 10)
}
return Number.isFinite(durationCandidate) ? durationCandidate : null
}
/**
* Format latency value for display in dashboard UI
* @param ms - Latency in milliseconds (number)
* @returns Formatted latency string
*/
export function formatLatency(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '—'
return formatDuration(ms, { precision: 2 }) ?? '—'
}
export function formatDateShort(dateStr: string): string {
const hasTime = dateStr.includes('T')
const [datePart, timePart] = dateStr.split('T')
const [, month, day] = datePart.split('-').map(Number)
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
]
const dateLabel = `${months[month - 1]} ${day}`
if (hasTime && timePart) {
return `${dateLabel} ${timePart.slice(0, 5)}`
}
return dateLabel
}
export const formatDate = (dateString: string) => {
const date = new Date(dateString)
return {
full: date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}),
time: date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}),
formatted: format(date, 'HH:mm:ss'),
compact: format(date, 'MMM d HH:mm:ss'),
compactDate: format(date, 'MMM d').toUpperCase(),
compactTime: format(date, 'h:mm a'),
relative: formatRelativeTime(dateString),
}
}
/**
* Extracts the original workflow input from a log entry for retry.
* Prefers the persisted `workflowInput` field (new logs), falls back to
* reconstructing from `executionState.blockStates` (old logs).
*/
export function extractRetryInput(log: WorkflowLogDetail): unknown | undefined {
const execData = log.executionData
if (!execData) return undefined
if (execData.workflowInput !== undefined) {
return execData.workflowInput
}
const executionState = (execData as Record<string, unknown>).executionState as
| {
blockStates?: Record<
string,
{ output?: unknown; executed?: boolean; executionTime?: number }
>
}
| undefined
if (!executionState?.blockStates) return undefined
// Starter/trigger blocks are pre-populated with executed: false and
// executionTime: 0, which distinguishes them from blocks that actually ran.
for (const state of Object.values(executionState.blockStates)) {
if (state.executed === false && state.executionTime === 0 && state.output != null) {
return state.output
}
}
return undefined
}