mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user