diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/controls.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/controls.tsx index cfe4dfcaff..51778e81ad 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/controls.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/controls.tsx @@ -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 ( -
+
{searchComponent ? ( searchComponent ) : ( @@ -87,34 +93,32 @@ export function Controls({ {isRefetching ? 'Refreshing...' : 'Refresh'} - {showExport && viewMode !== 'dashboard' && ( - - - - - Export CSV - - )} + + + + + Export CSV + + + Export CSV +
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/kpis.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/kpis.tsx index 9e9fc38335..324ed56c2f 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/kpis.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/kpis.tsx @@ -8,28 +8,28 @@ export interface AggregateMetrics { export function KPIs({ aggregate }: { aggregate: AggregateMetrics }) { return ( -
+
Total executions
-
+
{aggregate.totalExecutions.toLocaleString()}
Success rate
-
+
{aggregate.successRate.toFixed(1)}%
Failed executions
-
+
{aggregate.failedExecutions.toLocaleString()}
Active workflows
-
{aggregate.activeWorkflows}
+
{aggregate.activeWorkflows}
) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/line-chart.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/line-chart.tsx index d1b028e57a..bd117666e6 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/line-chart.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/line-chart.tsx @@ -17,14 +17,11 @@ export function LineChart({ color: string unit?: string }) { - // Responsive sizing: chart fills its container width const containerRef = useRef(null) const [containerWidth, setContainerWidth] = useState(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 ( -
+

{label}

{ if (scaledPoints.length === 0) return const rect = (e.currentTarget as SVGSVGElement).getBoundingClientRect() @@ -138,7 +140,7 @@ export function LineChart({ @@ -156,7 +158,7 @@ export function LineChart({ {[0.25, 0.5, 0.75].map((p) => ( ) : ( - // Single-point series: show a dot so the value doesn't "disappear" )} @@ -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 ( @@ -239,26 +241,41 @@ export function LineChart({ }) })()} - - {maxValue} - {unit} - - - {minValue} - {unit} - + {(() => { + const unitSuffix = (unit || '').trim() + const showInTicks = unitSuffix === '%' + const fmtCompact = (v: number) => + new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, + }) + .format(v) + .toLowerCase() + return ( + <> + + {fmtCompact(maxValue)} + {showInTicks ? unit : ''} + + + {fmtCompact(minValue)} + {showInTicks ? unit : ''} + + + ) + })()} void workflowId: string segmentDurationMs: number + preferBelow?: boolean }) { + const [hoverIndex, setHoverIndex] = useState(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 ( - -
+
+
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 = ( -
-
{segment.successRate.toFixed(1)}%
-
- {segment.successfulExecutions ?? 0}/{segment.totalExecutions ?? 0} succeeded -
- {rangeLabel && ( -
{rangeLabel}
- )} -
- ) - } - - // 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 ( - - -
{ - 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() - }} - /> - - - {rangeLabel && ( -
{rangeLabel}
- )} -
- - ) + color = 'bg-red-400/90' } return ( - - -
{ - // 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) - }} - /> - - - {tooltipContent} - - +
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) + }} + /> ) })}
- + + {hoverIndex !== null && segments[hoverIndex] && ( +
+ {segments[hoverIndex].hasExecutions ? ( +
+
{labels[hoverIndex].successLabel}
+
{labels[hoverIndex].countsLabel}
+ {labels[hoverIndex].rangeLabel && ( +
{labels[hoverIndex].rangeLabel}
+ )} +
+ ) : ( +
{labels[hoverIndex].rangeLabel}
+ )} +
+ )} +
) } -export default StatusBar +export default memo(StatusBar) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/workflow-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/workflow-details.tsx index 27275922bc..68238f77b6 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/workflow-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/workflow-details.tsx @@ -67,7 +67,7 @@ export function WorkflowDetails({ const [expandedRowId, setExpandedRowId] = useState(null) return ( -
+
@@ -79,7 +79,7 @@ export function WorkflowDetails({ className='h-[14px] w-[14px] flex-shrink-0 rounded' style={{ backgroundColor: workflowColor }} /> - + {workflowName} @@ -87,17 +87,15 @@ export function WorkflowDetails({
Executions - {overview.total} + {overview.total}
Success - - {overview.rate.toFixed(1)}% - + {overview.rate.toFixed(1)}%
Failures - {overview.failures} + {overview.failures}
@@ -123,9 +121,9 @@ export function WorkflowDetails({ }) : 'Selected segment' return ( -
+
-
+
Filtered to {tsLabel} {selectedSegmentIndex.length > 1 @@ -137,7 +135,7 @@ export function WorkflowDetails({
@@ -151,7 +149,7 @@ export function WorkflowDetails({ ? 'md:grid-cols-2 xl:grid-cols-4' : 'md:grid-cols-2 xl:grid-cols-3' return ( -
+
{(() => { const failures = details.errorRates.map((e, i) => ({ @@ -188,13 +186,13 @@ export function WorkflowDetails({
-
+
Time
-
+
Status
-
+
Trigger
@@ -261,7 +259,7 @@ export function WorkflowDetails({ {formattedDate.compactTime} @@ -271,7 +269,7 @@ export function WorkflowDetails({
-
+
{log.cost && log.cost.total > 0 ? formatCost(log.cost.total) : '—'}
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/workflows-list.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/workflows-list.tsx index ef1e172826..fa5a2c2c49 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/workflows-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/workflows-list.tsx @@ -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 ( -
-
-
-
-
- {fmt(start)} - {fmt(mid)} - {fmt(end)} -
-
-
-
- ) - } + // Date axis above the status bars intentionally removed for a cleaner, denser layout function DynamicLegend() { return ( @@ -92,12 +60,12 @@ export function WorkflowsList({ return (
-
+
-

Workflows

+

Workflows

@@ -107,15 +75,15 @@ export function WorkflowsList({
- - + {/* Axis removed */} +
{filteredExecutions.length === 0 ? (
No workflows found matching "{searchQuery}"
) : ( - 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', }} /> -

{workflow.workflowName}

+

+ {workflow.workflowName} +

@@ -145,11 +115,12 @@ export function WorkflowsList({ onSegmentClick={onSegmentClick as any} workflowId={workflow.workflowId} segmentDurationMs={segmentDurationMs} + preferBelow={idx < 2} />
- + {workflow.overallSuccessRate.toFixed(1)}%
@@ -163,4 +134,4 @@ export function WorkflowsList({ ) } -export default WorkflowsList +export default memo(WorkflowsList) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/folder.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/folder.tsx index e5cb8d7fbc..ebcac6b09e 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/folder.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/folder.tsx @@ -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(null) const { folderIds, toggleFolderId, setFolderIds } = useFilterStore() const { getFolderTree, getFolderPath, fetchFolders } = useFolderStore() const params = useParams() @@ -104,22 +111,21 @@ export default function FolderFilter() { return ( - setSearch(v)} /> - + {loading ? 'Loading folders...' : 'No folders found.'} - - { - 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' +
- All time - {timeRange === 'All time' && } - - - - - {specificTimeRanges.map((range) => ( { - 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' > - {range} - {timeRange === range && } + All time + {timeRange === 'All time' && } - ))} + + + + {specificTimeRanges.map((range) => ( + { + 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' + > + {range} + {timeRange === range && } + + ))} +
) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/trigger.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/trigger.tsx index 90ea545720..a29f11b7c9 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/trigger.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/trigger.tsx @@ -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(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 ( - - { - 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' - > - All triggers - {triggers.length === 0 && } - - - - - {triggerOptions.map((triggerItem) => ( - { - 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' - > -
- {triggerItem.color && ( -
- )} - {triggerItem.label} -
- {isTriggerSelected(triggerItem.value) && ( - - )} - - ))} + + setSearch(v)} /> + + No triggers found. + + clearSelections()} + className='cursor-pointer' + > + All triggers + {triggers.length === 0 && ( + + )} + + {useMemo(() => { + const q = search.trim().toLowerCase() + const filtered = q + ? triggerOptions.filter((t) => t.label.toLowerCase().includes(q)) + : triggerOptions + return filtered.map((triggerItem) => ( + toggleTrigger(triggerItem.value)} + className='cursor-pointer' + > +
+ {triggerItem.color && ( +
+ )} + {triggerItem.label} +
+ {isTriggerSelected(triggerItem.value) && ( + + )} + + )) + }, [search, triggers])} + + + ) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/workflow.tsx index d694dc6cc6..a73e8f6c0c 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/workflow.tsx @@ -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(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 ( - setSearch(v)} /> - + {loading ? 'Loading workflows...' : 'No workflows found.'} - Previous log (↑) + Previous log @@ -372,7 +372,7 @@ export function Sidebar({ - Next log (↓) + Next log diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/trace-spans/components/trace-span-item.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/trace-spans/components/trace-span-item.tsx index 10a8c9eb7e..01aa1849fc 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/trace-spans/components/trace-span-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/trace-spans/components/trace-span-item.tsx @@ -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 && ( +
+ )}
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/trace-spans/trace-spans.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/trace-spans/trace-spans.tsx index ac266b8fab..642ea5b9e1 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/trace-spans/trace-spans.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/trace-spans/trace-spans.tsx @@ -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 && ( - <> -
-
- {formatDurationDisplay(Math.max(0, (hoveredWorkflowMs || 0) - workflowStartTime))} -
- +
+ {formatDurationDisplay(Math.max(0, (hoveredWorkflowMs || 0) - workflowStartTime))} +
)} {/* Hover capture area - aligned to timeline bars, not extending to edge */} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/executions-dashboard.tsx b/apps/sim/app/workspace/[workspaceId]/logs/executions-dashboard.tsx index 23669ed508..7f2fe121de 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/executions-dashboard.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/executions-dashboard.tsx @@ -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(new Date()) @@ -101,6 +102,8 @@ export default function ExecutionsDashboard() { const [selectedSegmentIndices, setSelectedSegmentIndices] = useState([]) const [lastAnchorIndex, setLastAnchorIndex] = useState(null) const [searchQuery, setSearchQuery] = useState('') + const [segmentCount, setSegmentCount] = useState(DEFAULT_SEGMENTS) + const barsAreaRef = useRef(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() {
{/* Controls */} @@ -666,188 +670,230 @@ export default function ExecutionsDashboard() {
) : ( <> - {/* Time Range Display */} -
-
- - {getDateRange()} - - {/* Removed the "Historical" badge per design feedback */} - {(workflowIds.length > 0 || folderIds.length > 0 || triggers.length > 0) && ( -
- Filters: - {workflowIds.length > 0 && ( - - {workflowIds.length} workflow{workflowIds.length !== 1 ? 's' : ''} - - )} - {folderIds.length > 0 && ( - - {folderIds.length} folder{folderIds.length !== 1 ? 's' : ''} - - )} - {triggers.length > 0 && ( - - {triggers.length} trigger{triggers.length !== 1 ? 's' : ''} - - )} + {/* Top section pinned */} +
+ {/* Time Range Display */} +
+
+ + {getDateRange()} + + {(workflowIds.length > 0 || folderIds.length > 0 || triggers.length > 0) && ( +
+ Filters: + {workflowIds.length > 0 && ( + + {workflowIds.length} workflow{workflowIds.length !== 1 ? 's' : ''} + + )} + {folderIds.length > 0 && ( + + {folderIds.length} folder{folderIds.length !== 1 ? 's' : ''} + + )} + {triggers.length > 0 && ( + + {triggers.length} trigger{triggers.length !== 1 ? 's' : ''} + + )} +
+ )} +
+ + {/* Time Controls */} +
+
+
- )} +
- {/* Time Controls */} -
-
- -
+ {/* KPIs */} + + +
+
- {/* KPIs */} - - - - - {/* 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 */} +
+ {(() => { + 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 ( + { + 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 ( { 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 ( - { - setSelectedSegmentIndices([]) - setLastAnchorIndex(null) - }} - formatCost={formatCost} - /> - ) - })()} + })()} +
)}