fix(dashboard): add additional context for paginated logs in dashboard, add empty state when selected cell has no data (#1719)

* fix(dashboard): add additional context for paginated logs in dashboard, add empty state when selected cell has no data

* apps/sim

* renaming

* remove relative import
This commit is contained in:
Waleed
2025-10-22 14:07:52 -07:00
committed by GitHub
parent e425d064c0
commit ec034f3fc7
8 changed files with 470 additions and 327 deletions
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { TooltipProvider } from '@/components/ui/tooltip'
import { formatDate as formatDashboardDate } from '../../utils/format-date'
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils'
export interface LineChartPoint {
timestamp: string
@@ -152,7 +152,7 @@ export function LineChart({
const getCompactDateLabel = (timestamp?: string) => {
if (!timestamp) return ''
try {
const f = formatDashboardDate(timestamp)
const f = formatDate(timestamp)
return `${f.compactDate} · ${f.compactTime}`
} catch (e) {
const d = new Date(timestamp)
@@ -6,7 +6,7 @@ import LineChart, {
type LineChartPoint,
} from '@/app/workspace/[workspaceId]/logs/components/dashboard/line-chart'
import { getTriggerColor } from '@/app/workspace/[workspaceId]/logs/components/dashboard/utils'
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils/format-date'
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
export interface ExecutionLogItem {
@@ -208,7 +208,7 @@ export function WorkflowDetails({
{hasDuration && (
<LineChart
data={details.durations!}
label='Workflow Duration'
label='Duration'
color='#3b82f6'
unit='ms'
series={
@@ -438,7 +438,7 @@ export function WorkflowDetails({
})
})()}
{/* Bottom loading / sentinel */}
{hasMore && (
{hasMore && details.logs.length > 0 && (
<div className='flex items-center justify-center py-3 text-muted-foreground'>
<div ref={loaderRef} className='flex items-center gap-2'>
{isLoadingMore ? (
@@ -17,7 +17,7 @@ export function WorkflowsList({
filteredExecutions,
expandedWorkflowId,
onToggleWorkflow,
selectedSegmentIndex,
selectedSegments,
onSegmentClick,
searchQuery,
segmentDurationMs,
@@ -26,7 +26,7 @@ export function WorkflowsList({
filteredExecutions: WorkflowExecutionItem[]
expandedWorkflowId: string | null
onToggleWorkflow: (workflowId: string) => void
selectedSegmentIndex: number[] | null
selectedSegments: Record<string, number[]>
onSegmentClick: (
workflowId: string,
segmentIndex: number,
@@ -111,7 +111,7 @@ export function WorkflowsList({
<div className='flex-1'>
<StatusBar
segments={workflow.segments}
selectedSegmentIndices={isSelected ? selectedSegmentIndex : null}
selectedSegmentIndices={selectedSegments[workflow.workflowId] || null}
onSegmentClick={onSegmentClick as any}
workflowId={workflow.workflowId}
segmentDurationMs={segmentDurationMs}
@@ -13,7 +13,7 @@ import { FileDownload } from '@/app/workspace/[workspaceId]/logs/components/side
import LogMarkdownRenderer from '@/app/workspace/[workspaceId]/logs/components/sidebar/components/markdown-renderer'
import { ToolCallsDisplay } from '@/app/workspace/[workspaceId]/logs/components/tool-calls/tool-calls-display'
import { TraceSpans } from '@/app/workspace/[workspaceId]/logs/components/trace-spans/trace-spans'
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils/format-date'
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils'
import { formatCost } from '@/providers/utils'
import type { WorkflowLog } from '@/stores/logs/filters/types'
@@ -9,8 +9,10 @@ import KPIs from '@/app/workspace/[workspaceId]/logs/components/dashboard/kpis'
import WorkflowDetails from '@/app/workspace/[workspaceId]/logs/components/dashboard/workflow-details'
import WorkflowsList from '@/app/workspace/[workspaceId]/logs/components/dashboard/workflows-list'
import Timeline from '@/app/workspace/[workspaceId]/logs/components/filters/components/timeline'
import { mapToExecutionLog, mapToExecutionLogAlt } from '@/app/workspace/[workspaceId]/logs/utils'
import { formatCost } from '@/providers/utils'
import { useFilterStore } from '@/stores/logs/filters/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
type TimeFilter = '30m' | '1h' | '6h' | '12h' | '24h' | '3d' | '7d' | '14d' | '30d'
@@ -63,7 +65,7 @@ interface WorkflowDetailsDataLocal {
__meta?: { offset: number; hasMore: boolean }
}
export default function ExecutionsDashboard() {
export default function Dashboard() {
const params = useParams()
const workspaceId = params.workspaceId as string
const router = useRouter()
@@ -111,8 +113,8 @@ export default function ExecutionsDashboard() {
const [aggregateSegments, setAggregateSegments] = useState<
{ timestamp: string; totalExecutions: number; successfulExecutions: number }[]
>([])
const [selectedSegmentIndices, setSelectedSegmentIndices] = useState<number[]>([])
const [lastAnchorIndex, setLastAnchorIndex] = useState<number | null>(null)
const [selectedSegments, setSelectedSegments] = useState<Record<string, number[]>>({})
const [lastAnchorIndices, setLastAnchorIndices] = useState<Record<string, number>>({})
const [searchQuery, setSearchQuery] = useState('')
const [segmentCount, setSegmentCount] = useState<number>(DEFAULT_SEGMENTS)
const barsAreaRef = useRef<HTMLDivElement | null>(null)
@@ -126,6 +128,8 @@ export default function ExecutionsDashboard() {
timeRange: sidebarTimeRange,
} = useFilterStore()
const { workflows } = useWorkflowRegistry()
const timeFilter = getTimeFilterFromRange(sidebarTimeRange)
useEffect(() => {
@@ -346,78 +350,7 @@ export default function ExecutionsDashboard() {
let mappedLogs: ExecutionLog[] = []
if (logsResponse.ok) {
const logsData = await logsResponse.json()
mappedLogs = (logsData.data || []).map((l: any) => {
const started = l.startedAt
? new Date(l.startedAt)
: l.endedAt
? new Date(l.endedAt)
: null
const startedAt =
started && !Number.isNaN(started.getTime())
? started.toISOString()
: new Date().toISOString()
const durationCandidate =
typeof l.totalDurationMs === 'number'
? l.totalDurationMs
: typeof l.duration === 'number'
? l.duration
: typeof l.totalDurationMs === 'string'
? Number.parseInt(l.totalDurationMs.replace(/[^0-9]/g, ''), 10)
: typeof l.duration === 'string'
? Number.parseInt(l.duration.replace(/[^0-9]/g, ''), 10)
: null
let output: any = null
if (l.executionData?.finalOutput !== undefined) {
output = l.executionData.finalOutput
}
if (typeof l.output === 'string') {
output = l.output
} else if (l.executionData?.traceSpans && Array.isArray(l.executionData.traceSpans)) {
const spans: any[] = l.executionData.traceSpans
for (let i = spans.length - 1; i >= 0; i--) {
const s = spans[i]
if (s?.output && Object.keys(s.output).length > 0) {
output = s.output
break
}
if (s?.status === 'error' && (s?.output?.error || s?.error)) {
output = s.output?.error || s.error
break
}
}
if (!output && l.executionData?.output) {
output = l.executionData.output
}
}
if (!output) {
const be = l.executionData?.blockExecutions
if (Array.isArray(be) && be.length > 0) {
const last = be[be.length - 1]
output = last?.outputData || last?.errorMessage || null
}
}
if (!output) output = l.message || null
return {
id: l.id,
executionId: l.executionId,
startedAt,
level: l.level || 'info',
trigger: l.trigger || 'manual',
triggerUserId: l.triggerUserId || null,
triggerInputs: undefined,
outputs: output || undefined,
errorMessage: l.error || null,
duration: Number.isFinite(durationCandidate as number)
? (durationCandidate as number)
: null,
cost: l.cost
? { input: l.cost.input || 0, output: l.cost.output || 0, total: l.cost.total || 0 }
: null,
workflowName: l.workflowName || l.workflow?.name,
workflowColor: l.workflowColor || l.workflow?.color,
} as ExecutionLog
})
mappedLogs = (logsData.data || []).map(mapToExecutionLog)
}
setGlobalDetails({
@@ -471,68 +404,7 @@ export default function ExecutionsDashboard() {
}
const data = await response.json()
const mappedLogs: ExecutionLog[] = (data.data || []).map((l: any) => {
let durationCandidate: number | null = null
if (typeof l.totalDurationMs === 'number') durationCandidate = l.totalDurationMs
else if (typeof l.duration === 'number') durationCandidate = l.duration
else if (typeof l.totalDurationMs === 'string')
durationCandidate = Number.parseInt(
String(l.totalDurationMs).replace(/[^0-9]/g, ''),
10
)
else if (typeof l.duration === 'string')
durationCandidate = Number.parseInt(String(l.duration).replace(/[^0-9]/g, ''), 10)
let output: any = null
if (l.executionData?.finalOutput !== undefined) {
output = l.executionData.finalOutput
} else if (typeof l.output === 'string') {
output = l.output
} else if (l.executionData?.traceSpans && Array.isArray(l.executionData.traceSpans)) {
const spans: any[] = l.executionData.traceSpans
for (let i = spans.length - 1; i >= 0; i--) {
const s = spans[i]
if (s?.output && Object.keys(s.output).length > 0) {
output = s.output
break
}
if (s?.status === 'error' && (s?.output?.error || s?.error)) {
output = s.output?.error || s.error
break
}
}
if (!output && l.executionData?.output) {
output = l.executionData.output
}
}
if (!output) {
const be = l.executionData?.blockExecutions
if (Array.isArray(be) && be.length > 0) {
const last = be[be.length - 1]
output = last?.outputData || last?.errorMessage || null
}
}
return {
id: l.id,
executionId: l.executionId,
startedAt: l.createdAt || l.startedAt,
level: l.level || 'info',
trigger: l.trigger || 'manual',
triggerUserId: l.triggerUserId || null,
triggerInputs: undefined,
outputs: output || undefined,
errorMessage: l.error || null,
duration: Number.isFinite(durationCandidate as number)
? (durationCandidate as number)
: null,
cost: l.cost
? { input: l.cost.input || 0, output: l.cost.output || 0, total: l.cost.total || 0 }
: null,
workflowName: l.workflow?.name,
workflowColor: l.workflow?.color,
} as ExecutionLog
})
const mappedLogs: ExecutionLog[] = (data.data || []).map(mapToExecutionLogAlt)
setWorkflowDetails((prev) => ({
...prev,
@@ -581,64 +453,7 @@ export default function ExecutionsDashboard() {
const res = await fetch(`/api/logs?${qp.toString()}`)
if (!res.ok) return
const data = await res.json()
const more: ExecutionLog[] = (data.data || []).map((l: any) => {
let durationCandidate: number | null = null
if (typeof l.totalDurationMs === 'number') durationCandidate = l.totalDurationMs
else if (typeof l.duration === 'number') durationCandidate = l.duration
else if (typeof l.totalDurationMs === 'string')
durationCandidate = Number.parseInt(
String(l.totalDurationMs).replace(/[^0-9]/g, ''),
10
)
else if (typeof l.duration === 'string')
durationCandidate = Number.parseInt(String(l.duration).replace(/[^0-9]/g, ''), 10)
let output: any = null
if (l.executionData?.finalOutput !== undefined) {
output = l.executionData.finalOutput
} else if (l.executionData?.traceSpans && Array.isArray(l.executionData.traceSpans)) {
const spans: any[] = l.executionData.traceSpans
for (let i = spans.length - 1; i >= 0; i--) {
const s = spans[i]
if (s?.output && Object.keys(s.output).length > 0) {
output = s.output
break
}
if (s?.status === 'error' && (s?.output?.error || s?.error)) {
output = s.output?.error || s.error
break
}
}
if (!output && l.executionData?.output) {
output = l.executionData.output
}
}
if (!output) {
const be = l.executionData?.blockExecutions
if (Array.isArray(be) && be.length > 0) {
const last = be[be.length - 1]
output = last?.outputData || last?.errorMessage || null
}
}
return {
id: l.id,
executionId: l.executionId,
startedAt: l.createdAt || l.startedAt,
level: l.level || 'info',
trigger: l.trigger || 'manual',
triggerUserId: l.triggerUserId || null,
triggerInputs: undefined,
outputs: output || undefined,
errorMessage: l.error || null,
duration: Number.isFinite(durationCandidate as number)
? (durationCandidate as number)
: null,
cost: l.cost
? { input: l.cost.input || 0, output: l.cost.output || 0, total: l.cost.total || 0 }
: null,
workflowName: l.workflow?.name,
workflowColor: l.workflow?.color,
} as ExecutionLog
})
const more: ExecutionLog[] = (data.data || []).map(mapToExecutionLogAlt)
setWorkflowDetails((prev) => {
const cur = prev[workflowId]
@@ -695,34 +510,7 @@ export default function ExecutionsDashboard() {
const res = await fetch(`/api/logs?${qp.toString()}`)
if (!res.ok) return
const data = await res.json()
const more: ExecutionLog[] = (data.data || []).map((l: any) => {
let durationCandidate: number | null = null
if (typeof l.totalDurationMs === 'number') durationCandidate = l.totalDurationMs
else if (typeof l.duration === 'number') durationCandidate = l.duration
else if (typeof l.totalDurationMs === 'string')
durationCandidate = Number.parseInt(String(l.totalDurationMs).replace(/[^0-9]/g, ''), 10)
else if (typeof l.duration === 'string')
durationCandidate = Number.parseInt(String(l.duration).replace(/[^0-9]/g, ''), 10)
return {
id: l.id,
executionId: l.executionId,
startedAt: l.startedAt || l.createdAt,
level: l.level || 'info',
trigger: l.trigger || 'manual',
triggerUserId: l.triggerUserId || null,
triggerInputs: undefined,
outputs: l.executionData?.output || undefined,
errorMessage: l.error || null,
duration: Number.isFinite(durationCandidate as number)
? (durationCandidate as number)
: null,
cost: l.cost
? { input: l.cost.input || 0, output: l.cost.output || 0, total: l.cost.total || 0 }
: null,
workflowName: l.workflow?.name || l.workflowName,
workflowColor: l.workflow?.color || l.workflowColor,
} as ExecutionLog
})
const more: ExecutionLog[] = (data.data || []).map(mapToExecutionLog)
setGlobalDetails((prev) => {
if (!prev) return prev
@@ -760,12 +548,8 @@ export default function ExecutionsDashboard() {
(workflowId: string) => {
if (expandedWorkflowId === workflowId) {
setExpandedWorkflowId(null)
setSelectedSegmentIndices([])
setLastAnchorIndex(null)
} else {
setExpandedWorkflowId(workflowId)
setSelectedSegmentIndices([])
setLastAnchorIndex(null)
if (!workflowDetails[workflowId]) {
fetchWorkflowDetails(workflowId)
}
@@ -781,38 +565,69 @@ export default function ExecutionsDashboard() {
_timestamp: string,
mode: 'single' | 'toggle' | 'range'
) => {
if (expandedWorkflowId !== workflowId) {
setExpandedWorkflowId(workflowId)
if (!workflowDetails[workflowId]) {
fetchWorkflowDetails(workflowId)
}
setSelectedSegmentIndices([segmentIndex])
setLastAnchorIndex(segmentIndex)
} else {
setSelectedSegmentIndices((prev) => {
if (mode === 'single') {
setLastAnchorIndex(segmentIndex)
if (prev.includes(segmentIndex)) {
return prev.filter((i) => i !== segmentIndex)
// Fetch workflow details if not already loaded
if (!workflowDetails[workflowId]) {
fetchWorkflowDetails(workflowId)
}
if (mode === 'toggle') {
// Toggle mode: Add/remove segment from selection, allowing cross-workflow selection
setSelectedSegments((prev) => {
const currentSegments = prev[workflowId] || []
const exists = currentSegments.includes(segmentIndex)
const nextSegments = exists
? currentSegments.filter((i) => i !== segmentIndex)
: [...currentSegments, segmentIndex].sort((a, b) => a - b)
if (nextSegments.length === 0) {
const { [workflowId]: _, ...rest } = prev
// If this was the only workflow with selections, clear expanded
if (Object.keys(rest).length === 0) {
setExpandedWorkflowId(null)
}
return [segmentIndex]
return rest
}
if (mode === 'toggle') {
const exists = prev.includes(segmentIndex)
const next = exists ? prev.filter((i) => i !== segmentIndex) : [...prev, segmentIndex]
setLastAnchorIndex(segmentIndex)
return next.sort((a, b) => a - b)
const newState = { ...prev, [workflowId]: nextSegments }
// Set to multi-workflow mode if multiple workflows have selections
const selectedWorkflowIds = Object.keys(newState)
if (selectedWorkflowIds.length > 1) {
setExpandedWorkflowId('__multi__')
} else if (selectedWorkflowIds.length === 1) {
setExpandedWorkflowId(selectedWorkflowIds[0])
}
const anchor = lastAnchorIndex ?? segmentIndex
const [start, end] =
anchor < segmentIndex ? [anchor, segmentIndex] : [segmentIndex, anchor]
const range = Array.from({ length: end - start + 1 }, (_, i) => start + i)
const union = new Set([...(prev || []), ...range])
return Array.from(union).sort((a, b) => a - b)
return newState
})
setLastAnchorIndices((prev) => ({ ...prev, [workflowId]: segmentIndex }))
} else if (mode === 'single') {
// Single mode: Clear all selections and select only this segment
setExpandedWorkflowId(workflowId)
setSelectedSegments({ [workflowId]: [segmentIndex] })
setLastAnchorIndices({ [workflowId]: segmentIndex })
} else if (mode === 'range') {
// Range mode: Expand selection within the current workflow
if (expandedWorkflowId === workflowId) {
setSelectedSegments((prev) => {
const currentSegments = prev[workflowId] || []
const anchor = lastAnchorIndices[workflowId] ?? segmentIndex
const [start, end] =
anchor < segmentIndex ? [anchor, segmentIndex] : [segmentIndex, anchor]
const range = Array.from({ length: end - start + 1 }, (_, i) => start + i)
const union = new Set([...currentSegments, ...range])
return { ...prev, [workflowId]: Array.from(union).sort((a, b) => a - b) }
})
} else {
// If clicking range on a different workflow, treat as single click
setExpandedWorkflowId(workflowId)
setSelectedSegments({ [workflowId]: [segmentIndex] })
setLastAnchorIndices({ [workflowId]: segmentIndex })
}
}
},
[expandedWorkflowId, workflowDetails, fetchWorkflowDetails, lastAnchorIndex]
[expandedWorkflowId, workflowDetails, fetchWorkflowDetails, lastAnchorIndices]
)
const isInitialMount = useRef(true)
@@ -831,8 +646,8 @@ export default function ExecutionsDashboard() {
}, [expandedWorkflowId, timeFilter, endTime, workflowIds, folderIds, fetchWorkflowDetails])
useEffect(() => {
setSelectedSegmentIndices([])
setLastAnchorIndex(null)
setSelectedSegments({})
setLastAnchorIndices({})
}, [timeFilter, endTime, workflowIds, folderIds, triggers])
useEffect(() => {
@@ -1027,7 +842,7 @@ export default function ExecutionsDashboard() {
filteredExecutions={filteredExecutions as any}
expandedWorkflowId={expandedWorkflowId}
onToggleWorkflow={toggleWorkflow}
selectedSegmentIndex={selectedSegmentIndices as any}
selectedSegments={selectedSegments}
onSegmentClick={handleSegmentClick}
searchQuery={searchQuery}
segmentDurationMs={
@@ -1040,6 +855,170 @@ export default function ExecutionsDashboard() {
{/* Details section in its own scroll area */}
<div className='min-h-0 flex-1 overflow-auto'>
{(() => {
// Handle multi-workflow selection view
if (expandedWorkflowId === '__multi__') {
const selectedWorkflowIds = Object.keys(selectedSegments)
const totalMs = endTime.getTime() - getStartTime().getTime()
const segMs = totalMs / Math.max(1, segmentCount)
// Collect all unique segment indices across all workflows
const allSegmentIndices = new Set<number>()
for (const indices of Object.values(selectedSegments)) {
indices.forEach((idx) => allSegmentIndices.add(idx))
}
const sortedIndices = Array.from(allSegmentIndices).sort((a, b) => a - b)
// Aggregate logs from all selected workflows/segments
const allLogs: any[] = []
let totalExecutions = 0
let totalSuccess = 0
// Build aggregated chart series
const aggregatedSegments: Array<{
timestamp: string
totalExecutions: number
successfulExecutions: number
avgDurationMs: number
durationCount: number
}> = []
// Initialize aggregated segments for each unique index
for (const idx of sortedIndices) {
// Get the timestamp from the first workflow that has this index
let timestamp = ''
for (const wfId of selectedWorkflowIds) {
const wf = executions.find((w) => w.workflowId === wfId)
if (wf?.segments[idx]) {
timestamp = wf.segments[idx].timestamp
break
}
}
aggregatedSegments.push({
timestamp,
totalExecutions: 0,
successfulExecutions: 0,
avgDurationMs: 0,
durationCount: 0,
})
}
// Aggregate data from all workflows
for (const wfId of selectedWorkflowIds) {
const wf = executions.find((w) => w.workflowId === wfId)
const details = workflowDetails[wfId]
const indices = selectedSegments[wfId] || []
if (!wf || !details || indices.length === 0) continue
// Calculate time windows for this workflow's selected segments
const windows = indices
.map((idx) => wf.segments[idx])
.filter(Boolean)
.map((s) => {
const start = new Date(s.timestamp).getTime()
const end = start + segMs
totalExecutions += s.totalExecutions || 0
totalSuccess += s.successfulExecutions || 0
return { start, end }
})
const inAnyWindow = (t: number) =>
windows.some((w) => t >= w.start && t < w.end)
// Filter logs for this workflow's selected segments
const workflowLogs = details.allLogs
.filter((log) => inAnyWindow(new Date(log.startedAt).getTime()))
.map((log) => ({
...log,
workflowName: (log as any).workflowName || wf.workflowName,
workflowColor:
(log as any).workflowColor || workflows[wfId]?.color || '#64748b',
}))
allLogs.push(...workflowLogs)
// Aggregate segment metrics
indices.forEach((idx) => {
const segment = wf.segments[idx]
if (!segment) return
const aggIndex = sortedIndices.indexOf(idx)
if (aggIndex >= 0 && aggregatedSegments[aggIndex]) {
const agg = aggregatedSegments[aggIndex]
agg.totalExecutions += segment.totalExecutions || 0
agg.successfulExecutions += segment.successfulExecutions || 0
if (segment.avgDurationMs) {
agg.avgDurationMs += segment.avgDurationMs
agg.durationCount += 1
}
}
})
}
// Build chart series
const errorRates = aggregatedSegments.map((seg) => ({
timestamp: seg.timestamp,
value:
seg.totalExecutions > 0
? (1 - seg.successfulExecutions / seg.totalExecutions) * 100
: 0,
}))
const executionCounts = aggregatedSegments.map((seg) => ({
timestamp: seg.timestamp,
value: seg.totalExecutions,
}))
const durations = aggregatedSegments.map((seg) => ({
timestamp: seg.timestamp,
value: seg.durationCount > 0 ? seg.avgDurationMs / seg.durationCount : 0,
}))
// Sort logs by time (most recent first)
allLogs.sort(
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
)
const totalFailures = Math.max(totalExecutions - totalSuccess, 0)
const totalRate =
totalExecutions > 0 ? (totalSuccess / totalExecutions) * 100 : 100
return (
<WorkflowDetails
workspaceId={workspaceId}
expandedWorkflowId={'__multi__'}
workflowName={`${selectedWorkflowIds.length} workflows selected`}
overview={{
total: totalExecutions,
success: totalSuccess,
failures: totalFailures,
rate: totalRate,
}}
details={
{
errorRates,
durations,
executionCounts,
logs: allLogs,
allLogs: allLogs,
} as any
}
selectedSegmentIndex={[]}
selectedSegment={null}
clearSegmentSelection={() => {
setSelectedSegments({})
setLastAnchorIndices({})
setExpandedWorkflowId(null)
}}
formatCost={formatCost}
onLoadMore={undefined}
hasMore={false}
isLoadingMore={false}
/>
)
}
if (expandedWorkflowId) {
const wf = executions.find((w) => w.workflowId === expandedWorkflowId)
if (!wf) return null
@@ -1111,11 +1090,12 @@ export default function ExecutionsDashboard() {
}
}
if (details && selectedSegmentIndices.length > 0) {
const workflowSelectedIndices = selectedSegments[expandedWorkflowId] || []
if (details && workflowSelectedIndices.length > 0) {
const totalMs = endTime.getTime() - getStartTime().getTime()
const segMs = totalMs / Math.max(1, segmentCount)
const windows = selectedSegmentIndices
const windows = workflowSelectedIndices
.map((idx) => wf.segments[idx])
.filter(Boolean)
.map((s) => {
@@ -1134,11 +1114,8 @@ export default function ExecutionsDashboard() {
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)))
// Build series from selected segments indices
const idxSet = new Set(selectedSegmentIndices)
const idxSet = new Set(workflowSelectedIndices)
const selectedSegs = wf.segments.filter((_, i) => idxSet.has(i))
;(details as any).__filtered = buildSeriesFromSegments(selectedSegs as any)
}
@@ -1164,8 +1141,8 @@ export default function ExecutionsDashboard() {
: undefined
const selectedSegment =
selectedSegmentIndices.length === 1
? wf.segments[selectedSegmentIndices[0]]
workflowSelectedIndices.length === 1
? wf.segments[workflowSelectedIndices[0]]
: null
return (
@@ -1175,7 +1152,7 @@ export default function ExecutionsDashboard() {
workflowName={wf.workflowName}
overview={{ total, success, failures, rate }}
details={detailsWithFilteredLogs as any}
selectedSegmentIndex={selectedSegmentIndices}
selectedSegmentIndex={workflowSelectedIndices}
selectedSegment={
selectedSegment
? {
@@ -1185,8 +1162,8 @@ export default function ExecutionsDashboard() {
: null
}
clearSegmentSelection={() => {
setSelectedSegmentIndices([])
setLastAnchorIndex(null)
setSelectedSegments({})
setLastAnchorIndices({})
}}
formatCost={formatCost}
onLoadMore={() => loadMoreLogs(expandedWorkflowId)}
@@ -1218,8 +1195,8 @@ export default function ExecutionsDashboard() {
selectedSegmentIndex={[]}
selectedSegment={null}
clearSegmentSelection={() => {
setSelectedSegmentIndices([])
setLastAnchorIndex(null)
setSelectedSegments({})
setLastAnchorIndices({})
}}
formatCost={formatCost}
onLoadMore={loadMoreGlobalLogs}
@@ -9,8 +9,8 @@ import { cn } from '@/lib/utils'
import Controls from '@/app/workspace/[workspaceId]/logs/components/dashboard/controls'
import { AutocompleteSearch } from '@/app/workspace/[workspaceId]/logs/components/search/search'
import { Sidebar } from '@/app/workspace/[workspaceId]/logs/components/sidebar/sidebar'
import ExecutionsDashboard from '@/app/workspace/[workspaceId]/logs/executions-dashboard'
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils/format-date'
import Dashboard from '@/app/workspace/[workspaceId]/logs/dashboard'
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils'
import { useDebounce } from '@/hooks/use-debounce'
import { useFolderStore } from '@/stores/folders/store'
import { useFilterStore } from '@/stores/logs/filters/store'
@@ -680,7 +680,7 @@ export default function Logs() {
// If in dashboard mode, show the dashboard
if (viewMode === 'dashboard') {
return <ExecutionsDashboard />
return <Dashboard />
}
return (
@@ -0,0 +1,212 @@
/**
* Parse duration from various log data formats
*/
export function parseDuration(log: any): number | null {
let durationCandidate: number | null = null
if (typeof log.totalDurationMs === 'number') {
durationCandidate = log.totalDurationMs
} else if (typeof log.duration === 'number') {
durationCandidate = log.duration
} else if (typeof log.totalDurationMs === 'string') {
durationCandidate = Number.parseInt(String(log.totalDurationMs).replace(/[^0-9]/g, ''), 10)
} else if (typeof log.duration === 'string') {
durationCandidate = Number.parseInt(String(log.duration).replace(/[^0-9]/g, ''), 10)
}
return Number.isFinite(durationCandidate) ? durationCandidate : null
}
/**
* Extract output from various sources in execution data
* Checks multiple locations in priority order:
* 1. executionData.finalOutput
* 2. output (as string)
* 3. executionData.traceSpans (iterates through spans)
* 4. executionData.blockExecutions (last block)
* 5. message (fallback)
*/
export function extractOutput(log: any): any {
let output: any = null
// Check finalOutput first
if (log.executionData?.finalOutput !== undefined) {
output = log.executionData.finalOutput
}
// Check direct output field
if (typeof log.output === 'string') {
output = log.output
} else if (log.executionData?.traceSpans && Array.isArray(log.executionData.traceSpans)) {
// Search through trace spans
const spans: any[] = log.executionData.traceSpans
for (let i = spans.length - 1; i >= 0; i--) {
const s = spans[i]
if (s?.output && Object.keys(s.output).length > 0) {
output = s.output
break
}
if (s?.status === 'error' && (s?.output?.error || s?.error)) {
output = s.output?.error || s.error
break
}
}
// Fallback to executionData.output
if (!output && log.executionData?.output) {
output = log.executionData.output
}
}
// Check block executions
if (!output) {
const blockExecutions = log.executionData?.blockExecutions
if (Array.isArray(blockExecutions) && blockExecutions.length > 0) {
const lastBlock = blockExecutions[blockExecutions.length - 1]
output = lastBlock?.outputData || lastBlock?.errorMessage || null
}
}
// Final fallback to message
if (!output) {
output = log.message || null
}
return output
}
/**
* Map raw log data to ExecutionLog format
*/
export interface ExecutionLog {
id: string
executionId: string
startedAt: string
level: string
trigger: string
triggerUserId: string | null
triggerInputs: any
outputs: any
errorMessage: string | null
duration: number | null
cost: {
input: number
output: number
total: number
} | null
workflowName?: string
workflowColor?: string
}
/**
* Convert raw API log response to ExecutionLog format
*/
export function mapToExecutionLog(log: any): ExecutionLog {
const started = log.startedAt
? new Date(log.startedAt)
: log.endedAt
? new Date(log.endedAt)
: null
const startedAt =
started && !Number.isNaN(started.getTime()) ? started.toISOString() : new Date().toISOString()
const duration = parseDuration(log)
const output = extractOutput(log)
return {
id: log.id,
executionId: log.executionId,
startedAt,
level: log.level || 'info',
trigger: log.trigger || 'manual',
triggerUserId: log.triggerUserId || null,
triggerInputs: undefined,
outputs: output || undefined,
errorMessage: log.error || null,
duration,
cost: log.cost
? {
input: log.cost.input || 0,
output: log.cost.output || 0,
total: log.cost.total || 0,
}
: null,
workflowName: log.workflowName || log.workflow?.name,
workflowColor: log.workflowColor || log.workflow?.color,
}
}
/**
* Alternative version that uses createdAt as fallback for startedAt
* (used in some API responses)
*/
export function mapToExecutionLogAlt(log: any): ExecutionLog {
const duration = parseDuration(log)
const output = extractOutput(log)
return {
id: log.id,
executionId: log.executionId,
startedAt: log.createdAt || log.startedAt,
level: log.level || 'info',
trigger: log.trigger || 'manual',
triggerUserId: log.triggerUserId || null,
triggerInputs: undefined,
outputs: output || undefined,
errorMessage: log.error || null,
duration,
cost: log.cost
? {
input: log.cost.input || 0,
output: log.cost.output || 0,
total: log.cost.total || 0,
}
: null,
workflowName: log.workflow?.name,
workflowColor: log.workflow?.color,
}
}
import { format } from 'date-fns'
export const formatDate = (dateString: string) => {
const date = new Date(dateString)
return {
full: date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}),
time: date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}),
formatted: format(date, 'HH:mm:ss'),
compact: format(date, 'MMM d HH:mm:ss'),
compactDate: format(date, 'MMM d').toUpperCase(),
compactTime: format(date, 'HH:mm:ss'),
relative: (() => {
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
if (diffMins < 1) return 'just now'
if (diffMins < 60) return `${diffMins}m ago`
const diffHours = Math.floor(diffMins / 60)
if (diffHours < 24) return `${diffHours}h ago`
const diffDays = Math.floor(diffHours / 24)
if (diffDays === 1) return 'yesterday'
if (diffDays < 7) return `${diffDays}d ago`
return format(date, 'MMM d')
})(),
}
}
@@ -1,46 +0,0 @@
import { format } from 'date-fns'
/**
* Helper function to format date in various formats
*/
export const formatDate = (dateString: string) => {
const date = new Date(dateString)
return {
full: date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}),
time: date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}),
formatted: format(date, 'HH:mm:ss'),
compact: format(date, 'MMM d HH:mm:ss'),
compactDate: format(date, 'MMM d').toUpperCase(),
compactTime: format(date, 'HH:mm:ss'),
relative: (() => {
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
if (diffMins < 1) return 'just now'
if (diffMins < 60) return `${diffMins}m ago`
const diffHours = Math.floor(diffMins / 60)
if (diffHours < 24) return `${diffHours}h ago`
const diffDays = Math.floor(diffHours / 24)
if (diffDays === 1) return 'yesterday'
if (diffDays < 7) return `${diffDays}d ago`
return format(date, 'MMM d')
})(),
}
}