mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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'
|
||||
|
||||
|
||||
+246
-269
@@ -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')
|
||||
})(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user