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