improvement(dashboard): cleanup execution dashboard UI, fix logs trace, and improve performance (#1651)

* improvement(dashboard): cleanup execution dashboard UI, fix logs trace, and improve perforamnce

* cleanup

* cleaned up

* ack PR comments
This commit is contained in:
Waleed
2025-10-15 23:00:07 -07:00
committed by GitHub
parent c6ef5785c8
commit b7e0b42d48
15 changed files with 634 additions and 582 deletions

View File

@@ -1,9 +1,10 @@
import type { ReactNode } from 'react'
import { Loader2, Play, RefreshCw, Search, Square } from 'lucide-react'
import { Loader2, RefreshCw, Search } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { soehne } from '@/app/fonts/soehne/soehne'
import Timeline from '@/app/workspace/[workspaceId]/logs/components/filters/components/timeline'
export function Controls({
@@ -32,7 +33,12 @@ export function Controls({
onExport?: () => void
}) {
return (
<div className='mb-8 flex flex-col items-stretch justify-between gap-4 sm:flex-row sm:items-start'>
<div
className={cn(
'mb-8 flex flex-col items-stretch justify-between gap-4 sm:flex-row sm:items-start',
soehne.className
)}
>
{searchComponent ? (
searchComponent
) : (
@@ -87,34 +93,32 @@ export function Controls({
<TooltipContent>{isRefetching ? 'Refreshing...' : 'Refresh'}</TooltipContent>
</Tooltip>
{showExport && viewMode !== 'dashboard' && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={onExport}
className='h-9 rounded-[11px] hover:bg-secondary'
aria-label='Export CSV'
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={onExport}
className='h-9 rounded-[11px] hover:bg-secondary'
aria-label='Export CSV'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
className='h-5 w-5'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
className='h-5 w-5'
>
<path d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4' />
<polyline points='7 10 12 15 17 10' />
<line x1='12' y1='15' x2='12' y2='3' />
</svg>
<span className='sr-only'>Export CSV</span>
</Button>
</TooltipTrigger>
<TooltipContent>Export CSV</TooltipContent>
</Tooltip>
)}
<path d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4' />
<polyline points='7 10 12 15 17 10' />
<line x1='12' y1='15' x2='12' y2='3' />
</svg>
<span className='sr-only'>Export CSV</span>
</Button>
</TooltipTrigger>
<TooltipContent>Export CSV</TooltipContent>
</Tooltip>
<div className='inline-flex h-9 items-center rounded-[11px] border bg-card p-1 shadow-sm'>
<Button
@@ -123,21 +127,13 @@ export function Controls({
onClick={() => setLive((v) => !v)}
className={cn(
'h-7 rounded-[8px] px-3 font-normal text-xs',
live ? 'bg-muted text-foreground' : 'text-muted-foreground hover:text-foreground'
live
? 'bg-[var(--brand-primary-hex)] text-white shadow-[0_0_0_0_var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hover-hex)] hover:text-white hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
: 'text-muted-foreground hover:text-foreground'
)}
aria-pressed={live}
>
{live ? (
<>
<Square className='mr-1.5 h-3 w-3 fill-current' />
Live
</>
) : (
<>
<Play className='mr-1.5 h-3 w-3' />
Live
</>
)}
Live
</Button>
</div>

View File

@@ -8,28 +8,28 @@ export interface AggregateMetrics {
export function KPIs({ aggregate }: { aggregate: AggregateMetrics }) {
return (
<div className='mb-5 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4'>
<div className='mb-2 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4'>
<div className='rounded-[12px] border bg-card p-4 shadow-sm'>
<div className='text-muted-foreground text-xs'>Total executions</div>
<div className='mt-1 font-semibold text-[22px] leading-6'>
<div className='mt-1 font-[440] text-[22px] leading-6'>
{aggregate.totalExecutions.toLocaleString()}
</div>
</div>
<div className='rounded-[12px] border bg-card p-4 shadow-sm'>
<div className='text-muted-foreground text-xs'>Success rate</div>
<div className='mt-1 font-semibold text-[22px] leading-6'>
<div className='mt-1 font-[440] text-[22px] leading-6'>
{aggregate.successRate.toFixed(1)}%
</div>
</div>
<div className='rounded-[12px] border bg-card p-4 shadow-sm'>
<div className='text-muted-foreground text-xs'>Failed executions</div>
<div className='mt-1 font-semibold text-[22px] leading-6'>
<div className='mt-1 font-[440] text-[22px] leading-6'>
{aggregate.failedExecutions.toLocaleString()}
</div>
</div>
<div className='rounded-[12px] border bg-card p-4 shadow-sm'>
<div className='text-muted-foreground text-xs'>Active workflows</div>
<div className='mt-1 font-semibold text-[22px] leading-6'>{aggregate.activeWorkflows}</div>
<div className='mt-1 font-[440] text-[22px] leading-6'>{aggregate.activeWorkflows}</div>
</div>
</div>
)

View File

@@ -17,14 +17,11 @@ export function LineChart({
color: string
unit?: string
}) {
// Responsive sizing: chart fills its container width
const containerRef = useRef<HTMLDivElement | null>(null)
const [containerWidth, setContainerWidth] = useState<number>(420)
const width = containerWidth
const height = 176
// Add a touch more space below the axis so curves never visually clip it
const padding = { top: 18, right: 18, bottom: 32, left: 42 }
// Observe container width for responsiveness
const height = 166
const padding = { top: 16, right: 28, bottom: 26, left: 26 }
useEffect(() => {
if (!containerRef.current) return
const element = containerRef.current
@@ -36,7 +33,6 @@ export function LineChart({
}
})
ro.observe(element)
// Initialize once immediately
const rect = element.getBoundingClientRect()
if (rect?.width) setContainerWidth(Math.max(280, Math.floor(rect.width)))
return () => ro.disconnect()
@@ -67,22 +63,26 @@ export function LineChart({
)
}
// Ensure nice padding on the y-domain so the line never hugs the axes
const rawMax = Math.max(...data.map((d) => d.value), 1)
const rawMin = Math.min(...data.map((d) => d.value), 0)
const paddedMax = rawMax === 0 ? 1 : rawMax * 1.1
const paddedMin = Math.min(0, rawMin) // never below zero for our metrics
const maxValue = Math.ceil(paddedMax)
const minValue = Math.floor(paddedMin)
const paddedMin = Math.min(0, rawMin)
const unitSuffixPre = (unit || '').trim().toLowerCase()
let maxValue = Math.ceil(paddedMax)
let minValue = Math.floor(paddedMin)
if (unitSuffixPre === 'ms') {
maxValue = Math.max(1000, Math.ceil(paddedMax / 1000) * 1000)
minValue = 0
}
const valueRange = maxValue - minValue || 1
const yMin = padding.top + 3
const yMax = padding.top + chartHeight - 3
const scaledPoints = data.map((d, i) => {
const x = padding.left + (i / (data.length - 1 || 1)) * chartWidth
const usableW = Math.max(1, chartWidth - 8)
const x = padding.left + (i / (data.length - 1 || 1)) * usableW
const rawY = padding.top + chartHeight - ((d.value - minValue) / valueRange) * chartHeight
// keep the line safely within the plotting area to avoid clipping behind the x-axis
const y = Math.max(yMin, Math.min(yMax, rawY))
return { x, y }
})
@@ -101,7 +101,6 @@ export function LineChart({
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
// Clamp control points vertically to avoid bezier overshoot below the axis
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}`
@@ -110,14 +109,17 @@ export function LineChart({
})()
return (
<div ref={containerRef} className='w-full rounded-[11px] border bg-card p-4 shadow-sm'>
<div
ref={containerRef}
className='w-full overflow-hidden rounded-[11px] border bg-card p-4 shadow-sm'
>
<h4 className='mb-3 font-medium text-foreground text-sm'>{label}</h4>
<TooltipProvider delayDuration={0}>
<div className='relative' style={{ width, height }}>
<svg
width={width}
height={height}
className='overflow-visible'
className='overflow-hidden'
onMouseMove={(e) => {
if (scaledPoints.length === 0) return
const rect = (e.currentTarget as SVGSVGElement).getBoundingClientRect()
@@ -138,7 +140,7 @@ export function LineChart({
<rect
x={padding.left}
y={yMin}
width={chartWidth}
width={Math.max(1, chartWidth - 8)}
height={chartHeight - (yMin - padding.top) * 2}
rx='2'
/>
@@ -156,7 +158,7 @@ export function LineChart({
{[0.25, 0.5, 0.75].map((p) => (
<line
key={p}
key={`${label}-grid-${p}`}
x1={padding.left}
y1={padding.top + chartHeight * p}
x2={width - padding.right}
@@ -189,7 +191,6 @@ export function LineChart({
style={{ mixBlendMode: isDark ? 'screen' : 'normal' }}
/>
) : (
// Single-point series: show a dot so the value doesn't "disappear"
<circle cx={scaledPoints[0].x} cy={scaledPoints[0].y} r='3' fill={color} />
)}
@@ -215,9 +216,10 @@ export function LineChart({
{(() => {
if (data.length < 2) return null
const usableW = Math.max(1, chartWidth - 8)
const idx = [0, Math.floor(data.length / 2), data.length - 1]
return idx.map((i) => {
const x = padding.left + (i / (data.length - 1 || 1)) * chartWidth
const x = padding.left + (i / (data.length - 1 || 1)) * usableW
const tsSource = data[i]?.timestamp
if (!tsSource) return null
const ts = new Date(tsSource)
@@ -226,10 +228,10 @@ export function LineChart({
: ts.toLocaleString('en-US', { month: 'short', day: 'numeric' })
return (
<text
key={i}
key={`${label}-x-axis-${i}`}
x={x}
y={height - padding.bottom + 14}
fontSize='10'
fontSize='9'
textAnchor='middle'
fill='hsl(var(--muted-foreground))'
>
@@ -239,26 +241,41 @@ export function LineChart({
})
})()}
<text
x={padding.left - 10}
y={padding.top}
textAnchor='end'
fontSize='10'
fill='hsl(var(--muted-foreground))'
>
{maxValue}
{unit}
</text>
<text
x={padding.left - 10}
y={height - padding.bottom}
textAnchor='end'
fontSize='10'
fill='hsl(var(--muted-foreground))'
>
{minValue}
{unit}
</text>
{(() => {
const unitSuffix = (unit || '').trim()
const showInTicks = unitSuffix === '%'
const fmtCompact = (v: number) =>
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='hsl(var(--muted-foreground))'
>
{fmtCompact(maxValue)}
{showInTicks ? unit : ''}
</text>
<text
x={padding.left - 8}
y={height - padding.bottom}
textAnchor='end'
fontSize='9'
fill='hsl(var(--muted-foreground))'
>
{fmtCompact(minValue)}
{showInTicks ? unit : ''}
</text>
</>
)
})()}
<line
x1={padding.left}
@@ -283,7 +300,7 @@ export function LineChart({
} else if (u.toLowerCase().includes('ms')) {
formatted = `${Math.round(val)}ms`
} else if (u.toLowerCase().includes('exec')) {
formatted = `${Math.round(val)}${u}` // keep label like " execs"
formatted = `${Math.round(val)}${u}`
} else {
formatted = `${Math.round(val)}${u}`
}

View File

@@ -1,5 +1,4 @@
import type React from 'react'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { memo, useMemo, useState } from 'react'
export interface StatusBarSegment {
successRate: number
@@ -15,6 +14,7 @@ export function StatusBar({
onSegmentClick,
workflowId,
segmentDurationMs,
preferBelow = false,
}: {
segments: StatusBarSegment[]
selectedSegmentIndices: number[] | null
@@ -26,117 +26,94 @@ export function StatusBar({
) => void
workflowId: string
segmentDurationMs: number
preferBelow?: boolean
}) {
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' })} ${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 (
<TooltipProvider delayDuration={0}>
<div className='flex select-none items-stretch gap-[2px]'>
<div className='relative'>
<div
className='flex select-none items-stretch gap-[2px]'
onMouseLeave={() => setHoverIndex(null)}
>
{segments.map((segment, i) => {
let color: string
let tooltipContent: React.ReactNode
const isSelected = Array.isArray(selectedSegmentIndices)
? selectedSegmentIndices.includes(i)
: false
let color: string
if (!segment.hasExecutions) {
color = 'bg-gray-300/60 dark:bg-gray-500/40'
} else if (segment.successRate === 100) {
color = 'bg-emerald-400/90'
} else if (segment.successRate >= 95) {
color = 'bg-amber-400/90'
} else {
if (segment.successRate === 100) {
color = 'bg-emerald-400/90'
} else if (segment.successRate >= 95) {
color = 'bg-amber-400/90'
} else {
color = 'bg-red-400/90'
}
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' })} ${end.toLocaleString('en-US', { hour: 'numeric', minute: '2-digit' })}`
tooltipContent = (
<div className='text-center'>
<div className='font-semibold'>{segment.successRate.toFixed(1)}%</div>
<div className='mt-1 text-xs'>
{segment.successfulExecutions ?? 0}/{segment.totalExecutions ?? 0} succeeded
</div>
{rangeLabel && (
<div className='mt-1 text-[11px] text-muted-foreground'>{rangeLabel}</div>
)}
</div>
)
}
// For empty segments: show a minimal tooltip with just the time range
if (!segment.hasExecutions) {
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' })} ${end.toLocaleString('en-US', { hour: 'numeric', minute: '2-digit' })}`
return (
<Tooltip key={i}>
<TooltipTrigger asChild>
<div
className={`h-6 flex-1 rounded-[3px] ${color} cursor-pointer transition-[opacity,transform] hover:opacity-90 ${
isSelected
? 'relative z-10 ring-2 ring-primary ring-offset-1'
: 'relative z-0'
}`}
aria-label={`Segment ${i + 1}`}
onClick={(e) => {
e.stopPropagation()
const mode = e.shiftKey
? 'range'
: e.metaKey || e.ctrlKey
? 'toggle'
: 'single'
onSegmentClick(workflowId, i, segment.timestamp, mode)
}}
onMouseDown={(e) => {
// Avoid selecting surrounding text when shift-clicking
e.preventDefault()
}}
/>
</TooltipTrigger>
<TooltipContent side='top' className='select-none px-3 py-2'>
{rangeLabel && (
<div className='text-[11px] text-muted-foreground'>{rangeLabel}</div>
)}
</TooltipContent>
</Tooltip>
)
color = 'bg-red-400/90'
}
return (
<Tooltip key={i}>
<TooltipTrigger asChild>
<div
className={`h-6 flex-1 rounded-[3px] ${color} cursor-pointer transition-[opacity,transform] hover:opacity-90 ${
isSelected ? 'relative z-10 ring-2 ring-primary ring-offset-1' : 'relative z-0'
}`}
aria-label={`Segment ${i + 1}`}
onMouseDown={(e) => {
// Avoid selecting surrounding text when shift-clicking
e.preventDefault()
}}
onClick={(e) => {
e.stopPropagation()
const mode = e.shiftKey ? 'range' : e.metaKey || e.ctrlKey ? 'toggle' : 'single'
onSegmentClick(workflowId, i, segment.timestamp, mode)
}}
/>
</TooltipTrigger>
<TooltipContent side='top' className='select-none px-3 py-2'>
{tooltipContent}
</TooltipContent>
</Tooltip>
<div
key={i}
className={`h-6 flex-1 rounded-[3px] ${color} cursor-pointer transition-[opacity,transform] hover:opacity-90 ${
isSelected ? 'relative z-10 ring-2 ring-primary ring-offset-1' : '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)
}}
/>
)
})}
</div>
</TooltipProvider>
{hoverIndex !== null && segments[hoverIndex] && (
<div
className={`-translate-x-1/2 pointer-events-none absolute z-20 rounded-md bg-background/90 px-2 py-1 text-center text-[11px] shadow-sm ring-1 ring-border backdrop-blur ${
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'>{labels[hoverIndex].successLabel}</div>
<div className='text-muted-foreground'>{labels[hoverIndex].countsLabel}</div>
{labels[hoverIndex].rangeLabel && (
<div className='mt-0.5 text-muted-foreground'>{labels[hoverIndex].rangeLabel}</div>
)}
</div>
) : (
<div className='text-muted-foreground'>{labels[hoverIndex].rangeLabel}</div>
)}
</div>
)}
</div>
)
}
export default StatusBar
export default memo(StatusBar)

View File

@@ -67,7 +67,7 @@ export function WorkflowDetails({
const [expandedRowId, setExpandedRowId] = useState<string | null>(null)
return (
<div className='mt-5 overflow-hidden rounded-[11px] border bg-card shadow-sm'>
<div className='mt-1 overflow-hidden rounded-[11px] border bg-card shadow-sm'>
<div className='border-b bg-muted/30 px-4 py-2.5'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
@@ -79,7 +79,7 @@ export function WorkflowDetails({
className='h-[14px] w-[14px] flex-shrink-0 rounded'
style={{ backgroundColor: workflowColor }}
/>
<span className='font-semibold text-sm tracking-tight group-hover:text-primary'>
<span className='font-[480] text-sm tracking-tight group-hover:text-primary dark:font-[560]'>
{workflowName}
</span>
</button>
@@ -87,17 +87,15 @@ export function WorkflowDetails({
<div className='flex items-center gap-2'>
<div className='inline-flex h-7 items-center gap-2 rounded-[10px] border px-2.5'>
<span className='text-[11px] text-muted-foreground'>Executions</span>
<span className='font-semibold text-sm leading-none'>{overview.total}</span>
<span className='font-[500] text-sm leading-none'>{overview.total}</span>
</div>
<div className='inline-flex h-7 items-center gap-2 rounded-[10px] border px-2.5'>
<span className='text-[11px] text-muted-foreground'>Success</span>
<span className='font-semibold text-sm leading-none'>
{overview.rate.toFixed(1)}%
</span>
<span className='font-[500] text-sm leading-none'>{overview.rate.toFixed(1)}%</span>
</div>
<div className='inline-flex h-7 items-center gap-2 rounded-[10px] border px-2.5'>
<span className='text-[11px] text-muted-foreground'>Failures</span>
<span className='font-semibold text-sm leading-none'>{overview.failures}</span>
<span className='font-[500] text-sm leading-none'>{overview.failures}</span>
</div>
</div>
</div>
@@ -123,9 +121,9 @@ export function WorkflowDetails({
})
: 'Selected segment'
return (
<div className='mb-4 flex items-center justify-between rounded-lg border border-primary/30 bg-primary/10 px-4 py-2.5 text-foreground text-sm'>
<div className='mb-4 flex items-center justify-between rounded-[10px] border bg-muted/30 px-3 py-2 text-[13px] text-foreground'>
<div className='flex items-center gap-2'>
<div className='h-2 w-2 animate-pulse rounded-full bg-primary ring-2 ring-primary/40' />
<div className='h-1.5 w-1.5 rounded-full bg-primary ring-2 ring-primary/30' />
<span className='font-medium'>
Filtered to {tsLabel}
{selectedSegmentIndex.length > 1
@@ -137,7 +135,7 @@ export function WorkflowDetails({
</div>
<button
onClick={clearSegmentSelection}
className='rounded px-2 py-1 text-foreground text-xs hover:bg-primary/20 focus:outline-none focus:ring-2 focus:ring-primary/50'
className='rounded px-2 py-1 text-foreground text-xs hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary/40'
>
Clear filter
</button>
@@ -151,7 +149,7 @@ export function WorkflowDetails({
? 'md:grid-cols-2 xl:grid-cols-4'
: 'md:grid-cols-2 xl:grid-cols-3'
return (
<div className={`mb-4 grid grid-cols-1 gap-4 ${gridCols}`}>
<div className={`mb-3 grid grid-cols-1 gap-3 ${gridCols}`}>
<LineChart
data={details.errorRates}
label='Error Rate'
@@ -168,9 +166,9 @@ export function WorkflowDetails({
)}
<LineChart
data={details.executionCounts}
label='Usage'
label='Executions'
color='#10b981'
unit=' execs'
unit='execs'
/>
{(() => {
const failures = details.errorRates.map((e, i) => ({
@@ -188,13 +186,13 @@ export function WorkflowDetails({
<div>
<div className='border-border border-b'>
<div className='grid min-w-[980px] grid-cols-[140px_90px_90px_90px_180px_1fr_100px] gap-2 px-2 pb-3 md:gap-3 lg:min-w-0 lg:gap-4'>
<div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
<div className='font-[460] font-sans text-[13px] text-muted-foreground leading-normal'>
Time
</div>
<div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
<div className='font-[460] font-sans text-[13px] text-muted-foreground leading-normal'>
Status
</div>
<div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
<div className='font-[460] font-sans text-[13px] text-muted-foreground leading-normal'>
Trigger
</div>
<div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
@@ -261,7 +259,7 @@ export function WorkflowDetails({
</span>
<span
style={{ marginLeft: '8px' }}
className='hidden font-medium sm:inline'
className='hidden font-[400] sm:inline'
>
{formattedDate.compactTime}
</span>
@@ -271,7 +269,7 @@ export function WorkflowDetails({
<div>
<div
className={cn(
'inline-flex items-center rounded-[8px] px-[6px] py-[2px] font-medium text-xs transition-all duration-200 lg:px-[8px]',
'inline-flex items-center rounded-[8px] px-[6px] py-[2px] font-[400] text-xs transition-all duration-200 lg:px-[8px]',
log.level === 'error'
? 'bg-red-500 text-white'
: 'bg-secondary text-card-foreground'
@@ -285,7 +283,7 @@ export function WorkflowDetails({
{log.trigger ? (
<div
className={cn(
'inline-flex items-center rounded-[8px] px-[6px] py-[2px] font-medium text-xs transition-all duration-200 lg:px-[8px]',
'inline-flex items-center rounded-[8px] px-[6px] py-[2px] font-[400] text-xs transition-all duration-200 lg:px-[8px]',
log.trigger.toLowerCase() === 'manual'
? 'bg-secondary text-card-foreground'
: 'text-white'
@@ -304,7 +302,7 @@ export function WorkflowDetails({
</div>
<div>
<div className='font-medium text-muted-foreground text-xs'>
<div className='font-[400] text-muted-foreground text-xs'>
{log.cost && log.cost.total > 0 ? formatCost(log.cost.total) : '—'}
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { useMemo } from 'react'
import { memo, useMemo } from 'react'
import { ScrollArea } from '@/components/ui/scroll-area'
import StatusBar, {
type StatusBarSegment,
@@ -48,39 +48,7 @@ export function WorkflowsList({
return `${mins} minute${mins !== 1 ? 's' : ''}`
}, [segmentDurationMs])
const Axis = () => {
if (!filteredExecutions.length || !segmentsCount || !segmentDurationMs) return null
const firstTs = filteredExecutions[0]?.segments?.[0]?.timestamp
if (!firstTs) return null
const start = new Date(firstTs)
if (Number.isNaN(start.getTime())) return null
const totalMs = segmentsCount * segmentDurationMs
const end = new Date(start.getTime() + totalMs)
const midMs = start.getTime() + totalMs / 2
// Avoid duplicate labels by shifting mid tick slightly if it rounds identical to start/end
const mid = new Date(midMs + 60 * 1000)
const useDates = totalMs >= 24 * 60 * 60 * 1000
const fmt = (d: Date) => {
if (useDates) return d.toLocaleString('en-US', { month: 'short', day: 'numeric' })
return d.toLocaleString('en-US', { hour: 'numeric' })
}
return (
<div className='relative px-3 pt-2 pb-1'>
<div className='mr-[80px] ml-[224px]'>
<div className='relative h-4'>
<div className='-z-10 -translate-y-1/2 absolute inset-x-0 top-1/2 h-px bg-border' />
<div className='flex justify-between text-[10px] text-muted-foreground'>
<span>{fmt(start)}</span>
<span>{fmt(mid)}</span>
<span className='text-right'>{fmt(end)}</span>
</div>
</div>
</div>
</div>
)
}
// Date axis above the status bars intentionally removed for a cleaner, denser layout
function DynamicLegend() {
return (
@@ -92,12 +60,12 @@ export function WorkflowsList({
return (
<div
className='overflow-hidden rounded-lg border bg-card shadow-sm'
style={{ maxHeight: '380px', display: 'flex', flexDirection: 'column' }}
style={{ height: '380px', display: 'flex', flexDirection: 'column' }}
>
<div className='flex-shrink-0 border-b bg-muted/30 px-4 py-2.5'>
<div className='flex-shrink-0 border-b bg-muted/30 px-4 py-2'>
<div className='flex items-center justify-between'>
<div>
<h3 className='font-medium text-sm'>Workflows</h3>
<h3 className='font-[480] text-sm'>Workflows</h3>
<DynamicLegend />
</div>
<span className='text-muted-foreground text-xs'>
@@ -107,15 +75,15 @@ export function WorkflowsList({
</span>
</div>
</div>
<Axis />
<ScrollArea className='flex-1' style={{ height: 'calc(350px - 41px)' }}>
{/* Axis removed */}
<ScrollArea className='min-h-0 flex-1 overflow-auto'>
<div className='space-y-1 p-3'>
{filteredExecutions.length === 0 ? (
<div className='py-8 text-center text-muted-foreground text-sm'>
No workflows found matching "{searchQuery}"
</div>
) : (
filteredExecutions.map((workflow) => {
filteredExecutions.map((workflow, idx) => {
const isSelected = expandedWorkflowId === workflow.workflowId
return (
@@ -134,7 +102,9 @@ export function WorkflowsList({
backgroundColor: workflows[workflow.workflowId]?.color || '#64748b',
}}
/>
<h3 className='truncate font-medium text-sm'>{workflow.workflowName}</h3>
<h3 className='truncate font-[460] text-sm dark:font-medium'>
{workflow.workflowName}
</h3>
</div>
</div>
@@ -145,11 +115,12 @@ export function WorkflowsList({
onSegmentClick={onSegmentClick as any}
workflowId={workflow.workflowId}
segmentDurationMs={segmentDurationMs}
preferBelow={idx < 2}
/>
</div>
<div className='w-16 flex-shrink-0 text-right'>
<span className='font-medium text-muted-foreground text-sm'>
<span className='font-[460] text-muted-foreground text-sm'>
{workflow.overallSuccessRate.toFixed(1)}%
</span>
</div>
@@ -163,4 +134,4 @@ export function WorkflowsList({
)
}
export default WorkflowsList
export default memo(WorkflowsList)

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Check, ChevronDown } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
@@ -16,6 +16,12 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { createLogger } from '@/lib/logs/console/logger'
import {
commandListClass,
dropdownContentClass,
filterButtonClass,
folderDropdownListStyle,
} from '@/app/workspace/[workspaceId]/logs/components/filters/components/shared'
import { useFolderStore } from '@/stores/folders/store'
import { useFilterStore } from '@/stores/logs/filters/store'
@@ -29,6 +35,7 @@ interface FolderOption {
}
export default function FolderFilter() {
const triggerRef = useRef<HTMLButtonElement | null>(null)
const { folderIds, toggleFolderId, setFolderIds } = useFilterStore()
const { getFolderTree, getFolderPath, fetchFolders } = useFolderStore()
const params = useParams()
@@ -104,22 +111,21 @@ export default function FolderFilter() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='outline'
size='sm'
className='w-full justify-between rounded-[10px] border-[#E5E5E5] bg-[#FFFFFF] font-normal text-sm dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
>
<Button ref={triggerRef} variant='outline' size='sm' className={filterButtonClass}>
{loading ? 'Loading folders...' : getSelectedFoldersText()}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
className='w-[200px] rounded-lg border-[#E5E5E5] bg-[#FFFFFF] p-0 shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
side='bottom'
avoidCollisions={false}
sideOffset={4}
className={dropdownContentClass}
>
<Command>
<CommandInput placeholder='Search folders...' onValueChange={(v) => setSearch(v)} />
<CommandList>
<CommandList className={commandListClass} style={folderDropdownListStyle}>
<CommandEmpty>{loading ? 'Loading folders...' : 'No folders found.'}</CommandEmpty>
<CommandGroup>
<CommandItem

View File

@@ -0,0 +1,31 @@
export const filterButtonClass =
'w-full justify-between rounded-[10px] border-[#E5E5E5] bg-[#FFFFFF] font-normal text-sm dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
export const dropdownContentClass =
'w-[200px] rounded-lg border-[#E5E5E5] bg-[#FFFFFF] p-0 shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
export const commandListClass = 'overflow-y-auto overflow-x-hidden'
export const workflowDropdownListStyle = {
maxHeight: '14rem',
overflowY: 'auto',
overflowX: 'hidden',
} as const
export const folderDropdownListStyle = {
maxHeight: '10rem',
overflowY: 'auto',
overflowX: 'hidden',
} as const
export const triggerDropdownListStyle = {
maxHeight: '7.5rem',
overflowY: 'auto',
overflowX: 'hidden',
} as const
export const timelineDropdownListStyle = {
maxHeight: '9rem',
overflowY: 'auto',
overflowX: 'hidden',
} as const

View File

@@ -7,10 +7,20 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
commandListClass,
dropdownContentClass,
filterButtonClass,
timelineDropdownListStyle,
} from '@/app/workspace/[workspaceId]/logs/components/filters/components/shared'
import { useFilterStore } from '@/stores/logs/filters/store'
import type { TimeRange } from '@/stores/logs/filters/types'
export default function Timeline() {
type TimelineProps = {
variant?: 'default' | 'header'
}
export default function Timeline({ variant = 'default' }: TimelineProps = {}) {
const { timeRange, setTimeRange } = useFilterStore()
const specificTimeRanges: TimeRange[] = [
'Past 30 minutes',
@@ -27,47 +37,48 @@ export default function Timeline() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='outline'
size='sm'
className='w-full justify-between rounded-[10px] border-[#E5E5E5] bg-[#FFFFFF] font-normal text-sm dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
>
<Button variant='outline' size='sm' className={filterButtonClass}>
{timeRange}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align={variant === 'header' ? 'end' : 'start'}
side='bottom'
align='end'
sideOffset={6}
collisionPadding={8}
className='w-[220px] rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
avoidCollisions={false}
sideOffset={4}
className={dropdownContentClass}
>
<DropdownMenuItem
key='all'
onSelect={() => {
setTimeRange('All time')
}}
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
<div
className={`${commandListClass} py-1`}
style={variant === 'header' ? undefined : timelineDropdownListStyle}
>
<span>All time</span>
{timeRange === 'All time' && <Check className='h-4 w-4 text-muted-foreground' />}
</DropdownMenuItem>
<DropdownMenuSeparator />
{specificTimeRanges.map((range) => (
<DropdownMenuItem
key={range}
key='all'
onSelect={() => {
setTimeRange(range)
setTimeRange('All time')
}}
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<span>{range}</span>
{timeRange === range && <Check className='h-4 w-4 text-muted-foreground' />}
<span>All time</span>
{timeRange === 'All time' && <Check className='h-4 w-4 text-muted-foreground' />}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{specificTimeRanges.map((range) => (
<DropdownMenuItem
key={range}
onSelect={() => {
setTimeRange(range)
}}
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<span>{range}</span>
{timeRange === range && <Check className='h-4 w-4 text-muted-foreground' />}
</DropdownMenuItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
)

View File

@@ -1,17 +1,32 @@
import { useMemo, useRef, useState } from 'react'
import { Check, ChevronDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
commandListClass,
dropdownContentClass,
filterButtonClass,
triggerDropdownListStyle,
} from '@/app/workspace/[workspaceId]/logs/components/filters/components/shared'
import { useFilterStore } from '@/stores/logs/filters/store'
import type { TriggerType } from '@/stores/logs/filters/types'
export default function Trigger() {
const { triggers, toggleTrigger, setTriggers } = useFilterStore()
const [search, setSearch] = useState('')
const triggerRef = useRef<HTMLButtonElement | null>(null)
const triggerOptions: { value: TriggerType; label: string; color?: string }[] = [
{ value: 'manual', label: 'Manual', color: 'bg-gray-500' },
{ value: 'api', label: 'API', color: 'bg-blue-500' },
@@ -43,53 +58,60 @@ export default function Trigger() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='outline'
size='sm'
className='w-full justify-between rounded-[10px] border-[#E5E5E5] bg-[#FFFFFF] font-normal text-sm dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
>
<Button ref={triggerRef} variant='outline' size='sm' className={filterButtonClass}>
{getSelectedTriggersText()}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
className='w-[180px] rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
side='bottom'
avoidCollisions={false}
sideOffset={4}
className={dropdownContentClass}
>
<DropdownMenuItem
key='all'
onSelect={(e) => {
e.preventDefault()
clearSelections()
}}
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<span>All triggers</span>
{triggers.length === 0 && <Check className='h-4 w-4 text-muted-foreground' />}
</DropdownMenuItem>
<DropdownMenuSeparator />
{triggerOptions.map((triggerItem) => (
<DropdownMenuItem
key={triggerItem.value}
onSelect={(e) => {
e.preventDefault()
toggleTrigger(triggerItem.value)
}}
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<div className='flex items-center'>
{triggerItem.color && (
<div className={`mr-2 h-2 w-2 rounded-full ${triggerItem.color}`} />
)}
{triggerItem.label}
</div>
{isTriggerSelected(triggerItem.value) && (
<Check className='h-4 w-4 text-muted-foreground' />
)}
</DropdownMenuItem>
))}
<Command>
<CommandInput placeholder='Search triggers...' onValueChange={(v) => setSearch(v)} />
<CommandList className={commandListClass} style={triggerDropdownListStyle}>
<CommandEmpty>No triggers found.</CommandEmpty>
<CommandGroup>
<CommandItem
value='all-triggers'
onSelect={() => clearSelections()}
className='cursor-pointer'
>
<span>All triggers</span>
{triggers.length === 0 && (
<Check className='ml-auto h-4 w-4 text-muted-foreground' />
)}
</CommandItem>
{useMemo(() => {
const q = search.trim().toLowerCase()
const filtered = q
? triggerOptions.filter((t) => t.label.toLowerCase().includes(q))
: triggerOptions
return filtered.map((triggerItem) => (
<CommandItem
key={triggerItem.value}
value={triggerItem.label}
onSelect={() => toggleTrigger(triggerItem.value)}
className='cursor-pointer'
>
<div className='flex items-center'>
{triggerItem.color && (
<div className={`mr-2 h-2 w-2 rounded-full ${triggerItem.color}`} />
)}
{triggerItem.label}
</div>
{isTriggerSelected(triggerItem.value) && (
<Check className='ml-auto h-4 w-4 text-muted-foreground' />
)}
</CommandItem>
))
}, [search, triggers])}
</CommandGroup>
</CommandList>
</Command>
</DropdownMenuContent>
</DropdownMenu>
)

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Check, ChevronDown } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
@@ -16,6 +16,12 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { createLogger } from '@/lib/logs/console/logger'
import {
commandListClass,
dropdownContentClass,
filterButtonClass,
workflowDropdownListStyle,
} from '@/app/workspace/[workspaceId]/logs/components/filters/components/shared'
import { useFilterStore } from '@/stores/logs/filters/store'
const logger = createLogger('LogsWorkflowFilter')
@@ -27,6 +33,7 @@ interface WorkflowOption {
}
export default function Workflow() {
const triggerRef = useRef<HTMLButtonElement | null>(null)
const { workflowIds, toggleWorkflowId, setWorkflowIds, folderIds } = useFilterStore()
const params = useParams()
const workspaceId = params?.workspaceId as string | undefined
@@ -84,22 +91,21 @@ export default function Workflow() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='outline'
size='sm'
className='w-full justify-between rounded-[10px] border-[#E5E5E5] bg-[#FFFFFF] font-normal text-sm dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
>
<Button ref={triggerRef} variant='outline' size='sm' className={filterButtonClass}>
{loading ? 'Loading workflows...' : getSelectedWorkflowsText()}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
className='w-[180px] rounded-lg border-[#E5E5E5] bg-[#FFFFFF] p-0 shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
side='bottom'
avoidCollisions={false}
sideOffset={4}
className={dropdownContentClass}
>
<Command>
<CommandInput placeholder='Search workflows...' onValueChange={(v) => setSearch(v)} />
<CommandList>
<CommandList className={commandListClass} style={workflowDropdownListStyle}>
<CommandEmpty>{loading ? 'Loading workflows...' : 'No workflows found.'}</CommandEmpty>
<CommandGroup>
<CommandItem

View File

@@ -184,8 +184,8 @@ export function Sidebar({
hasPrev = false,
}: LogSidebarProps) {
const MIN_WIDTH = 400
const DEFAULT_WIDTH = 600
const EXPANDED_WIDTH = 800
const DEFAULT_WIDTH = 720
const EXPANDED_WIDTH = 900
const [width, setWidth] = useState(DEFAULT_WIDTH) // Start with default width
const [isDragging, setIsDragging] = useState(false)
@@ -325,7 +325,7 @@ export function Sidebar({
return (
<div
className={`fixed top-[148px] right-4 bottom-4 transform rounded-[14px] border bg-card shadow-xs ${
className={`fixed top-24 right-4 bottom-4 transform rounded-[14px] border bg-card shadow-xs ${
isOpen ? 'translate-x-0' : 'translate-x-[calc(100%+1rem)]'
} ${isDragging ? '' : 'transition-all duration-300 ease-in-out'} z-50 flex flex-col`}
style={{ width: `${width}px`, minWidth: `${MIN_WIDTH}px` }}
@@ -354,7 +354,7 @@ export function Sidebar({
<ChevronUp className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent side='bottom'>Previous log ()</TooltipContent>
<TooltipContent side='bottom'>Previous log</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -372,7 +372,7 @@ export function Sidebar({
<ChevronDown className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent side='bottom'>Next log ()</TooltipContent>
<TooltipContent side='bottom'>Next log</TooltipContent>
</Tooltip>
</TooltipProvider>

View File

@@ -52,6 +52,7 @@ export function TraceSpanItem({
workflowStartTime,
onToggle,
expandedSpans,
hoveredPercent,
forwardHover,
gapBeforeMs = 0,
gapBeforePercent = 0,
@@ -609,6 +610,12 @@ export function TraceSpanItem({
)
})
})()}
{hoveredPercent != null && (
<div
className='pointer-events-none absolute top-[-10px] bottom-[-10px] w-px bg-black/30 dark:bg-white/45'
style={{ left: `${Math.max(0, Math.min(100, hoveredPercent))}%`, zIndex: 12 }}
/>
)}
<div className='absolute inset-x-0 inset-y-[-12px] cursor-crosshair' />
</div>
</div>

View File

@@ -275,20 +275,14 @@ export function TraceSpans({ traceSpans, totalDuration = 0, onExpansionChange }:
)
})}
{/* Global crosshair spanning all rows with visible time label */}
{/* Time label for hover (keep top label, row lines render per-row) */}
{hoveredPercent !== null && hoveredX !== null && (
<>
<div
className='pointer-events-none absolute inset-y-0 w-px bg-black/30 dark:bg-white/45'
style={{ left: hoveredX, zIndex: 20 }}
/>
<div
className='-translate-x-1/2 pointer-events-none absolute top-1 rounded bg-popover px-1.5 py-0.5 text-[10px] text-foreground shadow'
style={{ left: hoveredX, zIndex: 20 }}
>
{formatDurationDisplay(Math.max(0, (hoveredWorkflowMs || 0) - workflowStartTime))}
</div>
</>
<div
className='-translate-x-1/2 pointer-events-none absolute top-1 rounded bg-popover px-1.5 py-0.5 text-[10px] text-foreground shadow'
style={{ left: hoveredX, zIndex: 20 }}
>
{formatDurationDisplay(Math.max(0, (hoveredWorkflowMs || 0) - workflowStartTime))}
</div>
)}
{/* Hover capture area - aligned to timeline bars, not extending to edge */}

View File

@@ -27,7 +27,8 @@ interface WorkflowExecution {
overallSuccessRate: number
}
const BAR_COUNT = 120
const DEFAULT_SEGMENTS = 72
const MIN_SEGMENT_PX = 10
interface ExecutionLog {
id: string
@@ -54,7 +55,7 @@ interface WorkflowDetailsDataLocal {
durations: { timestamp: string; value: number }[]
executionCounts: { timestamp: string; value: number }[]
logs: ExecutionLog[]
allLogs: ExecutionLog[] // Unfiltered logs for time filtering
allLogs: ExecutionLog[]
}
export default function ExecutionsDashboard() {
@@ -82,7 +83,7 @@ export default function ExecutionsDashboard() {
case 'Past 30 days':
return '30d'
default:
return '30d' // Treat "All time" as last 30 days to keep UI performant
return '30d'
}
}
const [endTime, setEndTime] = useState<Date>(new Date())
@@ -101,6 +102,8 @@ export default function ExecutionsDashboard() {
const [selectedSegmentIndices, setSelectedSegmentIndices] = useState<number[]>([])
const [lastAnchorIndex, setLastAnchorIndex] = useState<number | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [segmentCount, setSegmentCount] = useState<number>(DEFAULT_SEGMENTS)
const barsAreaRef = useRef<HTMLDivElement | null>(null)
const {
workflowIds,
@@ -113,7 +116,6 @@ export default function ExecutionsDashboard() {
const timeFilter = getTimeFilterFromRange(sidebarTimeRange)
// Build lightweight chart series from a set of logs for a given window
const buildSeriesFromLogs = (
logs: ExecutionLog[],
start: Date,
@@ -157,14 +159,12 @@ export default function ExecutionsDashboard() {
return { errorRates, executionCounts, durations }
}
// Filter executions based on search query
const filteredExecutions = searchQuery.trim()
? executions.filter((workflow) =>
workflow.workflowName.toLowerCase().includes(searchQuery.toLowerCase())
)
: executions
// Aggregate metrics across workflows for header KPIs
const aggregate = useMemo(() => {
let totalExecutions = 0
let successfulExecutions = 0
@@ -242,22 +242,19 @@ export default function ExecutionsDashboard() {
const startTime = getStartTime()
const params = new URLSearchParams({
segments: BAR_COUNT.toString(),
segments: String(segmentCount || DEFAULT_SEGMENTS),
startTime: startTime.toISOString(),
endTime: endTime.toISOString(),
})
// Add workflow filters if any
if (workflowIds.length > 0) {
params.set('workflowIds', workflowIds.join(','))
}
// Add folder filters if any
if (folderIds.length > 0) {
params.set('folderIds', folderIds.join(','))
}
// Add trigger filters if any
if (triggers.length > 0) {
params.set('triggers', triggers.join(','))
}
@@ -271,7 +268,6 @@ export default function ExecutionsDashboard() {
}
const data = await response.json()
// Sort workflows by error rate (highest first)
const sortedWorkflows = [...data.workflows].sort((a, b) => {
const errorRateA = 100 - a.overallSuccessRate
const errorRateB = 100 - b.overallSuccessRate
@@ -279,8 +275,7 @@ export default function ExecutionsDashboard() {
})
setExecutions(sortedWorkflows)
// Compute aggregate segments across all workflows
const segmentsCount: number = Number(params.get('segments') || BAR_COUNT)
const segmentsCount: number = Number(params.get('segments') || DEFAULT_SEGMENTS)
const agg: { timestamp: string; totalExecutions: number; successfulExecutions: number }[] =
Array.from({ length: segmentsCount }, (_, i) => {
const base = startTime.getTime()
@@ -302,7 +297,6 @@ export default function ExecutionsDashboard() {
}
setAggregateSegments(agg)
// Build charts from aggregate
const errorRates = agg.map((s) => ({
timestamp: s.timestamp,
value: s.totalExecutions > 0 ? (1 - s.successfulExecutions / s.totalExecutions) * 100 : 0,
@@ -312,7 +306,6 @@ export default function ExecutionsDashboard() {
value: s.totalExecutions,
}))
// Fetch recent logs for the time window with current filters
const logsParams = new URLSearchParams({
limit: '50',
offset: '0',
@@ -350,13 +343,11 @@ export default function ExecutionsDashboard() {
: typeof l.duration === 'string'
? Number.parseInt(l.duration.replace(/[^0-9]/g, ''), 10)
: null
// Extract a compact output for the table from executionData trace spans when available
let output: any = null
if (typeof l.output === 'string') {
output = l.output
} else if (l.executionData?.traceSpans && Array.isArray(l.executionData.traceSpans)) {
const spans: any[] = l.executionData.traceSpans
// Pick the last span that has an output or error-like payload
for (let i = spans.length - 1; i >= 0; i--) {
const s = spans[i]
if (s?.output && Object.keys(s.output).length > 0) {
@@ -373,7 +364,6 @@ export default function ExecutionsDashboard() {
}
}
if (!output) {
// Some executions store output under executionData.blockExecutions
const be = l.executionData?.blockExecutions
if (Array.isArray(be) && be.length > 0) {
const last = be[be.length - 1]
@@ -419,7 +409,7 @@ export default function ExecutionsDashboard() {
setIsRefetching(false)
}
},
[workspaceId, timeFilter, endTime, getStartTime, workflowIds, folderIds, triggers]
[workspaceId, timeFilter, endTime, getStartTime, workflowIds, folderIds, triggers, segmentCount]
)
const fetchWorkflowDetails = useCallback(
@@ -431,7 +421,6 @@ export default function ExecutionsDashboard() {
endTime: endTime.toISOString(),
})
// Add trigger filters if any
if (triggers.length > 0) {
params.set('triggers', triggers.join(','))
}
@@ -445,12 +434,11 @@ export default function ExecutionsDashboard() {
}
const data = await response.json()
// Store both filtered and all logs - update smoothly without clearing
setWorkflowDetails((prev) => ({
...prev,
[workflowId]: {
...data,
allLogs: data.logs, // Keep a copy of all logs for filtering
allLogs: data.logs,
},
}))
} catch (err) {
@@ -485,20 +473,17 @@ export default function ExecutionsDashboard() {
_timestamp: string,
mode: 'single' | 'toggle' | 'range'
) => {
// Open the workflow details if not already open
if (expandedWorkflowId !== workflowId) {
setExpandedWorkflowId(workflowId)
if (!workflowDetails[workflowId]) {
fetchWorkflowDetails(workflowId)
}
// Select the segment when opening a new workflow
setSelectedSegmentIndices([segmentIndex])
setLastAnchorIndex(segmentIndex)
} else {
setSelectedSegmentIndices((prev) => {
if (mode === 'single') {
setLastAnchorIndex(segmentIndex)
// If already selected, deselect it; otherwise select only this one
if (prev.includes(segmentIndex)) {
return prev.filter((i) => i !== segmentIndex)
}
@@ -510,7 +495,6 @@ export default function ExecutionsDashboard() {
setLastAnchorIndex(segmentIndex)
return next.sort((a, b) => a - b)
}
// range mode
const anchor = lastAnchorIndex ?? segmentIndex
const [start, end] =
anchor < segmentIndex ? [anchor, segmentIndex] : [segmentIndex, anchor]
@@ -523,7 +507,6 @@ export default function ExecutionsDashboard() {
[expandedWorkflowId, workflowDetails, fetchWorkflowDetails, lastAnchorIndex]
)
// Initial load and refetch on dependencies change
const isInitialMount = useRef(true)
useEffect(() => {
const isInitial = isInitialMount.current
@@ -531,22 +514,43 @@ export default function ExecutionsDashboard() {
isInitialMount.current = false
}
fetchExecutions(isInitial)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceId, timeFilter, endTime, workflowIds, folderIds, triggers])
}, [workspaceId, timeFilter, endTime, workflowIds, folderIds, triggers, segmentCount])
// Refetch workflow details when time, filters, or expanded workflow changes
useEffect(() => {
if (expandedWorkflowId) {
fetchWorkflowDetails(expandedWorkflowId)
}
}, [expandedWorkflowId, timeFilter, endTime, workflowIds, folderIds, fetchWorkflowDetails])
// Clear segment selection when time or filters change
useEffect(() => {
setSelectedSegmentIndices([])
setLastAnchorIndex(null)
}, [timeFilter, endTime, workflowIds, folderIds, triggers])
useEffect(() => {
if (!barsAreaRef.current) return
const el = barsAreaRef.current
let debounceId: any = null
const ro = new ResizeObserver(([entry]) => {
const w = entry?.contentRect?.width || 720
const n = Math.max(36, Math.min(96, Math.floor(w / MIN_SEGMENT_PX)))
if (debounceId) clearTimeout(debounceId)
debounceId = setTimeout(() => {
setSegmentCount(n)
}, 150)
})
ro.observe(el)
const rect = el.getBoundingClientRect()
if (rect?.width) {
const n = Math.max(36, Math.min(96, Math.floor(rect.width / MIN_SEGMENT_PX)))
setSegmentCount(n)
}
return () => {
if (debounceId) clearTimeout(debounceId)
ro.disconnect()
}
}, [])
const getShiftLabel = () => {
switch (sidebarTimeRange) {
case 'Past 30 minutes':
@@ -627,7 +631,7 @@ export default function ExecutionsDashboard() {
<div className={`flex h-full min-w-0 flex-col pl-64 ${soehne.className}`}>
<div className='flex min-w-0 flex-1 overflow-hidden'>
<div
className='flex flex-1 flex-col overflow-y-scroll p-6'
className='flex flex-1 flex-col overflow-hidden p-6'
style={{ scrollbarGutter: 'stable' }}
>
{/* Controls */}
@@ -666,188 +670,230 @@ export default function ExecutionsDashboard() {
</div>
) : (
<>
{/* Time Range Display */}
<div className='mb-4 flex items-center justify-between'>
<div className='flex items-center gap-3'>
<span className='font-medium text-muted-foreground text-sm'>
{getDateRange()}
</span>
{/* Removed the "Historical" badge per design feedback */}
{(workflowIds.length > 0 || folderIds.length > 0 || triggers.length > 0) && (
<div className='flex items-center gap-2 text-muted-foreground text-xs'>
<span>Filters:</span>
{workflowIds.length > 0 && (
<span className='inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-primary text-xs'>
{workflowIds.length} workflow{workflowIds.length !== 1 ? 's' : ''}
</span>
)}
{folderIds.length > 0 && (
<span className='inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-primary text-xs'>
{folderIds.length} folder{folderIds.length !== 1 ? 's' : ''}
</span>
)}
{triggers.length > 0 && (
<span className='inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-primary text-xs'>
{triggers.length} trigger{triggers.length !== 1 ? 's' : ''}
</span>
)}
{/* Top section pinned */}
<div className='sticky top-0 z-10 mb-1 bg-background pb-1'>
{/* Time Range Display */}
<div className='mb-3 flex items-center justify-between'>
<div className='flex min-w-0 items-center gap-3'>
<span className='max-w-[40vw] truncate font-[500] text-muted-foreground text-sm'>
{getDateRange()}
</span>
{(workflowIds.length > 0 || folderIds.length > 0 || triggers.length > 0) && (
<div className='flex items-center gap-2 text-muted-foreground text-xs'>
<span>Filters:</span>
{workflowIds.length > 0 && (
<span className='inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-primary text-xs'>
{workflowIds.length} workflow{workflowIds.length !== 1 ? 's' : ''}
</span>
)}
{folderIds.length > 0 && (
<span className='inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-primary text-xs'>
{folderIds.length} folder{folderIds.length !== 1 ? 's' : ''}
</span>
)}
{triggers.length > 0 && (
<span className='inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-primary text-xs'>
{triggers.length} trigger{triggers.length !== 1 ? 's' : ''}
</span>
)}
</div>
)}
</div>
{/* Time Controls */}
<div className='flex items-center gap-2'>
<div className='mr-2 hidden sm:block'>
<Timeline variant='header' />
</div>
)}
</div>
</div>
{/* Time Controls */}
<div className='flex items-center gap-2'>
<div className='mr-2 hidden sm:block'>
<Timeline />
</div>
{/* KPIs */}
<KPIs aggregate={aggregate} />
<div ref={barsAreaRef} className='mb-1'>
<WorkflowsList
executions={executions as any}
filteredExecutions={filteredExecutions as any}
expandedWorkflowId={expandedWorkflowId}
onToggleWorkflow={toggleWorkflow}
selectedSegmentIndex={selectedSegmentIndices as any}
onSegmentClick={handleSegmentClick}
searchQuery={searchQuery}
segmentDurationMs={
(endTime.getTime() - getStartTime().getTime()) / Math.max(1, segmentCount)
}
/>
</div>
</div>
{/* KPIs */}
<KPIs aggregate={aggregate} />
<WorkflowsList
executions={executions as any}
filteredExecutions={filteredExecutions as any}
expandedWorkflowId={expandedWorkflowId}
onToggleWorkflow={toggleWorkflow}
selectedSegmentIndex={selectedSegmentIndices as any}
onSegmentClick={handleSegmentClick}
searchQuery={searchQuery}
segmentDurationMs={(endTime.getTime() - getStartTime().getTime()) / BAR_COUNT}
/>
{/* Details section below the entire bars component - always visible */}
{(() => {
if (expandedWorkflowId) {
const wf = executions.find((w) => w.workflowId === expandedWorkflowId)
if (!wf) return null
const total = wf.segments.reduce((s, x) => s + (x.totalExecutions || 0), 0)
const success = wf.segments.reduce((s, x) => s + (x.successfulExecutions || 0), 0)
const failures = Math.max(total - success, 0)
const rate = total > 0 ? (success / total) * 100 : 100
// Prepare filtered logs for details
const details = workflowDetails[expandedWorkflowId]
let logsToDisplay = details?.logs || []
if (details && selectedSegmentIndices.length > 0) {
const totalMs = endTime.getTime() - getStartTime().getTime()
const segMs = totalMs / BAR_COUNT
const windows = selectedSegmentIndices
.map((idx) => wf.segments[idx])
.filter(Boolean)
.map((s) => {
const start = new Date(s.timestamp).getTime()
const end = start + segMs
return { start, end }
})
const inAnyWindow = (t: number) =>
windows.some((w) => t >= w.start && t < w.end)
logsToDisplay = details.allLogs
.filter((log) => inAnyWindow(new Date(log.startedAt).getTime()))
.map((log) => ({
// Ensure workflow name is visible in the table for multi-select
...log,
workflowName: (log as any).workflowName || wf.workflowName,
}))
const minStart = new Date(Math.min(...windows.map((w) => w.start)))
const maxEnd = new Date(Math.max(...windows.map((w) => w.end)))
let filteredErrorRates = (details.errorRates || []).filter((p: any) =>
inAnyWindow(new Date(p.timestamp).getTime())
)
let filteredDurations = (
Array.isArray((details as any).durations) ? (details as any).durations : []
).filter((p: any) => inAnyWindow(new Date(p.timestamp).getTime()))
let filteredExecutionCounts = (details.executionCounts || []).filter((p: any) =>
inAnyWindow(new Date(p.timestamp).getTime())
{/* Details section in its own scroll area */}
<div className='min-h-0 flex-1 overflow-auto'>
{(() => {
if (expandedWorkflowId) {
const wf = executions.find((w) => w.workflowId === expandedWorkflowId)
if (!wf) return null
const total = wf.segments.reduce((s, x) => s + (x.totalExecutions || 0), 0)
const success = wf.segments.reduce(
(s, x) => s + (x.successfulExecutions || 0),
0
)
const failures = Math.max(total - success, 0)
const rate = total > 0 ? (success / total) * 100 : 100
if (filteredErrorRates.length === 0 || filteredExecutionCounts.length === 0) {
const series = buildSeriesFromLogs(logsToDisplay, minStart, maxEnd, 8)
filteredErrorRates = series.errorRates
filteredExecutionCounts = series.executionCounts
filteredDurations = series.durations
const details = workflowDetails[expandedWorkflowId]
let logsToDisplay = (details?.logs || []).map((log) => ({
...log,
workflowName: (log as any).workflowName || wf.workflowName,
}))
if (details && selectedSegmentIndices.length > 0) {
const totalMs = endTime.getTime() - getStartTime().getTime()
const segMs = totalMs / Math.max(1, segmentCount)
const windows = selectedSegmentIndices
.map((idx) => wf.segments[idx])
.filter(Boolean)
.map((s) => {
const start = new Date(s.timestamp).getTime()
const end = start + segMs
return { start, end }
})
const inAnyWindow = (t: number) =>
windows.some((w) => t >= w.start && t < w.end)
logsToDisplay = details.allLogs
.filter((log) => inAnyWindow(new Date(log.startedAt).getTime()))
.map((log) => ({
...log,
workflowName: (log as any).workflowName || wf.workflowName,
}))
const minStart = new Date(Math.min(...windows.map((w) => w.start)))
const maxEnd = new Date(Math.max(...windows.map((w) => w.end)))
let filteredErrorRates = (details.errorRates || []).filter((p: any) =>
inAnyWindow(new Date(p.timestamp).getTime())
)
let filteredDurations = (
Array.isArray((details as any).durations) ? (details as any).durations : []
).filter((p: any) => inAnyWindow(new Date(p.timestamp).getTime()))
let filteredExecutionCounts = (details.executionCounts || []).filter(
(p: any) => inAnyWindow(new Date(p.timestamp).getTime())
)
if (filteredErrorRates.length === 0 || filteredExecutionCounts.length === 0) {
const series = buildSeriesFromLogs(logsToDisplay, minStart, maxEnd, 8)
filteredErrorRates = series.errorRates
filteredExecutionCounts = series.executionCounts
filteredDurations = series.durations
}
;(details as any).__filtered = {
errorRates: filteredErrorRates,
durations: filteredDurations,
executionCounts: filteredExecutionCounts,
}
}
;(details as any).__filtered = {
errorRates: filteredErrorRates,
durations: filteredDurations,
executionCounts: filteredExecutionCounts,
}
const detailsWithFilteredLogs = details
? {
...details,
logs: logsToDisplay,
errorRates:
(details as any).__filtered?.errorRates ||
details.errorRates ||
buildSeriesFromLogs(
logsToDisplay,
new Date(
wf.segments[0]?.timestamp ||
logsToDisplay[0]?.startedAt ||
new Date().toISOString()
),
endTime,
8
).errorRates,
durations:
(details as any).__filtered?.durations ||
(details as any).durations ||
buildSeriesFromLogs(
logsToDisplay,
new Date(
wf.segments[0]?.timestamp ||
logsToDisplay[0]?.startedAt ||
new Date().toISOString()
),
endTime,
8
).durations,
executionCounts:
(details as any).__filtered?.executionCounts ||
details.executionCounts ||
buildSeriesFromLogs(
logsToDisplay,
new Date(
wf.segments[0]?.timestamp ||
logsToDisplay[0]?.startedAt ||
new Date().toISOString()
),
endTime,
8
).executionCounts,
}
: undefined
const selectedSegment =
selectedSegmentIndices.length === 1
? wf.segments[selectedSegmentIndices[0]]
: null
return (
<WorkflowDetails
workspaceId={workspaceId}
expandedWorkflowId={expandedWorkflowId}
workflowName={wf.workflowName}
overview={{ total, success, failures, rate }}
details={detailsWithFilteredLogs as any}
selectedSegmentIndex={selectedSegmentIndices}
selectedSegment={
selectedSegment
? {
timestamp: selectedSegment.timestamp,
totalExecutions: selectedSegment.totalExecutions,
}
: null
}
clearSegmentSelection={() => {
setSelectedSegmentIndices([])
setLastAnchorIndex(null)
}}
formatCost={formatCost}
/>
)
}
const detailsWithFilteredLogs = details
? {
...details,
logs: logsToDisplay,
errorRates:
(details as any).__filtered?.errorRates ||
details.errorRates ||
buildSeriesFromLogs(
logsToDisplay,
new Date(
wf.segments[0]?.timestamp ||
logsToDisplay[0]?.startedAt ||
new Date().toISOString()
),
endTime,
8
).errorRates,
durations:
(details as any).__filtered?.durations ||
(details as any).durations ||
buildSeriesFromLogs(
logsToDisplay,
new Date(
wf.segments[0]?.timestamp ||
logsToDisplay[0]?.startedAt ||
new Date().toISOString()
),
endTime,
8
).durations,
executionCounts:
(details as any).__filtered?.executionCounts ||
details.executionCounts ||
buildSeriesFromLogs(
logsToDisplay,
new Date(
wf.segments[0]?.timestamp ||
logsToDisplay[0]?.startedAt ||
new Date().toISOString()
),
endTime,
8
).executionCounts,
}
: undefined
const selectedSegment =
selectedSegmentIndices.length === 1
? wf.segments[selectedSegmentIndices[0]]
: null
// Aggregate view for all workflows
if (!globalDetails) return null
const totals = aggregateSegments.reduce(
(acc, s) => {
acc.total += s.totalExecutions
acc.success += s.successfulExecutions
return acc
},
{ total: 0, success: 0 }
)
const failures = Math.max(totals.total - totals.success, 0)
const rate = totals.total > 0 ? (totals.success / totals.total) * 100 : 100
return (
<WorkflowDetails
workspaceId={workspaceId}
expandedWorkflowId={expandedWorkflowId}
workflowName={wf.workflowName}
overview={{ total, success, failures, rate }}
details={detailsWithFilteredLogs as any}
selectedSegmentIndex={selectedSegmentIndices}
selectedSegment={
selectedSegment
? {
timestamp: selectedSegment.timestamp,
totalExecutions: selectedSegment.totalExecutions,
}
: null
}
expandedWorkflowId={'all'}
workflowName={'All workflows'}
overview={{ total: totals.total, success: totals.success, failures, rate }}
details={globalDetails as any}
selectedSegmentIndex={[]}
selectedSegment={null}
clearSegmentSelection={() => {
setSelectedSegmentIndices([])
setLastAnchorIndex(null)
@@ -855,38 +901,8 @@ export default function ExecutionsDashboard() {
formatCost={formatCost}
/>
)
}
// Aggregate view for all workflows
if (!globalDetails) return null
const totals = aggregateSegments.reduce(
(acc, s) => {
acc.total += s.totalExecutions
acc.success += s.successfulExecutions
return acc
},
{ total: 0, success: 0 }
)
const failures = Math.max(totals.total - totals.success, 0)
const rate = totals.total > 0 ? (totals.success / totals.total) * 100 : 100
return (
<WorkflowDetails
workspaceId={workspaceId}
expandedWorkflowId={'all'}
workflowName={'All workflows'}
overview={{ total: totals.total, success: totals.success, failures, rate }}
details={globalDetails as any}
selectedSegmentIndex={[]}
selectedSegment={null}
clearSegmentSelection={() => {
setSelectedSegmentIndices([])
setLastAnchorIndex(null)
}}
formatCost={formatCost}
/>
)
})()}
})()}
</div>
</>
)}
</div>