improvement(logs): dashboard/logs optimizations and improvements (#2414)

* improvement(logs): dashboard/logs optimizations and improvements

* improvement: addressed comments

* improvement: loading

* cleanup

* ack PR comments

* cleanup more

---------

Co-authored-by: waleed <walif6@gmail.com>
This commit is contained in:
Emir Karabeg
2025-12-22 16:44:10 -08:00
committed by GitHub
parent e01d4cb990
commit ab3a3d12fe
27 changed files with 1361 additions and 1403 deletions

View File

@@ -16,6 +16,10 @@ const QueryParamsSchema = z.object({
folderIds: z.string().optional(), folderIds: z.string().optional(),
triggers: z.string().optional(), triggers: z.string().optional(),
level: z.string().optional(), // Supports comma-separated values: 'error,running' level: z.string().optional(), // Supports comma-separated values: 'error,running'
allTime: z
.enum(['true', 'false'])
.optional()
.transform((v) => v === 'true'),
}) })
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
@@ -29,17 +33,18 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
} }
const userId = session.user.id const userId = session.user.id
const end = qp.endTime ? new Date(qp.endTime) : new Date() let end = qp.endTime ? new Date(qp.endTime) : new Date()
const start = qp.startTime let start = qp.startTime
? new Date(qp.startTime) ? new Date(qp.startTime)
: new Date(end.getTime() - 24 * 60 * 60 * 1000) : new Date(end.getTime() - 24 * 60 * 60 * 1000)
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || start >= end) {
const isAllTime = qp.allTime === true
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
return NextResponse.json({ error: 'Invalid time range' }, { status: 400 }) return NextResponse.json({ error: 'Invalid time range' }, { status: 400 })
} }
const segments = qp.segments const segments = qp.segments
const totalMs = Math.max(1, end.getTime() - start.getTime())
const segmentMs = Math.max(1, Math.floor(totalMs / Math.max(1, segments)))
const [permission] = await db const [permission] = await db
.select() .select()
@@ -75,23 +80,18 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
workflows: [], workflows: [],
startTime: start.toISOString(), startTime: start.toISOString(),
endTime: end.toISOString(), endTime: end.toISOString(),
segmentMs, segmentMs: 0,
}) })
} }
const workflowIdList = workflows.map((w) => w.id) const workflowIdList = workflows.map((w) => w.id)
const logWhere = [ const baseLogWhere = [inArray(workflowExecutionLogs.workflowId, workflowIdList)] as SQL[]
inArray(workflowExecutionLogs.workflowId, workflowIdList),
gte(workflowExecutionLogs.startedAt, start),
lte(workflowExecutionLogs.startedAt, end),
] as SQL[]
if (qp.triggers) { if (qp.triggers) {
const t = qp.triggers.split(',').filter(Boolean) const t = qp.triggers.split(',').filter(Boolean)
logWhere.push(inArray(workflowExecutionLogs.trigger, t)) baseLogWhere.push(inArray(workflowExecutionLogs.trigger, t))
} }
// Handle level filtering with support for derived statuses and multiple selections
if (qp.level && qp.level !== 'all') { if (qp.level && qp.level !== 'all') {
const levels = qp.level.split(',').filter(Boolean) const levels = qp.level.split(',').filter(Boolean)
const levelConditions: SQL[] = [] const levelConditions: SQL[] = []
@@ -100,21 +100,18 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
if (level === 'error') { if (level === 'error') {
levelConditions.push(eq(workflowExecutionLogs.level, 'error')) levelConditions.push(eq(workflowExecutionLogs.level, 'error'))
} else if (level === 'info') { } else if (level === 'info') {
// Completed info logs only
const condition = and( const condition = and(
eq(workflowExecutionLogs.level, 'info'), eq(workflowExecutionLogs.level, 'info'),
isNotNull(workflowExecutionLogs.endedAt) isNotNull(workflowExecutionLogs.endedAt)
) )
if (condition) levelConditions.push(condition) if (condition) levelConditions.push(condition)
} else if (level === 'running') { } else if (level === 'running') {
// Running logs: info level with no endedAt
const condition = and( const condition = and(
eq(workflowExecutionLogs.level, 'info'), eq(workflowExecutionLogs.level, 'info'),
isNull(workflowExecutionLogs.endedAt) isNull(workflowExecutionLogs.endedAt)
) )
if (condition) levelConditions.push(condition) if (condition) levelConditions.push(condition)
} else if (level === 'pending') { } else if (level === 'pending') {
// Pending logs: info level with pause status indicators
const condition = and( const condition = and(
eq(workflowExecutionLogs.level, 'info'), eq(workflowExecutionLogs.level, 'info'),
or( or(
@@ -132,10 +129,55 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
if (levelConditions.length > 0) { if (levelConditions.length > 0) {
const combinedCondition = const combinedCondition =
levelConditions.length === 1 ? levelConditions[0] : or(...levelConditions) levelConditions.length === 1 ? levelConditions[0] : or(...levelConditions)
if (combinedCondition) logWhere.push(combinedCondition) if (combinedCondition) baseLogWhere.push(combinedCondition)
} }
} }
if (isAllTime) {
const boundsQuery = db
.select({
minDate: sql<Date>`MIN(${workflowExecutionLogs.startedAt})`,
maxDate: sql<Date>`MAX(${workflowExecutionLogs.startedAt})`,
})
.from(workflowExecutionLogs)
.leftJoin(
pausedExecutions,
eq(pausedExecutions.executionId, workflowExecutionLogs.executionId)
)
.where(and(...baseLogWhere))
const [bounds] = await boundsQuery
if (bounds?.minDate && bounds?.maxDate) {
start = new Date(bounds.minDate)
end = new Date(Math.max(new Date(bounds.maxDate).getTime(), Date.now()))
} else {
return NextResponse.json({
workflows: workflows.map((wf) => ({
workflowId: wf.id,
workflowName: wf.name,
segments: [],
})),
startTime: new Date().toISOString(),
endTime: new Date().toISOString(),
segmentMs: 0,
})
}
}
if (start >= end) {
return NextResponse.json({ error: 'Invalid time range' }, { status: 400 })
}
const totalMs = Math.max(1, end.getTime() - start.getTime())
const segmentMs = Math.max(1, Math.floor(totalMs / Math.max(1, segments)))
const logWhere = [
...baseLogWhere,
gte(workflowExecutionLogs.startedAt, start),
lte(workflowExecutionLogs.startedAt, end),
]
const logs = await db const logs = await db
.select({ .select({
workflowId: workflowExecutionLogs.workflowId, workflowId: workflowExecutionLogs.workflowId,

View File

@@ -1,2 +1 @@
export type { LineChartMultiSeries, LineChartPoint } from './line-chart' export { default, LineChart, type LineChartMultiSeries, type LineChartPoint } from './line-chart'
export { default, LineChart } from './line-chart'

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react' import { memo, useEffect, useMemo, useRef, useState } from 'react'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { formatDate, formatLatency } from '@/app/workspace/[workspaceId]/logs/utils' import { formatDate, formatLatency } from '@/app/workspace/[workspaceId]/logs/utils'
@@ -15,7 +15,7 @@ export interface LineChartMultiSeries {
dashed?: boolean dashed?: boolean
} }
export function LineChart({ function LineChartComponent({
data, data,
label, label,
color, color,
@@ -95,6 +95,133 @@ export function LineChart({
const hasExternalWrapper = !label || label === '' const hasExternalWrapper = !label || label === ''
const allSeries = useMemo(
() =>
(Array.isArray(series) && series.length > 0
? [{ id: 'base', label, color, data }, ...series]
: [{ id: 'base', label, color, data }]
).map((s, idx) => ({ ...s, id: s.id || s.label || String(idx) })),
[series, label, color, data]
)
const { maxValue, minValue, valueRange } = useMemo(() => {
const flatValues = allSeries.flatMap((s) => s.data.map((d) => d.value))
const rawMax = Math.max(...flatValues, 1)
const rawMin = Math.min(...flatValues, 0)
const paddedMax = rawMax === 0 ? 1 : rawMax * 1.1
const paddedMin = Math.min(0, rawMin)
const unitSuffixPre = (unit || '').trim().toLowerCase()
let maxVal = Math.ceil(paddedMax)
let minVal = Math.floor(paddedMin)
if (unitSuffixPre === 'ms' || unitSuffixPre === 'latency') {
minVal = 0
if (paddedMax < 10) {
maxVal = Math.ceil(paddedMax)
} else if (paddedMax < 100) {
maxVal = Math.ceil(paddedMax / 10) * 10
} else if (paddedMax < 1000) {
maxVal = Math.ceil(paddedMax / 50) * 50
} else if (paddedMax < 10000) {
maxVal = Math.ceil(paddedMax / 500) * 500
} else {
maxVal = Math.ceil(paddedMax / 1000) * 1000
}
}
return {
maxValue: maxVal,
minValue: minVal,
valueRange: maxVal - minVal || 1,
}
}, [allSeries, unit])
const yMin = padding.top + 3
const yMax = padding.top + chartHeight - 3
const scaledPoints = useMemo(
() =>
data.map((d, i) => {
const usableW = Math.max(1, chartWidth)
const x = padding.left + (i / (data.length - 1 || 1)) * usableW
const rawY = padding.top + chartHeight - ((d.value - minValue) / valueRange) * chartHeight
const y = Math.max(yMin, Math.min(yMax, rawY))
return { x, y }
}),
[data, chartWidth, chartHeight, minValue, valueRange, yMin, yMax, padding.left, padding.top]
)
const scaledSeries = useMemo(
() =>
allSeries.map((s) => {
const pts = s.data.map((d, i) => {
const usableW = Math.max(1, chartWidth)
const x = padding.left + (i / (s.data.length - 1 || 1)) * usableW
const rawY = padding.top + chartHeight - ((d.value - minValue) / valueRange) * chartHeight
const y = Math.max(yMin, Math.min(yMax, rawY))
return { x, y }
})
return { ...s, pts }
}),
[
allSeries,
chartWidth,
chartHeight,
minValue,
valueRange,
yMin,
yMax,
padding.left,
padding.top,
]
)
const getSeriesById = (id?: string | null) => scaledSeries.find((s) => s.id === id)
const visibleSeries = useMemo(
() => (activeSeriesId ? scaledSeries.filter((s) => s.id === activeSeriesId) : scaledSeries),
[activeSeriesId, scaledSeries]
)
const pathD = useMemo(() => {
if (scaledPoints.length <= 1) return ''
const p = scaledPoints
const tension = 0.2
let d = `M ${p[0].x} ${p[0].y}`
for (let i = 0; i < p.length - 1; i++) {
const p0 = p[i - 1] || p[i]
const p1 = p[i]
const p2 = p[i + 1]
const p3 = p[i + 2] || p[i + 1]
const cp1x = p1.x + ((p2.x - p0.x) / 6) * tension
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
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}`
}
return d
}, [scaledPoints, yMin, yMax])
const getCompactDateLabel = (timestamp?: string) => {
if (!timestamp) return ''
try {
const f = formatDate(timestamp)
return `${f.compactDate} ${f.compactTime}`
} catch (e) {
const d = new Date(timestamp)
if (Number.isNaN(d.getTime())) return ''
return d.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
}
}
const currentHoverDate =
hoverIndex !== null && data[hoverIndex] ? getCompactDateLabel(data[hoverIndex].timestamp) : ''
if (containerWidth === null) { if (containerWidth === null) {
return ( return (
<div <div
@@ -119,109 +246,6 @@ export function LineChart({
) )
} }
const allSeries = (
Array.isArray(series) && series.length > 0
? [{ id: 'base', label, color, data }, ...series]
: [{ id: 'base', label, color, data }]
).map((s, idx) => ({ ...s, id: s.id || s.label || String(idx) }))
const flatValues = allSeries.flatMap((s) => s.data.map((d) => d.value))
const rawMax = Math.max(...flatValues, 1)
const rawMin = Math.min(...flatValues, 0)
const paddedMax = rawMax === 0 ? 1 : rawMax * 1.1
const paddedMin = Math.min(0, rawMin)
const unitSuffixPre = (unit || '').trim().toLowerCase()
let maxValue = Math.ceil(paddedMax)
let minValue = Math.floor(paddedMin)
if (unitSuffixPre === 'ms' || unitSuffixPre === 'latency') {
minValue = 0
if (paddedMax < 10) {
maxValue = Math.ceil(paddedMax)
} else if (paddedMax < 100) {
maxValue = Math.ceil(paddedMax / 10) * 10
} else if (paddedMax < 1000) {
maxValue = Math.ceil(paddedMax / 50) * 50
} else if (paddedMax < 10000) {
maxValue = Math.ceil(paddedMax / 500) * 500
} else {
maxValue = Math.ceil(paddedMax / 1000) * 1000
}
}
const valueRange = maxValue - minValue || 1
const yMin = padding.top + 3
const yMax = padding.top + chartHeight - 3
const scaledPoints = data.map((d, i) => {
const usableW = Math.max(1, chartWidth)
const x = padding.left + (i / (data.length - 1 || 1)) * usableW
const rawY = padding.top + chartHeight - ((d.value - minValue) / valueRange) * chartHeight
const y = Math.max(yMin, Math.min(yMax, rawY))
return { x, y }
})
const scaledSeries = allSeries.map((s) => {
const pts = s.data.map((d, i) => {
const usableW = Math.max(1, chartWidth)
const x = padding.left + (i / (s.data.length - 1 || 1)) * usableW
const rawY = padding.top + chartHeight - ((d.value - minValue) / valueRange) * chartHeight
const y = Math.max(yMin, Math.min(yMax, rawY))
return { x, y }
})
return { ...s, pts }
})
const getSeriesById = (id?: string | null) => scaledSeries.find((s) => s.id === id)
const visibleSeries = activeSeriesId
? scaledSeries.filter((s) => s.id === activeSeriesId)
: scaledSeries
const orderedSeries = (() => {
if (!activeSeriesId) return visibleSeries
return visibleSeries
})()
const pathD = (() => {
if (scaledPoints.length <= 1) return ''
const p = scaledPoints
const tension = 0.2
let d = `M ${p[0].x} ${p[0].y}`
for (let i = 0; i < p.length - 1; i++) {
const p0 = p[i - 1] || p[i]
const p1 = p[i]
const p2 = p[i + 1]
const p3 = p[i + 2] || p[i + 1]
const cp1x = p1.x + ((p2.x - p0.x) / 6) * tension
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
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}`
}
return d
})()
const getCompactDateLabel = (timestamp?: string) => {
if (!timestamp) return ''
try {
const f = formatDate(timestamp)
return `${f.compactDate} ${f.compactTime}`
} catch (e) {
const d = new Date(timestamp)
if (Number.isNaN(d.getTime())) return ''
return d.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
}
}
const currentHoverDate =
hoverIndex !== null && data[hoverIndex] ? getCompactDateLabel(data[hoverIndex].timestamp) : ''
return ( return (
<div <div
ref={containerRef} ref={containerRef}
@@ -386,7 +410,7 @@ export function LineChart({
) )
})()} })()}
{orderedSeries.map((s, idx) => { {visibleSeries.map((s, idx) => {
const isActive = activeSeriesId ? activeSeriesId === s.id : true const isActive = activeSeriesId ? activeSeriesId === s.id : true
const isHovered = hoverSeriesId ? hoverSeriesId === s.id : false const isHovered = hoverSeriesId ? hoverSeriesId === s.id : false
const baseOpacity = isActive ? 1 : 0.12 const baseOpacity = isActive ? 1 : 0.12
@@ -682,4 +706,8 @@ export function LineChart({
) )
} }
/**
* Memoized LineChart component to prevent re-renders when parent updates.
*/
export const LineChart = memo(LineChartComponent)
export default LineChart export default LineChart

View File

@@ -36,7 +36,7 @@ export function StatusBar({
const end = new Date(start.getTime() + (segmentDurationMs || 0)) const end = new Date(start.getTime() + (segmentDurationMs || 0))
const rangeLabel = Number.isNaN(start.getTime()) 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' })}` : `${start.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })} ${end.toLocaleString('en-US', { hour: 'numeric', minute: '2-digit' })}`
return { return {
rangeLabel, rangeLabel,
successLabel: `${segment.successRate.toFixed(1)}%`, successLabel: `${segment.successRate.toFixed(1)}%`,

View File

@@ -11,7 +11,6 @@ export interface WorkflowExecutionItem {
} }
export function WorkflowsList({ export function WorkflowsList({
executions,
filteredExecutions, filteredExecutions,
expandedWorkflowId, expandedWorkflowId,
onToggleWorkflow, onToggleWorkflow,
@@ -20,7 +19,6 @@ export function WorkflowsList({
searchQuery, searchQuery,
segmentDurationMs, segmentDurationMs,
}: { }: {
executions: WorkflowExecutionItem[]
filteredExecutions: WorkflowExecutionItem[] filteredExecutions: WorkflowExecutionItem[]
expandedWorkflowId: string | null expandedWorkflowId: string | null
onToggleWorkflow: (workflowId: string) => void onToggleWorkflow: (workflowId: string) => void

View File

@@ -2,24 +2,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Loader2 } from 'lucide-react' import { Loader2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { import { formatLatency, parseDuration } from '@/app/workspace/[workspaceId]/logs/utils'
formatLatency,
mapToExecutionLog,
mapToExecutionLogAlt,
} from '@/app/workspace/[workspaceId]/logs/utils'
import {
useExecutionsMetrics,
useGlobalDashboardLogs,
useWorkflowDashboardLogs,
} from '@/hooks/queries/logs'
import { useFilterStore } from '@/stores/logs/filters/store' import { useFilterStore } from '@/stores/logs/filters/store'
import type { WorkflowLog } from '@/stores/logs/filters/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { LineChart, WorkflowsList } from './components' import { LineChart, WorkflowsList } from './components'
type TimeFilter = '30m' | '1h' | '6h' | '12h' | '24h' | '3d' | '7d' | '14d' | '30d'
interface WorkflowExecution { interface WorkflowExecution {
workflowId: string workflowId: string
workflowName: string workflowName: string
@@ -39,18 +28,12 @@ interface WorkflowExecution {
const DEFAULT_SEGMENTS = 72 const DEFAULT_SEGMENTS = 72
const MIN_SEGMENT_PX = 10 const MIN_SEGMENT_PX = 10
const MIN_SEGMENT_MS = 60000
/**
* Predetermined heights for skeleton bars to avoid hydration mismatch.
* Using static values instead of Math.random() ensures server/client consistency.
*/
const SKELETON_BAR_HEIGHTS = [ const SKELETON_BAR_HEIGHTS = [
45, 72, 38, 85, 52, 68, 30, 90, 55, 42, 78, 35, 88, 48, 65, 28, 82, 58, 40, 75, 32, 95, 50, 70, 45, 72, 38, 85, 52, 68, 30, 90, 55, 42, 78, 35, 88, 48, 65, 28, 82, 58, 40, 75, 32, 95, 50, 70,
] ]
/**
* Skeleton loader for a single graph card
*/
function GraphCardSkeleton({ title }: { title: string }) { function GraphCardSkeleton({ title }: { title: string }) {
return ( return (
<div className='flex flex-col overflow-hidden rounded-[6px] bg-[var(--surface-elevated)]'> <div className='flex flex-col overflow-hidden rounded-[6px] bg-[var(--surface-elevated)]'>
@@ -62,7 +45,6 @@ function GraphCardSkeleton({ title }: { title: string }) {
</div> </div>
<div className='flex-1 overflow-y-auto rounded-t-[6px] bg-[var(--surface-1)] px-[14px] py-[10px]'> <div className='flex-1 overflow-y-auto rounded-t-[6px] bg-[var(--surface-1)] px-[14px] py-[10px]'>
<div className='flex h-[166px] flex-col justify-end gap-[4px]'> <div className='flex h-[166px] flex-col justify-end gap-[4px]'>
{/* Skeleton bars simulating chart */}
<div className='flex items-end gap-[2px]'> <div className='flex items-end gap-[2px]'>
{SKELETON_BAR_HEIGHTS.map((height, i) => ( {SKELETON_BAR_HEIGHTS.map((height, i) => (
<Skeleton <Skeleton
@@ -80,24 +62,16 @@ function GraphCardSkeleton({ title }: { title: string }) {
) )
} }
/**
* Skeleton loader for a workflow row in the workflows list
*/
function WorkflowRowSkeleton() { function WorkflowRowSkeleton() {
return ( return (
<div className='flex h-[44px] items-center gap-[16px] px-[24px]'> <div className='flex h-[44px] items-center gap-[16px] px-[24px]'>
{/* Workflow name with color */}
<div className='flex w-[160px] flex-shrink-0 items-center gap-[8px] pr-[8px]'> <div className='flex w-[160px] flex-shrink-0 items-center gap-[8px] pr-[8px]'>
<Skeleton className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]' /> <Skeleton className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]' />
<Skeleton className='h-[16px] flex-1' /> <Skeleton className='h-[16px] flex-1' />
</div> </div>
{/* Status bar - takes most of the space */}
<div className='flex-1'> <div className='flex-1'>
<Skeleton className='h-[24px] w-full rounded-[4px]' /> <Skeleton className='h-[24px] w-full rounded-[4px]' />
</div> </div>
{/* Success rate */}
<div className='w-[100px] flex-shrink-0 pl-[16px]'> <div className='w-[100px] flex-shrink-0 pl-[16px]'>
<Skeleton className='h-[16px] w-[50px]' /> <Skeleton className='h-[16px] w-[50px]' />
</div> </div>
@@ -105,13 +79,9 @@ function WorkflowRowSkeleton() {
) )
} }
/**
* Skeleton loader for the workflows list table
*/
function WorkflowsListSkeleton({ rowCount = 5 }: { rowCount?: number }) { function WorkflowsListSkeleton({ rowCount = 5 }: { rowCount?: number }) {
return ( return (
<div className='flex h-full flex-col overflow-hidden rounded-[6px] bg-[var(--surface-1)]'> <div className='flex h-full flex-col overflow-hidden rounded-[6px] bg-[var(--surface-1)]'>
{/* Table header */}
<div className='flex-shrink-0 rounded-t-[6px] bg-[var(--surface-3)] px-[24px] py-[10px]'> <div className='flex-shrink-0 rounded-t-[6px] bg-[var(--surface-3)] px-[24px] py-[10px]'>
<div className='flex items-center gap-[16px]'> <div className='flex items-center gap-[16px]'>
<span className='w-[160px] flex-shrink-0 font-medium text-[12px] text-[var(--text-tertiary)]'> <span className='w-[160px] flex-shrink-0 font-medium text-[12px] text-[var(--text-tertiary)]'>
@@ -123,8 +93,6 @@ function WorkflowsListSkeleton({ rowCount = 5 }: { rowCount?: number }) {
</span> </span>
</div> </div>
</div> </div>
{/* Table body - scrollable */}
<div className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden'> <div className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden'>
{Array.from({ length: rowCount }).map((_, i) => ( {Array.from({ length: rowCount }).map((_, i) => (
<WorkflowRowSkeleton key={i} /> <WorkflowRowSkeleton key={i} />
@@ -134,13 +102,9 @@ function WorkflowsListSkeleton({ rowCount = 5 }: { rowCount?: number }) {
) )
} }
/**
* Complete skeleton loader for the entire dashboard
*/
function DashboardSkeleton() { function DashboardSkeleton() {
return ( return (
<div className='mt-[24px] flex min-h-0 flex-1 flex-col pb-[24px]'> <div className='mt-[24px] flex min-h-0 flex-1 flex-col pb-[24px]'>
{/* Graphs Section */}
<div className='mb-[16px] flex-shrink-0'> <div className='mb-[16px] flex-shrink-0'>
<div className='grid grid-cols-1 gap-[16px] md:grid-cols-3'> <div className='grid grid-cols-1 gap-[16px] md:grid-cols-3'>
<GraphCardSkeleton title='Runs' /> <GraphCardSkeleton title='Runs' />
@@ -148,8 +112,6 @@ function DashboardSkeleton() {
<GraphCardSkeleton title='Latency' /> <GraphCardSkeleton title='Latency' />
</div> </div>
</div> </div>
{/* Workflows Table - takes remaining space */}
<div className='min-h-0 flex-1 overflow-hidden'> <div className='min-h-0 flex-1 overflow-hidden'>
<WorkflowsListSkeleton rowCount={14} /> <WorkflowsListSkeleton rowCount={14} />
</div> </div>
@@ -158,225 +120,237 @@ function DashboardSkeleton() {
} }
interface DashboardProps { interface DashboardProps {
isLive?: boolean logs: WorkflowLog[]
refreshTrigger?: number isLoading: boolean
onCustomTimeRangeChange?: (isCustom: boolean) => void error?: Error | null
} }
export default function Dashboard({ export default function Dashboard({ logs, isLoading, error }: DashboardProps) {
isLive = false, const [segmentCount, setSegmentCount] = useState<number>(DEFAULT_SEGMENTS)
refreshTrigger = 0,
onCustomTimeRangeChange,
}: DashboardProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const getTimeFilterFromRange = (range: string): TimeFilter => {
switch (range) {
case 'Past 30 minutes':
return '30m'
case 'Past hour':
return '1h'
case 'Past 6 hours':
return '6h'
case 'Past 12 hours':
return '12h'
case 'Past 24 hours':
return '24h'
case 'Past 3 days':
return '3d'
case 'Past 7 days':
return '7d'
case 'Past 14 days':
return '14d'
case 'Past 30 days':
return '30d'
default:
return '30d'
}
}
const [endTime, setEndTime] = useState<Date>(new Date())
const [expandedWorkflowId, setExpandedWorkflowId] = useState<string | null>(null)
const [selectedSegments, setSelectedSegments] = useState<Record<string, number[]>>({}) const [selectedSegments, setSelectedSegments] = useState<Record<string, number[]>>({})
const [lastAnchorIndices, setLastAnchorIndices] = useState<Record<string, number>>({}) const [lastAnchorIndices, setLastAnchorIndices] = useState<Record<string, number>>({})
const [segmentCount, setSegmentCount] = useState<number>(DEFAULT_SEGMENTS)
const barsAreaRef = useRef<HTMLDivElement | null>(null) const barsAreaRef = useRef<HTMLDivElement | null>(null)
const { const { workflowIds, searchQuery, toggleWorkflowId, timeRange } = useFilterStore()
workflowIds,
folderIds,
triggers,
timeRange: sidebarTimeRange,
level,
searchQuery,
} = useFilterStore()
const { workflows } = useWorkflowRegistry() const allWorkflows = useWorkflowRegistry((state) => state.workflows)
const timeFilter = getTimeFilterFromRange(sidebarTimeRange) const expandedWorkflowId = workflowIds.length === 1 ? workflowIds[0] : null
const getStartTime = useCallback(() => { const lastExecutionByWorkflow = useMemo(() => {
const start = new Date(endTime) const map = new Map<string, number>()
for (const log of logs) {
const wfId = log.workflowId
if (!wfId) continue
const ts = new Date(log.createdAt).getTime()
const existing = map.get(wfId)
if (!existing || ts > existing) {
map.set(wfId, ts)
}
}
return map
}, [logs])
switch (timeFilter) { const timeBounds = useMemo(() => {
case '30m': if (logs.length === 0) {
start.setMinutes(endTime.getMinutes() - 30) const now = new Date()
break return { start: now, end: now }
case '1h':
start.setHours(endTime.getHours() - 1)
break
case '6h':
start.setHours(endTime.getHours() - 6)
break
case '12h':
start.setHours(endTime.getHours() - 12)
break
case '24h':
start.setHours(endTime.getHours() - 24)
break
case '3d':
start.setDate(endTime.getDate() - 3)
break
case '7d':
start.setDate(endTime.getDate() - 7)
break
case '14d':
start.setDate(endTime.getDate() - 14)
break
case '30d':
start.setDate(endTime.getDate() - 30)
break
default:
start.setHours(endTime.getHours() - 24)
} }
return start let minTime = Number.POSITIVE_INFINITY
}, [endTime, timeFilter]) let maxTime = Number.NEGATIVE_INFINITY
const metricsFilters = useMemo( for (const log of logs) {
() => ({ const ts = new Date(log.createdAt).getTime()
workspaceId, if (ts < minTime) minTime = ts
segments: segmentCount || DEFAULT_SEGMENTS, if (ts > maxTime) maxTime = ts
startTime: getStartTime().toISOString(), }
endTime: endTime.toISOString(),
workflowIds: workflowIds.length > 0 ? workflowIds : undefined,
folderIds: folderIds.length > 0 ? folderIds : undefined,
triggers: triggers.length > 0 ? triggers : undefined,
level: level !== 'all' ? level : undefined,
}),
[workspaceId, segmentCount, getStartTime, endTime, workflowIds, folderIds, triggers, level]
)
const logsFilters = useMemo( const end = new Date(Math.max(maxTime, Date.now()))
() => ({ const start = new Date(minTime)
workspaceId,
startDate: getStartTime().toISOString(),
endDate: endTime.toISOString(),
workflowIds: workflowIds.length > 0 ? workflowIds : undefined,
folderIds: folderIds.length > 0 ? folderIds : undefined,
triggers: triggers.length > 0 ? triggers : undefined,
level: level !== 'all' ? level : undefined,
searchQuery: searchQuery.trim() || undefined,
limit: 50,
}),
[workspaceId, getStartTime, endTime, workflowIds, folderIds, triggers, level, searchQuery]
)
const metricsQuery = useExecutionsMetrics(metricsFilters, { return { start, end }
enabled: Boolean(workspaceId), }, [logs])
})
const globalLogsQuery = useGlobalDashboardLogs(logsFilters, { const { executions, aggregateSegments, segmentMs } = useMemo(() => {
enabled: Boolean(workspaceId), const allWorkflowsList = Object.values(allWorkflows)
})
const workflowLogsQuery = useWorkflowDashboardLogs(expandedWorkflowId ?? undefined, logsFilters, { if (allWorkflowsList.length === 0) {
enabled: Boolean(workspaceId) && Boolean(expandedWorkflowId), return { executions: [], aggregateSegments: [], segmentMs: 0 }
}) }
const executions = metricsQuery.data?.workflows ?? [] const { start, end } =
const aggregateSegments = metricsQuery.data?.aggregateSegments ?? [] logs.length > 0
const error = metricsQuery.error?.message ?? null ? timeBounds
: { start: new Date(Date.now() - 24 * 60 * 60 * 1000), end: new Date() }
/** const totalMs = Math.max(1, end.getTime() - start.getTime())
* Loading state logic using TanStack Query best practices: const calculatedSegmentMs = Math.max(
* - isPending: true when there's no cached data (initial load only) MIN_SEGMENT_MS,
* - isFetching: true when any fetch is in progress Math.floor(totalMs / Math.max(1, segmentCount))
* - isPlaceholderData: true when showing stale data from keepPreviousData )
*
* We only show skeleton on initial load (isPending + no data).
* For subsequent fetches, keepPreviousData shows stale content while fetching.
*/
const showSkeleton = metricsQuery.isPending && !metricsQuery.data
// Check if any filters are actually applied const logsByWorkflow = new Map<string, WorkflowLog[]>()
const hasActiveFilters = useMemo( for (const log of logs) {
() => const wfId = log.workflowId
level !== 'all' || if (!logsByWorkflow.has(wfId)) {
workflowIds.length > 0 || logsByWorkflow.set(wfId, [])
folderIds.length > 0 || }
triggers.length > 0 || logsByWorkflow.get(wfId)!.push(log)
searchQuery.trim() !== '', }
[level, workflowIds, folderIds, triggers, searchQuery]
)
// Filter workflows based on search query and whether they have any executions matching the filters const workflowExecutions: WorkflowExecution[] = []
const filteredExecutions = useMemo(() => {
let filtered = executions
// Only filter out workflows with no executions if filters are active for (const workflow of allWorkflowsList) {
if (hasActiveFilters) { const workflowLogs = logsByWorkflow.get(workflow.id) || []
filtered = filtered.filter((workflow) => {
const hasExecutions = workflow.segments.some((seg) => seg.hasExecutions === true) const segments: WorkflowExecution['segments'] = Array.from(
return hasExecutions { length: segmentCount },
(_, i) => ({
timestamp: new Date(start.getTime() + i * calculatedSegmentMs).toISOString(),
hasExecutions: false,
totalExecutions: 0,
successfulExecutions: 0,
successRate: 100,
avgDurationMs: 0,
})
)
const durations: number[][] = Array.from({ length: segmentCount }, () => [])
for (const log of workflowLogs) {
const logTime = new Date(log.createdAt).getTime()
const idx = Math.min(
segmentCount - 1,
Math.max(0, Math.floor((logTime - start.getTime()) / calculatedSegmentMs))
)
segments[idx].totalExecutions += 1
segments[idx].hasExecutions = true
if (log.level !== 'error') {
segments[idx].successfulExecutions += 1
}
const duration = parseDuration({ duration: log.duration ?? undefined })
if (duration !== null && duration > 0) {
durations[idx].push(duration)
}
}
let totalExecs = 0
let totalSuccess = 0
for (let i = 0; i < segmentCount; i++) {
const seg = segments[i]
totalExecs += seg.totalExecutions
totalSuccess += seg.successfulExecutions
if (seg.totalExecutions > 0) {
seg.successRate = (seg.successfulExecutions / seg.totalExecutions) * 100
}
if (durations[i].length > 0) {
seg.avgDurationMs = Math.round(
durations[i].reduce((sum, d) => sum + d, 0) / durations[i].length
)
}
}
const overallSuccessRate = totalExecs > 0 ? (totalSuccess / totalExecs) * 100 : 100
workflowExecutions.push({
workflowId: workflow.id,
workflowName: workflow.name,
segments,
overallSuccessRate,
}) })
} }
// Apply search query filter workflowExecutions.sort((a, b) => {
if (searchQuery.trim()) { const errA = a.overallSuccessRate < 100 ? 1 - a.overallSuccessRate / 100 : 0
const query = searchQuery.toLowerCase().trim() const errB = b.overallSuccessRate < 100 ? 1 - b.overallSuccessRate / 100 : 0
filtered = filtered.filter((workflow) => workflow.workflowName.toLowerCase().includes(query)) if (errA !== errB) return errB - errA
} return a.workflowName.localeCompare(b.workflowName)
// Sort by creation date (newest first) to match sidebar ordering
filtered = filtered.sort((a, b) => {
const workflowA = workflows[a.workflowId]
const workflowB = workflows[b.workflowId]
if (!workflowA || !workflowB) return 0
return workflowB.createdAt.getTime() - workflowA.createdAt.getTime()
}) })
return filtered const aggSegments: {
}, [executions, searchQuery, hasActiveFilters, workflows]) timestamp: string
totalExecutions: number
successfulExecutions: number
avgDurationMs: number
}[] = Array.from({ length: segmentCount }, (_, i) => ({
timestamp: new Date(start.getTime() + i * calculatedSegmentMs).toISOString(),
totalExecutions: 0,
successfulExecutions: 0,
avgDurationMs: 0,
}))
const globalLogs = useMemo(() => { const weightedDurationSums: number[] = Array(segmentCount).fill(0)
if (!globalLogsQuery.data?.pages) return [] const executionCounts: number[] = Array(segmentCount).fill(0)
return globalLogsQuery.data.pages.flatMap((page) => page.logs).map(mapToExecutionLog)
}, [globalLogsQuery.data?.pages])
const workflowLogs = useMemo(() => { for (const wf of workflowExecutions) {
if (!workflowLogsQuery.data?.pages) return [] wf.segments.forEach((s, i) => {
return workflowLogsQuery.data.pages.flatMap((page) => page.logs).map(mapToExecutionLogAlt) aggSegments[i].totalExecutions += s.totalExecutions
}, [workflowLogsQuery.data?.pages]) aggSegments[i].successfulExecutions += s.successfulExecutions
if (s.avgDurationMs && s.avgDurationMs > 0 && s.totalExecutions > 0) {
weightedDurationSums[i] += s.avgDurationMs * s.totalExecutions
executionCounts[i] += s.totalExecutions
}
})
}
aggSegments.forEach((seg, i) => {
if (executionCounts[i] > 0) {
seg.avgDurationMs = weightedDurationSums[i] / executionCounts[i]
}
})
return {
executions: workflowExecutions,
aggregateSegments: aggSegments,
segmentMs: calculatedSegmentMs,
}
}, [logs, timeBounds, segmentCount, allWorkflows])
const filteredExecutions = useMemo(() => {
let filtered = executions
if (workflowIds.length > 0) {
filtered = filtered.filter((wf) => workflowIds.includes(wf.workflowId))
}
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase().trim()
filtered = filtered.filter((wf) => wf.workflowName.toLowerCase().includes(query))
}
return filtered.slice().sort((a, b) => {
const timeA = lastExecutionByWorkflow.get(a.workflowId) ?? 0
const timeB = lastExecutionByWorkflow.get(b.workflowId) ?? 0
if (!timeA && !timeB) return a.workflowName.localeCompare(b.workflowName)
if (!timeA) return 1
if (!timeB) return -1
return timeB - timeA
})
}, [executions, lastExecutionByWorkflow, workflowIds, searchQuery])
const globalDetails = useMemo(() => { const globalDetails = useMemo(() => {
if (!aggregateSegments.length) return null if (!aggregateSegments.length) return null
const hasSelection = Object.keys(selectedSegments).length > 0 const hasSelection = Object.keys(selectedSegments).length > 0
const hasWorkflowFilter = expandedWorkflowId && expandedWorkflowId !== '__multi__' const hasWorkflowFilter = expandedWorkflowId !== null
// Stack filters: workflow filter + segment selection
const segmentsToUse = hasSelection const segmentsToUse = hasSelection
? (() => { ? (() => {
// Get all selected segment indices across all workflows
const allSelectedIndices = new Set<number>() const allSelectedIndices = new Set<number>()
Object.values(selectedSegments).forEach((indices) => { Object.values(selectedSegments).forEach((indices) => {
indices.forEach((idx) => allSelectedIndices.add(idx)) indices.forEach((idx) => allSelectedIndices.add(idx))
}) })
// For each selected index, aggregate data from workflows that have that segment selected
// If a workflow filter is active, only include that workflow's data
return Array.from(allSelectedIndices) return Array.from(allSelectedIndices)
.sort((a, b) => a - b) .sort((a, b) => a - b)
.map((idx) => { .map((idx) => {
@@ -386,11 +360,8 @@ export default function Dashboard({
let latencyCount = 0 let latencyCount = 0
const timestamp = aggregateSegments[idx]?.timestamp || '' const timestamp = aggregateSegments[idx]?.timestamp || ''
// Sum up data from workflows that have this segment selected
Object.entries(selectedSegments).forEach(([workflowId, indices]) => { Object.entries(selectedSegments).forEach(([workflowId, indices]) => {
if (!indices.includes(idx)) return if (!indices.includes(idx)) return
// If workflow filter is active, skip other workflows
if (hasWorkflowFilter && workflowId !== expandedWorkflowId) return if (hasWorkflowFilter && workflowId !== expandedWorkflowId) return
const workflow = filteredExecutions.find((w) => w.workflowId === workflowId) const workflow = filteredExecutions.find((w) => w.workflowId === workflowId)
@@ -416,7 +387,6 @@ export default function Dashboard({
})() })()
: hasWorkflowFilter : hasWorkflowFilter
? (() => { ? (() => {
// Filter to show only the expanded workflow's data
const workflow = filteredExecutions.find((w) => w.workflowId === expandedWorkflowId) const workflow = filteredExecutions.find((w) => w.workflowId === expandedWorkflowId)
if (!workflow) return aggregateSegments if (!workflow) return aggregateSegments
@@ -427,42 +397,7 @@ export default function Dashboard({
avgDurationMs: segment.avgDurationMs ?? 0, avgDurationMs: segment.avgDurationMs ?? 0,
})) }))
})() })()
: hasActiveFilters : aggregateSegments
? (() => {
// Always recalculate aggregate segments based on filtered workflows when filters are active
return aggregateSegments.map((aggSeg, idx) => {
let totalExecutions = 0
let successfulExecutions = 0
let weightedLatencySum = 0
let latencyCount = 0
filteredExecutions.forEach((workflow) => {
const segment = workflow.segments[idx]
if (!segment) return
totalExecutions += segment.totalExecutions || 0
successfulExecutions += segment.successfulExecutions || 0
if (segment.avgDurationMs && segment.totalExecutions) {
weightedLatencySum += segment.avgDurationMs * segment.totalExecutions
latencyCount += segment.totalExecutions
}
})
return {
timestamp: aggSeg.timestamp,
totalExecutions,
successfulExecutions,
avgDurationMs: latencyCount > 0 ? weightedLatencySum / latencyCount : 0,
}
})
})()
: aggregateSegments
const errorRates = segmentsToUse.map((s) => ({
timestamp: s.timestamp,
value: s.totalExecutions > 0 ? (1 - s.successfulExecutions / s.totalExecutions) * 100 : 0,
}))
const executionCounts = segmentsToUse.map((s) => ({ const executionCounts = segmentsToUse.map((s) => ({
timestamp: s.timestamp, timestamp: s.timestamp,
@@ -479,128 +414,46 @@ export default function Dashboard({
value: s.avgDurationMs ?? 0, value: s.avgDurationMs ?? 0,
})) }))
const totalRuns = segmentsToUse.reduce((sum, s) => sum + s.totalExecutions, 0)
const totalErrors = segmentsToUse.reduce(
(sum, s) => sum + (s.totalExecutions - s.successfulExecutions),
0
)
let weightedLatencySum = 0
let latencyCount = 0
for (const s of segmentsToUse) {
if (s.avgDurationMs && s.totalExecutions > 0) {
weightedLatencySum += s.avgDurationMs * s.totalExecutions
latencyCount += s.totalExecutions
}
}
const avgLatency = latencyCount > 0 ? weightedLatencySum / latencyCount : 0
return { return {
errorRates,
durations: [],
executionCounts, executionCounts,
failureCounts, failureCounts,
latencies, latencies,
logs: globalLogs, totalRuns,
allLogs: globalLogs, totalErrors,
}
}, [
aggregateSegments,
globalLogs,
selectedSegments,
filteredExecutions,
expandedWorkflowId,
hasActiveFilters,
])
const workflowDetails = useMemo(() => {
if (!expandedWorkflowId || !workflowLogs.length) return {}
return {
[expandedWorkflowId]: {
errorRates: [],
durations: [],
executionCounts: [],
logs: workflowLogs,
allLogs: workflowLogs,
},
}
}, [expandedWorkflowId, workflowLogs])
const aggregate = useMemo(() => {
const hasSelection = Object.keys(selectedSegments).length > 0
const hasWorkflowFilter = expandedWorkflowId && expandedWorkflowId !== '__multi__'
let totalExecutions = 0
let successfulExecutions = 0
let activeWorkflows = 0
let weightedLatencySum = 0
let latencyExecutionCount = 0
// Apply workflow filter first if present, otherwise use filtered executions
const workflowsToProcess = hasWorkflowFilter
? filteredExecutions.filter((wf) => wf.workflowId === expandedWorkflowId)
: filteredExecutions
for (const wf of workflowsToProcess) {
const selectedIndices = hasSelection ? selectedSegments[wf.workflowId] : null
let workflowHasExecutions = false
wf.segments.forEach((seg, idx) => {
// If segment selection exists, only count selected segments
// Otherwise, count all segments
if (!selectedIndices || selectedIndices.includes(idx)) {
const execCount = seg.totalExecutions || 0
totalExecutions += execCount
successfulExecutions += seg.successfulExecutions || 0
if (
seg.avgDurationMs !== undefined &&
seg.avgDurationMs !== null &&
seg.avgDurationMs > 0 &&
execCount > 0
) {
weightedLatencySum += seg.avgDurationMs * execCount
latencyExecutionCount += execCount
}
if (seg.hasExecutions) workflowHasExecutions = true
}
})
if (workflowHasExecutions) activeWorkflows += 1
}
const failedExecutions = Math.max(totalExecutions - successfulExecutions, 0)
const successRate = totalExecutions > 0 ? (successfulExecutions / totalExecutions) * 100 : 100
const avgLatency = latencyExecutionCount > 0 ? weightedLatencySum / latencyExecutionCount : 0
return {
totalExecutions,
successfulExecutions,
failedExecutions,
activeWorkflows,
successRate,
avgLatency, avgLatency,
} }
}, [filteredExecutions, selectedSegments, expandedWorkflowId]) }, [aggregateSegments, selectedSegments, filteredExecutions, expandedWorkflowId])
const loadMoreLogs = useCallback( const handleToggleWorkflow = useCallback(
(workflowId: string) => { (workflowId: string) => {
if ( toggleWorkflowId(workflowId)
workflowId === expandedWorkflowId &&
workflowLogsQuery.hasNextPage &&
!workflowLogsQuery.isFetchingNextPage
) {
workflowLogsQuery.fetchNextPage()
}
}, },
[expandedWorkflowId, workflowLogsQuery] [toggleWorkflowId]
)
const loadMoreGlobalLogs = useCallback(() => {
if (globalLogsQuery.hasNextPage && !globalLogsQuery.isFetchingNextPage) {
globalLogsQuery.fetchNextPage()
}
}, [globalLogsQuery])
const toggleWorkflow = useCallback(
(workflowId: string) => {
if (expandedWorkflowId === workflowId) {
setExpandedWorkflowId(null)
setSelectedSegments({})
setLastAnchorIndices({})
} else {
setExpandedWorkflowId(workflowId)
setSelectedSegments({})
setLastAnchorIndices({})
}
},
[expandedWorkflowId]
) )
/**
* Handles segment click for selecting time segments.
* @param workflowId - The workflow containing the segment
* @param segmentIndex - Index of the clicked segment
* @param _timestamp - Timestamp of the segment (unused)
* @param mode - Selection mode: 'single', 'toggle' (cmd+click), or 'range' (shift+click)
*/
const handleSegmentClick = useCallback( const handleSegmentClick = useCallback(
( (
workflowId: string, workflowId: string,
@@ -618,22 +471,10 @@ export default function Dashboard({
if (nextSegments.length === 0) { if (nextSegments.length === 0) {
const { [workflowId]: _, ...rest } = prev const { [workflowId]: _, ...rest } = prev
if (Object.keys(rest).length === 0) {
setExpandedWorkflowId(null)
}
return rest return rest
} }
const newState = { ...prev, [workflowId]: nextSegments } return { ...prev, [workflowId]: nextSegments }
const selectedWorkflowIds = Object.keys(newState)
if (selectedWorkflowIds.length > 1) {
setExpandedWorkflowId('__multi__')
} else if (selectedWorkflowIds.length === 1) {
setExpandedWorkflowId(selectedWorkflowIds[0])
}
return newState
}) })
setLastAnchorIndices((prev) => ({ ...prev, [workflowId]: segmentIndex })) setLastAnchorIndices((prev) => ({ ...prev, [workflowId]: segmentIndex }))
@@ -645,85 +486,32 @@ export default function Dashboard({
const isOnlyWorkflowSelected = Object.keys(prev).length === 1 && prev[workflowId] const isOnlyWorkflowSelected = Object.keys(prev).length === 1 && prev[workflowId]
if (isOnlySelectedSegment && isOnlyWorkflowSelected) { if (isOnlySelectedSegment && isOnlyWorkflowSelected) {
setExpandedWorkflowId(null)
setLastAnchorIndices({}) setLastAnchorIndices({})
return {} return {}
} }
setExpandedWorkflowId(workflowId)
setLastAnchorIndices({ [workflowId]: segmentIndex }) setLastAnchorIndices({ [workflowId]: segmentIndex })
return { [workflowId]: [segmentIndex] } return { [workflowId]: [segmentIndex] }
}) })
} else if (mode === 'range') { } else if (mode === 'range') {
if (expandedWorkflowId === workflowId) { setSelectedSegments((prev) => {
setSelectedSegments((prev) => { const currentSegments = prev[workflowId] || []
const currentSegments = prev[workflowId] || [] const anchor = lastAnchorIndices[workflowId] ?? segmentIndex
const anchor = lastAnchorIndices[workflowId] ?? segmentIndex const [start, end] =
const [start, end] = anchor < segmentIndex ? [anchor, segmentIndex] : [segmentIndex, anchor]
anchor < segmentIndex ? [anchor, segmentIndex] : [segmentIndex, anchor] const range = Array.from({ length: end - start + 1 }, (_, i) => start + i)
const range = Array.from({ length: end - start + 1 }, (_, i) => start + i) const union = new Set([...currentSegments, ...range])
const union = new Set([...currentSegments, ...range]) return { ...prev, [workflowId]: Array.from(union).sort((a, b) => a - b) }
return { ...prev, [workflowId]: Array.from(union).sort((a, b) => a - b) } })
})
} else {
setExpandedWorkflowId(workflowId)
setSelectedSegments({ [workflowId]: [segmentIndex] })
setLastAnchorIndices({ [workflowId]: segmentIndex })
}
} }
}, },
[expandedWorkflowId, lastAnchorIndices] [lastAnchorIndices]
) )
// Update endTime when filters change to ensure consistent time ranges with logs view
useEffect(() => { useEffect(() => {
setEndTime(new Date())
setSelectedSegments({}) setSelectedSegments({})
setLastAnchorIndices({}) setLastAnchorIndices({})
}, [timeFilter, workflowIds, folderIds, triggers, level, searchQuery]) }, [logs, timeRange, workflowIds, searchQuery])
// Clear expanded workflow if it's no longer in filtered executions
useEffect(() => {
if (expandedWorkflowId && expandedWorkflowId !== '__multi__') {
const isStillVisible = filteredExecutions.some((wf) => wf.workflowId === expandedWorkflowId)
if (!isStillVisible) {
setExpandedWorkflowId(null)
setSelectedSegments({})
setLastAnchorIndices({})
}
} else if (expandedWorkflowId === '__multi__') {
// Check if any of the selected workflows are still visible
const selectedWorkflowIds = Object.keys(selectedSegments)
const stillVisibleIds = selectedWorkflowIds.filter((id) =>
filteredExecutions.some((wf) => wf.workflowId === id)
)
if (stillVisibleIds.length === 0) {
setExpandedWorkflowId(null)
setSelectedSegments({})
setLastAnchorIndices({})
} else if (stillVisibleIds.length !== selectedWorkflowIds.length) {
// Remove segments for workflows that are no longer visible
const updatedSegments: Record<string, number[]> = {}
stillVisibleIds.forEach((id) => {
if (selectedSegments[id]) {
updatedSegments[id] = selectedSegments[id]
}
})
setSelectedSegments(updatedSegments)
if (stillVisibleIds.length === 1) {
setExpandedWorkflowId(stillVisibleIds[0])
}
}
}
}, [filteredExecutions, expandedWorkflowId, selectedSegments])
// Notify parent when custom time range is active
useEffect(() => {
const hasCustomRange = Object.keys(selectedSegments).length > 0
onCustomTimeRangeChange?.(hasCustomRange)
}, [selectedSegments, onCustomTimeRangeChange])
useEffect(() => { useEffect(() => {
if (!barsAreaRef.current) return if (!barsAreaRef.current) return
@@ -749,43 +537,30 @@ export default function Dashboard({
} }
}, []) }, [])
// Live mode: refresh endTime periodically if (isLoading && Object.keys(allWorkflows).length === 0) {
useEffect(() => {
if (!isLive) return
const interval = setInterval(() => {
setEndTime(new Date())
}, 5000)
return () => clearInterval(interval)
}, [isLive])
// Refresh when trigger changes
useEffect(() => {
if (refreshTrigger > 0) {
setEndTime(new Date())
}
}, [refreshTrigger])
if (showSkeleton) {
return <DashboardSkeleton /> return <DashboardSkeleton />
} }
// Show error state
if (error) { if (error) {
return ( return (
<div className='mt-[24px] flex flex-1 items-center justify-center'> <div className='mt-[24px] flex flex-1 items-center justify-center'>
<div className='text-[var(--text-error)]'> <div className='text-[var(--text-error)]'>
<p className='font-medium text-[13px]'>Error loading data</p> <p className='font-medium text-[13px]'>Error loading data</p>
<p className='text-[12px]'>{error}</p> <p className='text-[12px]'>{error.message}</p>
</div> </div>
</div> </div>
) )
} }
if (executions.length === 0) { if (Object.keys(allWorkflows).length === 0) {
return ( return (
<div className='mt-[24px] flex flex-1 items-center justify-center'> <div className='mt-[24px] flex flex-1 items-center justify-center'>
<div className='text-center text-[var(--text-secondary)]'> <div className='text-center text-[var(--text-secondary)]'>
<p className='font-medium text-[13px]'>No execution history</p> <p className='font-medium text-[13px]'>No workflows</p>
<p className='mt-[4px] text-[12px]'>Execute some workflows to see their history here</p> <p className='mt-[4px] text-[12px]'>
Create a workflow to see its execution history here
</p>
</div> </div>
</div> </div>
) )
@@ -793,18 +568,16 @@ export default function Dashboard({
return ( return (
<div className='mt-[24px] flex min-h-0 flex-1 flex-col pb-[24px]'> <div className='mt-[24px] flex min-h-0 flex-1 flex-col pb-[24px]'>
{/* Graphs Section */}
<div className='mb-[16px] flex-shrink-0'> <div className='mb-[16px] flex-shrink-0'>
<div className='grid grid-cols-1 gap-[16px] md:grid-cols-3'> <div className='grid grid-cols-1 gap-[16px] md:grid-cols-3'>
{/* Runs Graph */}
<div className='flex flex-col overflow-hidden rounded-[6px] bg-[var(--surface-elevated)]'> <div className='flex flex-col overflow-hidden rounded-[6px] bg-[var(--surface-elevated)]'>
<div className='flex min-w-0 items-center justify-between gap-[8px] bg-[var(--surface-3)] px-[16px] py-[9px]'> <div className='flex min-w-0 items-center justify-between gap-[8px] bg-[var(--surface-3)] px-[16px] py-[9px]'>
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-sm'> <span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-sm'>
Runs Runs
</span> </span>
{globalDetails && globalDetails.executionCounts.length > 0 && ( {globalDetails && (
<span className='flex-shrink-0 font-medium text-[var(--text-secondary)] text-sm'> <span className='flex-shrink-0 font-medium text-[var(--text-secondary)] text-sm'>
{aggregate.totalExecutions} {globalDetails.totalRuns}
</span> </span>
)} )}
</div> </div>
@@ -824,15 +597,14 @@ export default function Dashboard({
</div> </div>
</div> </div>
{/* Errors Graph */}
<div className='flex flex-col overflow-hidden rounded-[6px] bg-[var(--surface-elevated)]'> <div className='flex flex-col overflow-hidden rounded-[6px] bg-[var(--surface-elevated)]'>
<div className='flex min-w-0 items-center justify-between gap-[8px] bg-[var(--surface-3)] px-[16px] py-[9px]'> <div className='flex min-w-0 items-center justify-between gap-[8px] bg-[var(--surface-3)] px-[16px] py-[9px]'>
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-sm'> <span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-sm'>
Errors Errors
</span> </span>
{globalDetails && globalDetails.failureCounts.length > 0 && ( {globalDetails && (
<span className='flex-shrink-0 font-medium text-[var(--text-secondary)] text-sm'> <span className='flex-shrink-0 font-medium text-[var(--text-secondary)] text-sm'>
{aggregate.failedExecutions} {globalDetails.totalErrors}
</span> </span>
)} )}
</div> </div>
@@ -852,15 +624,14 @@ export default function Dashboard({
</div> </div>
</div> </div>
{/* Latency Graph */}
<div className='flex flex-col overflow-hidden rounded-[6px] bg-[var(--surface-elevated)]'> <div className='flex flex-col overflow-hidden rounded-[6px] bg-[var(--surface-elevated)]'>
<div className='flex min-w-0 items-center justify-between gap-[8px] bg-[var(--surface-3)] px-[16px] py-[9px]'> <div className='flex min-w-0 items-center justify-between gap-[8px] bg-[var(--surface-3)] px-[16px] py-[9px]'>
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-sm'> <span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-sm'>
Latency Latency
</span> </span>
{globalDetails && globalDetails.latencies.length > 0 && ( {globalDetails && (
<span className='flex-shrink-0 font-medium text-[var(--text-secondary)] text-sm'> <span className='flex-shrink-0 font-medium text-[var(--text-secondary)] text-sm'>
{formatLatency(aggregate.avgLatency)} {formatLatency(globalDetails.avgLatency)}
</span> </span>
)} )}
</div> </div>
@@ -882,19 +653,15 @@ export default function Dashboard({
</div> </div>
</div> </div>
{/* Workflows Table - takes remaining space */}
<div className='min-h-0 flex-1 overflow-hidden' ref={barsAreaRef}> <div className='min-h-0 flex-1 overflow-hidden' ref={barsAreaRef}>
<WorkflowsList <WorkflowsList
executions={executions as WorkflowExecution[]}
filteredExecutions={filteredExecutions as WorkflowExecution[]} filteredExecutions={filteredExecutions as WorkflowExecution[]}
expandedWorkflowId={expandedWorkflowId} expandedWorkflowId={expandedWorkflowId}
onToggleWorkflow={toggleWorkflow} onToggleWorkflow={handleToggleWorkflow}
selectedSegments={selectedSegments} selectedSegments={selectedSegments}
onSegmentClick={handleSegmentClick} onSegmentClick={handleSegmentClick}
searchQuery={searchQuery} searchQuery={searchQuery}
segmentDurationMs={ segmentDurationMs={segmentMs}
(endTime.getTime() - getStartTime().getTime()) / Math.max(1, segmentCount)
}
/> />
</div> </div>
</div> </div>

View File

@@ -3,6 +3,7 @@ export { LogDetails } from './log-details'
export { FileCards } from './log-details/components/file-download' export { FileCards } from './log-details/components/file-download'
export { FrozenCanvas } from './log-details/components/frozen-canvas' export { FrozenCanvas } from './log-details/components/frozen-canvas'
export { TraceSpans } from './log-details/components/trace-spans' export { TraceSpans } from './log-details/components/trace-spans'
export { LogsList } from './logs-list'
export { export {
AutocompleteSearch, AutocompleteSearch,
Controls, Controls,

View File

@@ -34,9 +34,6 @@ interface FileCardProps {
workspaceId?: string workspaceId?: string
} }
/**
* Formats file size to human readable format
*/
function formatFileSize(bytes: number): string { function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B' if (bytes === 0) return '0 B'
const k = 1024 const k = 1024
@@ -45,9 +42,6 @@ function formatFileSize(bytes: number): string {
return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}` return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`
} }
/**
* Individual file card component
*/
function FileCard({ file, isExecutionFile = false, workspaceId }: FileCardProps) { function FileCard({ file, isExecutionFile = false, workspaceId }: FileCardProps) {
const [isDownloading, setIsDownloading] = useState(false) const [isDownloading, setIsDownloading] = useState(false)
const router = useRouter() const router = useRouter()
@@ -142,10 +136,6 @@ function FileCard({ file, isExecutionFile = false, workspaceId }: FileCardProps)
) )
} }
/**
* Container component for displaying workflow execution files.
* Each file is displayed as a separate card with consistent styling.
*/
export function FileCards({ files, isExecutionFile = false, workspaceId }: FileCardsProps) { export function FileCards({ files, isExecutionFile = false, workspaceId }: FileCardsProps) {
if (!files || files.length === 0) { if (!files || files.length === 0) {
return null return null
@@ -170,9 +160,6 @@ export function FileCards({ files, isExecutionFile = false, workspaceId }: FileC
) )
} }
/**
* Single file download button (legacy export for backwards compatibility)
*/
export function FileDownload({ export function FileDownload({
file, file,
isExecutionFile = false, isExecutionFile = false,

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import type React from 'react' import type React from 'react'
import { useCallback, useMemo, useState } from 'react' import { memo, useCallback, useMemo, useState } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import { ChevronDown, Code } from '@/components/emcn' import { ChevronDown, Code } from '@/components/emcn'
import { WorkflowIcon } from '@/components/icons' import { WorkflowIcon } from '@/components/icons'
@@ -531,9 +531,10 @@ interface TraceSpanItemProps {
} }
/** /**
* Individual trace span card component * Individual trace span card component.
* Memoized to prevent re-renders when sibling spans change.
*/ */
function TraceSpanItem({ const TraceSpanItem = memo(function TraceSpanItem({
span, span,
totalDuration, totalDuration,
workflowStartTime, workflowStartTime,
@@ -779,12 +780,16 @@ function TraceSpanItem({
))} ))}
</> </>
) )
} })
/** /**
* Displays workflow execution trace spans with nested structure * Displays workflow execution trace spans with nested structure.
* Memoized to prevent re-renders when parent LogDetails updates.
*/ */
export function TraceSpans({ traceSpans, totalDuration = 0 }: TraceSpansProps) { export const TraceSpans = memo(function TraceSpans({
traceSpans,
totalDuration = 0,
}: TraceSpansProps) {
const { workflowStartTime, actualTotalDuration, normalizedSpans } = useMemo(() => { const { workflowStartTime, actualTotalDuration, normalizedSpans } = useMemo(() => {
if (!traceSpans || traceSpans.length === 0) { if (!traceSpans || traceSpans.length === 0) {
return { workflowStartTime: 0, actualTotalDuration: totalDuration, normalizedSpans: [] } return { workflowStartTime: 0, actualTotalDuration: totalDuration, normalizedSpans: [] }
@@ -827,4 +832,4 @@ export function TraceSpans({ traceSpans, totalDuration = 0 }: TraceSpansProps) {
</div> </div>
</div> </div>
) )
} })

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useEffect, useMemo, useRef, useState } from 'react' import { memo, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronUp, X } from 'lucide-react' import { ChevronUp, X } from 'lucide-react'
import { Button, Eye } from '@/components/emcn' import { Button, Eye } from '@/components/emcn'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
@@ -36,7 +36,7 @@ interface LogDetailsProps {
* @param props - Component props * @param props - Component props
* @returns Log details sidebar component * @returns Log details sidebar component
*/ */
export function LogDetails({ export const LogDetails = memo(function LogDetails({
log, log,
isOpen, isOpen,
onClose, onClose,
@@ -95,7 +95,10 @@ export function LogDetails({
navigateFunction() navigateFunction()
} }
const formattedTimestamp = log ? formatDate(log.createdAt) : null const formattedTimestamp = useMemo(
() => (log ? formatDate(log.createdAt) : null),
[log?.createdAt]
)
const logStatus: LogStatus = useMemo(() => { const logStatus: LogStatus = useMemo(() => {
if (!log) return 'info' if (!log) return 'info'
@@ -140,7 +143,7 @@ export function LogDetails({
disabled={!hasPrev} disabled={!hasPrev}
aria-label='Previous log' aria-label='Previous log'
> >
<ChevronUp className='h-[14px] w-[14px] rotate-180' /> <ChevronUp className='h-[14px] w-[14px]' />
</Button> </Button>
<Button <Button
variant='ghost' variant='ghost'
@@ -149,7 +152,7 @@ export function LogDetails({
disabled={!hasNext} disabled={!hasNext}
aria-label='Next log' aria-label='Next log'
> >
<ChevronUp className='h-[14px] w-[14px]' /> <ChevronUp className='h-[14px] w-[14px] rotate-180' />
</Button> </Button>
<Button variant='ghost' className='!p-[4px]' onClick={onClose} aria-label='Close'> <Button variant='ghost' className='!p-[4px]' onClick={onClose} aria-label='Close'>
<X className='h-[14px] w-[14px]' /> <X className='h-[14px] w-[14px]' />
@@ -374,4 +377,4 @@ export function LogDetails({
</div> </div>
</> </>
) )
} })

View File

@@ -0,0 +1 @@
export { LogsList, type LogsListProps } from './logs-list'

View File

@@ -0,0 +1,273 @@
'use client'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ArrowUpRight, Loader2 } from 'lucide-react'
import Link from 'next/link'
import { List, type RowComponentProps, useListRef } from 'react-window'
import { Badge, buttonVariants } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { WorkflowLog } from '@/stores/logs/filters/types'
import { formatDate, formatDuration, StatusBadge, TriggerBadge } from '../../utils'
const LOG_ROW_HEIGHT = 44 as const
interface LogRowProps {
log: WorkflowLog
isSelected: boolean
onClick: (log: WorkflowLog) => void
selectedRowRef: React.RefObject<HTMLTableRowElement | null> | null
}
/**
* Memoized log row component to prevent unnecessary re-renders.
* Uses shallow comparison for the log object.
*/
const LogRow = memo(
function LogRow({ log, isSelected, onClick, selectedRowRef }: LogRowProps) {
const formattedDate = useMemo(() => formatDate(log.createdAt), [log.createdAt])
const baseLevel = (log.level || 'info').toLowerCase()
const isError = baseLevel === 'error'
const isPending = !isError && log.hasPendingPause === true
const isRunning = !isError && !isPending && log.duration === null
const handleClick = useCallback(() => onClick(log), [onClick, log])
return (
<div
ref={isSelected ? selectedRowRef : null}
className={cn(
'relative flex h-[44px] cursor-pointer items-center px-[24px] hover:bg-[var(--c-2A2A2A)]',
isSelected && 'bg-[var(--c-2A2A2A)]'
)}
onClick={handleClick}
>
<div className='flex flex-1 items-center'>
{/* Date */}
<span className='w-[8%] min-w-[70px] font-medium text-[12px] text-[var(--text-primary)]'>
{formattedDate.compactDate}
</span>
{/* Time */}
<span className='w-[12%] min-w-[90px] font-medium text-[12px] text-[var(--text-primary)]'>
{formattedDate.compactTime}
</span>
{/* Status */}
<div className='w-[12%] min-w-[100px]'>
<StatusBadge
status={isError ? 'error' : isPending ? 'pending' : isRunning ? 'running' : 'info'}
/>
</div>
{/* Workflow */}
<div className='flex w-[22%] min-w-[140px] items-center gap-[8px] pr-[8px]'>
<div
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
style={{ backgroundColor: log.workflow?.color }}
/>
<span className='min-w-0 truncate font-medium text-[12px] text-[var(--text-primary)]'>
{log.workflow?.name || 'Unknown'}
</span>
</div>
{/* Cost */}
<span className='w-[12%] min-w-[90px] font-medium text-[12px] text-[var(--text-primary)]'>
{typeof log.cost?.total === 'number' ? `$${log.cost.total.toFixed(4)}` : '—'}
</span>
{/* Trigger */}
<div className='w-[14%] min-w-[110px]'>
{log.trigger ? (
<TriggerBadge trigger={log.trigger} />
) : (
<span className='font-medium text-[12px] text-[var(--text-primary)]'></span>
)}
</div>
{/* Duration */}
<div className='w-[20%] min-w-[100px]'>
<Badge variant='default' className='rounded-[6px] px-[9px] py-[2px] text-[12px]'>
{formatDuration(log.duration) || '—'}
</Badge>
</div>
</div>
{/* Resume Link */}
{isPending && log.executionId && (log.workflow?.id || log.workflowId) && (
<Link
href={`/resume/${log.workflow?.id || log.workflowId}/${log.executionId}`}
target='_blank'
rel='noopener noreferrer'
className={cn(
buttonVariants({ variant: 'active' }),
'absolute right-[24px] h-[26px] w-[26px] rounded-[6px] p-0'
)}
aria-label='Open resume console'
onClick={(e) => e.stopPropagation()}
>
<ArrowUpRight className='h-[14px] w-[14px]' />
</Link>
)}
</div>
)
},
(prevProps, nextProps) => {
return (
prevProps.log.id === nextProps.log.id &&
prevProps.log.duration === nextProps.log.duration &&
prevProps.log.level === nextProps.log.level &&
prevProps.log.hasPendingPause === nextProps.log.hasPendingPause &&
prevProps.isSelected === nextProps.isSelected
)
}
)
interface RowProps {
logs: WorkflowLog[]
selectedLogId: string | null
onLogClick: (log: WorkflowLog) => void
selectedRowRef: React.RefObject<HTMLTableRowElement | null>
isFetchingNextPage: boolean
loaderRef: React.RefObject<HTMLDivElement | null>
}
/**
* Row component for the virtualized list.
* Receives row-specific props via rowProps.
*/
function Row({
index,
style,
logs,
selectedLogId,
onLogClick,
selectedRowRef,
isFetchingNextPage,
loaderRef,
}: RowComponentProps<RowProps>) {
// Show loader for the last item if loading more
if (index >= logs.length) {
return (
<div style={style} className='flex items-center justify-center'>
<div ref={loaderRef} className='flex items-center gap-[8px] text-[var(--text-secondary)]'>
{isFetchingNextPage ? (
<>
<Loader2 className='h-[16px] w-[16px] animate-spin' />
<span className='text-[13px]'>Loading more...</span>
</>
) : (
<span className='text-[13px]'>Scroll to load more</span>
)}
</div>
</div>
)
}
const log = logs[index]
const isSelected = selectedLogId === log.id
return (
<div style={style}>
<LogRow
log={log}
isSelected={isSelected}
onClick={onLogClick}
selectedRowRef={isSelected ? selectedRowRef : null}
/>
</div>
)
}
export interface LogsListProps {
logs: WorkflowLog[]
selectedLogId: string | null
onLogClick: (log: WorkflowLog) => void
selectedRowRef: React.RefObject<HTMLTableRowElement | null>
hasNextPage: boolean
isFetchingNextPage: boolean
onLoadMore: () => void
loaderRef: React.RefObject<HTMLDivElement | null>
}
/**
* Virtualized logs list using react-window for optimal performance.
* Renders only visible rows, enabling smooth scrolling with large datasets.
* @param props - Component props
* @returns The virtualized logs list
*/
export function LogsList({
logs,
selectedLogId,
onLogClick,
selectedRowRef,
hasNextPage,
isFetchingNextPage,
onLoadMore,
loaderRef,
}: LogsListProps) {
const listRef = useListRef(null)
const containerRef = useRef<HTMLDivElement>(null)
const [listHeight, setListHeight] = useState(400)
// Measure container height for virtualization
useEffect(() => {
const container = containerRef.current
if (!container) return
const updateHeight = () => {
const rect = container.getBoundingClientRect()
if (rect.height > 0) {
setListHeight(rect.height)
}
}
updateHeight()
const ro = new ResizeObserver(updateHeight)
ro.observe(container)
return () => ro.disconnect()
}, [])
// Handle infinite scroll when nearing the end of the list
const handleRowsRendered = useCallback(
({ stopIndex }: { startIndex: number; stopIndex: number }) => {
const threshold = logs.length - 10
if (stopIndex >= threshold && hasNextPage && !isFetchingNextPage) {
onLoadMore()
}
},
[logs.length, hasNextPage, isFetchingNextPage, onLoadMore]
)
// Calculate total item count including loader row
const itemCount = hasNextPage ? logs.length + 1 : logs.length
// Row props passed to each row component
const rowProps = useMemo<RowProps>(
() => ({
logs,
selectedLogId,
onLogClick,
selectedRowRef,
isFetchingNextPage,
loaderRef,
}),
[logs, selectedLogId, onLogClick, selectedRowRef, isFetchingNextPage, loaderRef]
)
return (
<div ref={containerRef} className='h-full w-full'>
<List
listRef={listRef}
defaultHeight={listHeight}
rowCount={itemCount}
rowHeight={LOG_ROW_HEIGHT}
rowComponent={Row}
rowProps={rowProps}
overscanCount={5}
onRowsRendered={handleRowsRendered}
/>
</div>
)
}
export default LogsList

View File

@@ -87,6 +87,7 @@ export function AutocompleteSearch({
suggestions, suggestions,
sections, sections,
highlightedIndex, highlightedIndex,
highlightedBadgeIndex,
inputRef, inputRef,
dropdownRef, dropdownRef,
handleInputChange, handleInputChange,
@@ -162,7 +163,7 @@ export function AutocompleteSearch({
}} }}
> >
<PopoverAnchor asChild> <PopoverAnchor asChild>
<div className='relative flex h-[32px] w-[400px] items-center rounded-[8px] bg-[var(--surface-5)]'> <div className='relative flex h-[32px] w-full items-center rounded-[8px] bg-[var(--surface-5)]'>
{/* Search Icon */} {/* Search Icon */}
<Search className='mr-[6px] ml-[8px] h-[14px] w-[14px] flex-shrink-0 text-[var(--text-subtle)]' /> <Search className='mr-[6px] ml-[8px] h-[14px] w-[14px] flex-shrink-0 text-[var(--text-subtle)]' />
@@ -175,7 +176,11 @@ export function AutocompleteSearch({
variant='outline' variant='outline'
role='button' role='button'
tabIndex={0} tabIndex={0}
className='h-6 shrink-0 cursor-pointer whitespace-nowrap rounded-md px-2 text-[11px]' className={cn(
'h-6 shrink-0 cursor-pointer whitespace-nowrap rounded-md px-2 text-[11px]',
highlightedBadgeIndex === index &&
'ring-1 ring-[var(--border-focus)] ring-offset-1 ring-offset-[var(--surface-5)]'
)}
onClick={() => removeBadge(index)} onClick={() => removeBadge(index)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {

View File

@@ -1,24 +1,25 @@
'use client' 'use client'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useMemo } from 'react'
import { ArrowUp, Bell, Library, Loader2, MoreHorizontal, RefreshCw } from 'lucide-react' import { ArrowUp, Bell, Library, MoreHorizontal, RefreshCw } from 'lucide-react'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { import {
Button, Button,
Combobox, Combobox,
type ComboboxOption, type ComboboxOption,
Loader,
Popover, Popover,
PopoverContent, PopoverContent,
PopoverItem, PopoverItem,
PopoverScrollArea, PopoverScrollArea,
PopoverTrigger, PopoverTrigger,
Tooltip,
} from '@/components/emcn' } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { getTriggerOptions } from '@/lib/logs/get-trigger-options' import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
import { getBlock } from '@/blocks/registry' import { getBlock } from '@/blocks/registry'
import { useFolderStore } from '@/stores/folders/store' import { useFolderStore } from '@/stores/folders/store'
import { useFilterStore } from '@/stores/logs/filters/store' import { useFilterStore } from '@/stores/logs/filters/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { AutocompleteSearch } from './components/search' import { AutocompleteSearch } from './components/search'
const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook'] as const const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook'] as const
@@ -155,22 +156,15 @@ export function LogsToolbar({
} = useFilterStore() } = useFilterStore()
const folders = useFolderStore((state) => state.folders) const folders = useFolderStore((state) => state.folders)
const [workflows, setWorkflows] = useState<Array<{ id: string; name: string; color: string }>>([]) const allWorkflows = useWorkflowRegistry((state) => state.workflows)
useEffect(() => { const workflows = useMemo(() => {
const fetchWorkflows = async () => { return Object.values(allWorkflows).map((w) => ({
try { id: w.id,
const res = await fetch(`/api/workflows?workspaceId=${encodeURIComponent(workspaceId)}`) name: w.name,
if (res.ok) { color: w.color,
const body = await res.json() }))
setWorkflows(Array.isArray(body?.data) ? body.data : []) }, [allWorkflows])
}
} catch {
setWorkflows([])
}
}
if (workspaceId) fetchWorkflows()
}, [workspaceId])
const folderList = useMemo(() => { const folderList = useMemo(() => {
return Object.values(folders).filter((f) => f.workspaceId === workspaceId) return Object.values(folders).filter((f) => f.workspaceId === workspaceId)
@@ -178,7 +172,6 @@ export function LogsToolbar({
const isDashboardView = viewMode === 'dashboard' const isDashboardView = viewMode === 'dashboard'
// Status filter
const selectedStatuses = useMemo((): string[] => { const selectedStatuses = useMemo((): string[] => {
if (level === 'all' || !level) return [] if (level === 'all' || !level) return []
return level.split(',').filter(Boolean) return level.split(',').filter(Boolean)
@@ -199,7 +192,7 @@ export function LogsToolbar({
if (values.length === 0) { if (values.length === 0) {
setLevel('all') setLevel('all')
} else { } else {
setLevel(values.join(',') as any) setLevel(values.join(','))
} }
}, },
[setLevel] [setLevel]
@@ -224,7 +217,6 @@ export function LogsToolbar({
return null return null
}, [selectedStatuses]) }, [selectedStatuses])
// Workflow filter
const workflowOptions: ComboboxOption[] = useMemo( const workflowOptions: ComboboxOption[] = useMemo(
() => workflows.map((w) => ({ value: w.id, label: w.name, icon: getColorIcon(w.color) })), () => workflows.map((w) => ({ value: w.id, label: w.name, icon: getColorIcon(w.color) })),
[workflows] [workflows]
@@ -242,7 +234,6 @@ export function LogsToolbar({
const selectedWorkflow = const selectedWorkflow =
workflowIds.length === 1 ? workflows.find((w) => w.id === workflowIds[0]) : null workflowIds.length === 1 ? workflows.find((w) => w.id === workflowIds[0]) : null
// Folder filter
const folderOptions: ComboboxOption[] = useMemo( const folderOptions: ComboboxOption[] = useMemo(
() => folderList.map((f) => ({ value: f.id, label: f.name })), () => folderList.map((f) => ({ value: f.id, label: f.name })),
[folderList] [folderList]
@@ -257,7 +248,6 @@ export function LogsToolbar({
return `${folderIds.length} folders` return `${folderIds.length} folders`
}, [folderIds, folderList]) }, [folderIds, folderList])
// Trigger filter
const triggerOptions: ComboboxOption[] = useMemo( const triggerOptions: ComboboxOption[] = useMemo(
() => () =>
getTriggerOptions().map((t) => ({ getTriggerOptions().map((t) => ({
@@ -282,6 +272,24 @@ export function LogsToolbar({
return timeRange return timeRange
}, [timeRange]) }, [timeRange])
const hasActiveFilters = useMemo(() => {
return (
level !== 'all' ||
workflowIds.length > 0 ||
folderIds.length > 0 ||
triggers.length > 0 ||
timeRange !== 'All time'
)
}, [level, workflowIds, folderIds, triggers, timeRange])
const handleClearFilters = useCallback(() => {
setLevel('all')
setWorkflowIds([])
setFolderIds([])
setTriggers([])
setTimeRange('All time')
}, [setLevel, setWorkflowIds, setFolderIds, setTriggers, setTimeRange])
return ( return (
<div className='flex flex-col gap-[19px]'> <div className='flex flex-col gap-[19px]'>
{/* Header Section */} {/* Header Section */}
@@ -316,22 +324,18 @@ export function LogsToolbar({
</Popover> </Popover>
{/* Refresh button */} {/* Refresh button */}
<Tooltip.Root> <Button
<Tooltip.Trigger asChild> variant='default'
<Button className='h-[32px] rounded-[6px] px-[10px]'
variant='default' onClick={isRefreshing ? undefined : onRefresh}
className={cn('h-[32px] w-[32px] rounded-[6px] p-0', isRefreshing && 'opacity-50')} disabled={isRefreshing}
onClick={isRefreshing ? undefined : onRefresh} >
> {isRefreshing ? (
{isRefreshing ? ( <Loader className='h-[14px] w-[14px]' animate />
<Loader2 className='h-[14px] w-[14px] animate-spin' /> ) : (
) : ( <RefreshCw className='h-[14px] w-[14px]' />
<RefreshCw className='h-[14px] w-[14px]' /> )}
)} </Button>
</Button>
</Tooltip.Trigger>
<Tooltip.Content>{isRefreshing ? 'Refreshing...' : 'Refresh'}</Tooltip.Content>
</Tooltip.Root>
{/* Live button */} {/* Live button */}
<Button <Button
@@ -365,7 +369,7 @@ export function LogsToolbar({
{/* Filter Bar Section */} {/* Filter Bar Section */}
<div className='flex w-full items-center gap-[12px]'> <div className='flex w-full items-center gap-[12px]'>
<div className='min-w-0 flex-1'> <div className='min-w-[200px] max-w-[400px] flex-1'>
<AutocompleteSearch <AutocompleteSearch
value={searchQuery} value={searchQuery}
onChange={onSearchQueryChange} onChange={onSearchQueryChange}
@@ -373,110 +377,269 @@ export function LogsToolbar({
onOpenChange={onSearchOpenChange} onOpenChange={onSearchOpenChange}
/> />
</div> </div>
<div className='flex items-center gap-[8px]'> <div className='ml-auto flex items-center gap-[8px]'>
{/* Status Filter */} {/* Clear Filters Button */}
<Combobox {hasActiveFilters && (
options={statusOptions} <Button
multiSelect variant='active'
multiSelectValues={selectedStatuses} onClick={handleClearFilters}
onMultiSelectChange={handleStatusChange} className='h-[32px] rounded-[6px] px-[10px]'
placeholder='Status' >
overlayContent={ <span>Clear</span>
<span className='flex items-center gap-[6px] truncate text-[var(--text-primary)]'> </Button>
{selectedStatusColor && ( )}
<div
className='flex-shrink-0 rounded-[3px]' {/* Filters Popover - Small screens only */}
style={{ backgroundColor: selectedStatusColor, width: 8, height: 8 }} <Popover>
<PopoverTrigger asChild>
<Button
variant='active'
className='h-[32px] gap-[6px] rounded-[6px] px-[10px] xl:hidden'
>
<span>Filters</span>
</Button>
</PopoverTrigger>
<PopoverContent align='end' sideOffset={4} className='w-[280px] p-[12px]'>
<div className='flex flex-col gap-[12px]'>
{/* Status Filter */}
<div className='flex flex-col gap-[6px]'>
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
Status
</span>
<Combobox
options={statusOptions}
multiSelect
multiSelectValues={selectedStatuses}
onMultiSelectChange={handleStatusChange}
placeholder='All statuses'
overlayContent={
<span className='flex items-center gap-[6px] truncate text-[var(--text-primary)]'>
{selectedStatusColor && (
<div
className='flex-shrink-0 rounded-[3px]'
style={{ backgroundColor: selectedStatusColor, width: 8, height: 8 }}
/>
)}
<span className='truncate'>{statusDisplayLabel}</span>
</span>
}
showAllOption
allOptionLabel='All statuses'
size='sm'
className='h-[32px] w-full rounded-[6px]'
/> />
)} </div>
<span className='truncate'>{statusDisplayLabel}</span>
</span>
}
showAllOption
allOptionLabel='All statuses'
size='sm'
align='end'
className='h-[32px] w-[100px] rounded-[6px]'
/>
{/* Workflow Filter */} {/* Workflow Filter */}
<Combobox <div className='flex flex-col gap-[6px]'>
options={workflowOptions} <span className='font-medium text-[12px] text-[var(--text-secondary)]'>
multiSelect Workflow
multiSelectValues={workflowIds} </span>
onMultiSelectChange={setWorkflowIds} <Combobox
placeholder='Workflow' options={workflowOptions}
overlayContent={ multiSelect
<span className='flex items-center gap-[6px] truncate text-[var(--text-primary)]'> multiSelectValues={workflowIds}
{selectedWorkflow && ( onMultiSelectChange={setWorkflowIds}
<div placeholder='All workflows'
className='h-[8px] w-[8px] flex-shrink-0 rounded-[2px]' overlayContent={
style={{ backgroundColor: selectedWorkflow.color }} <span className='flex items-center gap-[6px] truncate text-[var(--text-primary)]'>
{selectedWorkflow && (
<div
className='h-[8px] w-[8px] flex-shrink-0 rounded-[2px]'
style={{ backgroundColor: selectedWorkflow.color }}
/>
)}
<span className='truncate'>{workflowDisplayLabel}</span>
</span>
}
searchable
searchPlaceholder='Search workflows...'
showAllOption
allOptionLabel='All workflows'
size='sm'
className='h-[32px] w-full rounded-[6px]'
/> />
)} </div>
<span className='truncate'>{workflowDisplayLabel}</span>
</span>
}
searchable
searchPlaceholder='Search workflows...'
showAllOption
allOptionLabel='All workflows'
size='sm'
align='end'
className='h-[32px] w-[120px] rounded-[6px]'
/>
{/* Folder Filter */} {/* Folder Filter */}
<Combobox <div className='flex flex-col gap-[6px]'>
options={folderOptions} <span className='font-medium text-[12px] text-[var(--text-secondary)]'>
multiSelect Folder
multiSelectValues={folderIds} </span>
onMultiSelectChange={setFolderIds} <Combobox
placeholder='Folder' options={folderOptions}
overlayContent={ multiSelect
<span className='truncate text-[var(--text-primary)]'>{folderDisplayLabel}</span> multiSelectValues={folderIds}
} onMultiSelectChange={setFolderIds}
searchable placeholder='All folders'
searchPlaceholder='Search folders...' overlayContent={
showAllOption <span className='truncate text-[var(--text-primary)]'>
allOptionLabel='All folders' {folderDisplayLabel}
size='sm' </span>
align='end' }
className='h-[32px] w-[100px] rounded-[6px]' searchable
/> searchPlaceholder='Search folders...'
showAllOption
allOptionLabel='All folders'
size='sm'
className='h-[32px] w-full rounded-[6px]'
/>
</div>
{/* Trigger Filter */} {/* Trigger Filter */}
<Combobox <div className='flex flex-col gap-[6px]'>
options={triggerOptions} <span className='font-medium text-[12px] text-[var(--text-secondary)]'>
multiSelect Trigger
multiSelectValues={triggers} </span>
onMultiSelectChange={setTriggers} <Combobox
placeholder='Trigger' options={triggerOptions}
overlayContent={ multiSelect
<span className='truncate text-[var(--text-primary)]'>{triggerDisplayLabel}</span> multiSelectValues={triggers}
} onMultiSelectChange={setTriggers}
searchable placeholder='All triggers'
searchPlaceholder='Search triggers...' overlayContent={
showAllOption <span className='truncate text-[var(--text-primary)]'>
allOptionLabel='All triggers' {triggerDisplayLabel}
size='sm' </span>
align='end' }
className='h-[32px] w-[100px] rounded-[6px]' searchable
/> searchPlaceholder='Search triggers...'
showAllOption
allOptionLabel='All triggers'
size='sm'
className='h-[32px] w-full rounded-[6px]'
/>
</div>
{/* Timeline Filter */} {/* Time Filter */}
<Combobox <div className='flex flex-col gap-[6px]'>
options={TIME_RANGE_OPTIONS as unknown as ComboboxOption[]} <span className='font-medium text-[12px] text-[var(--text-secondary)]'>
value={timeRange} Time Range
onChange={(val) => setTimeRange(val as typeof timeRange)} </span>
placeholder='Time' <Combobox
overlayContent={ options={TIME_RANGE_OPTIONS as unknown as ComboboxOption[]}
<span className='truncate text-[var(--text-primary)]'>{timeDisplayLabel}</span> value={timeRange}
} onChange={(val) => setTimeRange(val as typeof timeRange)}
size='sm' placeholder='All time'
align='end' overlayContent={
className='h-[32px] w-[140px] rounded-[6px]' <span className='truncate text-[var(--text-primary)]'>
/> {timeDisplayLabel}
</span>
}
size='sm'
className='h-[32px] w-full rounded-[6px]'
/>
</div>
</div>
</PopoverContent>
</Popover>
{/* Inline Filters - Large screens only */}
<div className='hidden items-center gap-[8px] xl:flex'>
{/* Status Filter */}
<Combobox
options={statusOptions}
multiSelect
multiSelectValues={selectedStatuses}
onMultiSelectChange={handleStatusChange}
placeholder='Status'
overlayContent={
<span className='flex items-center gap-[6px] truncate text-[var(--text-primary)]'>
{selectedStatusColor && (
<div
className='flex-shrink-0 rounded-[3px]'
style={{ backgroundColor: selectedStatusColor, width: 8, height: 8 }}
/>
)}
<span className='truncate'>{statusDisplayLabel}</span>
</span>
}
showAllOption
allOptionLabel='All statuses'
size='sm'
align='end'
className='h-[32px] w-[120px] rounded-[6px]'
/>
{/* Workflow Filter */}
<Combobox
options={workflowOptions}
multiSelect
multiSelectValues={workflowIds}
onMultiSelectChange={setWorkflowIds}
placeholder='Workflow'
overlayContent={
<span className='flex items-center gap-[6px] truncate text-[var(--text-primary)]'>
{selectedWorkflow && (
<div
className='h-[8px] w-[8px] flex-shrink-0 rounded-[2px]'
style={{ backgroundColor: selectedWorkflow.color }}
/>
)}
<span className='truncate'>{workflowDisplayLabel}</span>
</span>
}
searchable
searchPlaceholder='Search workflows...'
showAllOption
allOptionLabel='All workflows'
size='sm'
align='end'
className='h-[32px] w-[120px] rounded-[6px]'
/>
{/* Folder Filter */}
<Combobox
options={folderOptions}
multiSelect
multiSelectValues={folderIds}
onMultiSelectChange={setFolderIds}
placeholder='Folder'
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{folderDisplayLabel}</span>
}
searchable
searchPlaceholder='Search folders...'
showAllOption
allOptionLabel='All folders'
size='sm'
align='end'
className='h-[32px] w-[120px] rounded-[6px]'
/>
{/* Trigger Filter */}
<Combobox
options={triggerOptions}
multiSelect
multiSelectValues={triggers}
onMultiSelectChange={setTriggers}
placeholder='Trigger'
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{triggerDisplayLabel}</span>
}
searchable
searchPlaceholder='Search triggers...'
showAllOption
allOptionLabel='All triggers'
size='sm'
align='end'
className='h-[32px] w-[120px] rounded-[6px]'
/>
{/* Timeline Filter */}
<Combobox
options={TIME_RANGE_OPTIONS as unknown as ComboboxOption[]}
value={timeRange}
onChange={(val) => setTimeRange(val as typeof timeRange)}
placeholder='Time'
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{timeDisplayLabel}</span>
}
size='sm'
align='end'
className='h-[32px] w-[120px] rounded-[6px]'
/>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -26,6 +26,8 @@ export function useSearchState({
const [sections, setSections] = useState<SuggestionSection[]>([]) const [sections, setSections] = useState<SuggestionSection[]>([])
const [highlightedIndex, setHighlightedIndex] = useState(-1) const [highlightedIndex, setHighlightedIndex] = useState(-1)
const [highlightedBadgeIndex, setHighlightedBadgeIndex] = useState<number | null>(null)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null) const dropdownRef = useRef<HTMLDivElement>(null)
const debounceRef = useRef<NodeJS.Timeout | null>(null) const debounceRef = useRef<NodeJS.Timeout | null>(null)
@@ -52,6 +54,7 @@ export function useSearchState({
const handleInputChange = useCallback( const handleInputChange = useCallback(
(value: string) => { (value: string) => {
setCurrentInput(value) setCurrentInput(value)
setHighlightedBadgeIndex(null)
if (debounceRef.current) { if (debounceRef.current) {
clearTimeout(debounceRef.current) clearTimeout(debounceRef.current)
@@ -125,13 +128,24 @@ export function useSearchState({
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => { (event: React.KeyboardEvent) => {
if (event.key === 'Backspace' && currentInput === '') { if (event.key === 'Backspace' && currentInput === '') {
if (appliedFilters.length > 0) { event.preventDefault()
event.preventDefault()
removeBadge(appliedFilters.length - 1) if (highlightedBadgeIndex !== null) {
removeBadge(highlightedBadgeIndex)
setHighlightedBadgeIndex(null)
} else if (appliedFilters.length > 0) {
setHighlightedBadgeIndex(appliedFilters.length - 1)
} }
return return
} }
if (
highlightedBadgeIndex !== null &&
!['ArrowDown', 'ArrowUp', 'Enter'].includes(event.key)
) {
setHighlightedBadgeIndex(null)
}
if (event.key === 'Enter') { if (event.key === 'Enter') {
event.preventDefault() event.preventDefault()
@@ -180,6 +194,7 @@ export function useSearchState({
[ [
currentInput, currentInput,
appliedFilters, appliedFilters,
highlightedBadgeIndex,
isOpen, isOpen,
highlightedIndex, highlightedIndex,
suggestions, suggestions,
@@ -226,6 +241,7 @@ export function useSearchState({
suggestions, suggestions,
sections, sections,
highlightedIndex, highlightedIndex,
highlightedBadgeIndex,
inputRef, inputRef,
dropdownRef, dropdownRef,
@@ -238,7 +254,7 @@ export function useSearchState({
removeBadge, removeBadge,
clearAll, clearAll,
initializeFromQuery, initializeFromQuery,
setHighlightedIndex, setHighlightedIndex,
setHighlightedBadgeIndex,
} }
} }

View File

@@ -1,6 +1,3 @@
/**
* Logs layout - applies sidebar padding for all logs routes.
*/
export default function LogsLayout({ children }: { children: React.ReactNode }) { export default function LogsLayout({ children }: { children: React.ReactNode }) {
return <div className='flex h-full flex-1 flex-col overflow-hidden pl-60'>{children}</div> return <div className='flex h-full flex-1 flex-col overflow-hidden pl-60'>{children}</div>
} }

View File

@@ -1,20 +1,17 @@
'use client' 'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { AlertCircle, ArrowUpRight, Loader2 } from 'lucide-react' import { AlertCircle, Loader2 } from 'lucide-react'
import Link from 'next/link'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { Badge, buttonVariants } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser' import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
import { useFolders } from '@/hooks/queries/folders' import { useFolders } from '@/hooks/queries/folders'
import { useLogDetail, useLogsList } from '@/hooks/queries/logs' import { useDashboardLogs, useLogDetail, useLogsList } from '@/hooks/queries/logs'
import { useDebounce } from '@/hooks/use-debounce' import { useDebounce } from '@/hooks/use-debounce'
import { useFilterStore } from '@/stores/logs/filters/store' import { useFilterStore } from '@/stores/logs/filters/store'
import type { WorkflowLog } from '@/stores/logs/filters/types' import type { WorkflowLog } from '@/stores/logs/filters/types'
import { useUserPermissionsContext } from '../providers/workspace-permissions-provider' import { useUserPermissionsContext } from '../providers/workspace-permissions-provider'
import { Dashboard, LogDetails, LogsToolbar, NotificationSettings } from './components' import { Dashboard, LogDetails, LogsList, LogsToolbar, NotificationSettings } from './components'
import { formatDate, formatDuration, StatusBadge, TriggerBadge } from './utils'
const LOGS_PER_PAGE = 50 as const const LOGS_PER_PAGE = 50 as const
const REFRESH_SPINNER_DURATION_MS = 1000 as const const REFRESH_SPINNER_DURATION_MS = 1000 as const
@@ -56,20 +53,17 @@ export default function Logs() {
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const debouncedSearchQuery = useDebounce(searchQuery, 300) const debouncedSearchQuery = useDebounce(searchQuery, 300)
// Sync search query from URL on mount (client-side only)
useEffect(() => { useEffect(() => {
const urlSearch = new URLSearchParams(window.location.search).get('search') || '' const urlSearch = new URLSearchParams(window.location.search).get('search') || ''
if (urlSearch && urlSearch !== searchQuery) { if (urlSearch && urlSearch !== searchQuery) {
setSearchQuery(urlSearch) setSearchQuery(urlSearch)
} }
// Only run on mount
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
const [isLive, setIsLive] = useState(false) const [isLive, setIsLive] = useState(false)
const [isVisuallyRefreshing, setIsVisuallyRefreshing] = useState(false) const [isVisuallyRefreshing, setIsVisuallyRefreshing] = useState(false)
const [isExporting, setIsExporting] = useState(false) const [isExporting, setIsExporting] = useState(false)
const [dashboardRefreshTrigger, setDashboardRefreshTrigger] = useState(0)
const isSearchOpenRef = useRef<boolean>(false) const isSearchOpenRef = useRef<boolean>(false)
const [isNotificationSettingsOpen, setIsNotificationSettingsOpen] = useState(false) const [isNotificationSettingsOpen, setIsNotificationSettingsOpen] = useState(false)
const userPermissions = useUserPermissionsContext() const userPermissions = useUserPermissionsContext()
@@ -92,8 +86,31 @@ export default function Logs() {
refetchInterval: isLive ? 5000 : false, refetchInterval: isLive ? 5000 : false,
}) })
const dashboardFilters = useMemo(
() => ({
timeRange,
level,
workflowIds,
folderIds,
triggers,
searchQuery: debouncedSearchQuery,
}),
[timeRange, level, workflowIds, folderIds, triggers, debouncedSearchQuery]
)
const dashboardLogsQuery = useDashboardLogs(workspaceId, dashboardFilters, {
enabled: Boolean(workspaceId) && isInitialized.current,
refetchInterval: isLive ? 5000 : false,
})
const logDetailQuery = useLogDetail(selectedLog?.id) const logDetailQuery = useLogDetail(selectedLog?.id)
const mergedSelectedLog = useMemo(() => {
if (!selectedLog) return null
if (!logDetailQuery.data) return selectedLog
return { ...selectedLog, ...logDetailQuery.data }
}, [selectedLog, logDetailQuery.data])
const logs = useMemo(() => { const logs = useMemo(() => {
if (!logsQuery.data?.pages) return [] if (!logsQuery.data?.pages) return []
return logsQuery.data.pages.flatMap((page) => page.logs) return logsQuery.data.pages.flatMap((page) => page.logs)
@@ -107,10 +124,8 @@ export default function Logs() {
} }
}, [debouncedSearchQuery, setStoreSearchQuery]) }, [debouncedSearchQuery, setStoreSearchQuery])
// Track previous log state for efficient change detection
const prevSelectedLogRef = useRef<WorkflowLog | null>(null) const prevSelectedLogRef = useRef<WorkflowLog | null>(null)
// Sync selected log with refreshed data from logs list
useEffect(() => { useEffect(() => {
if (!selectedLog?.id || logs.length === 0) return if (!selectedLog?.id || logs.length === 0) return
@@ -119,32 +134,27 @@ export default function Logs() {
const prevLog = prevSelectedLogRef.current const prevLog = prevSelectedLogRef.current
// Check if status-related fields have changed (e.g., running -> done)
const hasStatusChange = const hasStatusChange =
prevLog?.id === updatedLog.id && prevLog?.id === updatedLog.id &&
(updatedLog.duration !== prevLog.duration || (updatedLog.duration !== prevLog.duration ||
updatedLog.level !== prevLog.level || updatedLog.level !== prevLog.level ||
updatedLog.hasPendingPause !== prevLog.hasPendingPause) updatedLog.hasPendingPause !== prevLog.hasPendingPause)
// Only update if the log data actually changed
if (updatedLog !== selectedLog) { if (updatedLog !== selectedLog) {
setSelectedLog(updatedLog) setSelectedLog(updatedLog)
prevSelectedLogRef.current = updatedLog prevSelectedLogRef.current = updatedLog
} }
// Update index in case position changed
const newIndex = logs.findIndex((l) => l.id === selectedLog.id) const newIndex = logs.findIndex((l) => l.id === selectedLog.id)
if (newIndex !== selectedLogIndex) { if (newIndex !== selectedLogIndex) {
setSelectedLogIndex(newIndex) setSelectedLogIndex(newIndex)
} }
// Refetch log details if status changed to keep details panel in sync
if (hasStatusChange) { if (hasStatusChange) {
logDetailQuery.refetch() logDetailQuery.refetch()
} }
}, [logs, selectedLog?.id, selectedLogIndex, logDetailQuery.refetch]) }, [logs, selectedLog?.id, selectedLogIndex, logDetailQuery])
// Refetch log details during live mode
useEffect(() => { useEffect(() => {
if (!isLive || !selectedLog?.id) return if (!isLive || !selectedLog?.id) return
@@ -155,20 +165,24 @@ export default function Logs() {
return () => clearInterval(interval) return () => clearInterval(interval)
}, [isLive, selectedLog?.id, logDetailQuery]) }, [isLive, selectedLog?.id, logDetailQuery])
const handleLogClick = (log: WorkflowLog) => { const handleLogClick = useCallback(
// If clicking on the same log that's already selected and sidebar is open, close it (log: WorkflowLog) => {
if (selectedLog?.id === log.id && isSidebarOpen) { if (selectedLog?.id === log.id && isSidebarOpen) {
handleCloseSidebar() setIsSidebarOpen(false)
return setSelectedLog(null)
} setSelectedLogIndex(-1)
prevSelectedLogRef.current = null
return
}
// Otherwise, select the log and open the sidebar setSelectedLog(log)
setSelectedLog(log) prevSelectedLogRef.current = log
prevSelectedLogRef.current = log const index = logs.findIndex((l) => l.id === log.id)
const index = logs.findIndex((l) => l.id === log.id) setSelectedLogIndex(index)
setSelectedLogIndex(index) setIsSidebarOpen(true)
setIsSidebarOpen(true) },
} [selectedLog?.id, isSidebarOpen, logs]
)
const handleNavigateNext = useCallback(() => { const handleNavigateNext = useCallback(() => {
if (selectedLogIndex < logs.length - 1) { if (selectedLogIndex < logs.length - 1) {
@@ -190,12 +204,12 @@ export default function Logs() {
} }
}, [selectedLogIndex, logs]) }, [selectedLogIndex, logs])
const handleCloseSidebar = () => { const handleCloseSidebar = useCallback(() => {
setIsSidebarOpen(false) setIsSidebarOpen(false)
setSelectedLog(null) setSelectedLog(null)
setSelectedLogIndex(-1) setSelectedLogIndex(-1)
prevSelectedLogRef.current = null prevSelectedLogRef.current = null
} }, [])
useEffect(() => { useEffect(() => {
if (selectedRowRef.current) { if (selectedRowRef.current) {
@@ -213,8 +227,6 @@ export default function Logs() {
if (selectedLog?.id) { if (selectedLog?.id) {
logDetailQuery.refetch() logDetailQuery.refetch()
} }
// Also trigger dashboard refresh
setDashboardRefreshTrigger((prev) => prev + 1)
}, [logsQuery, logDetailQuery, selectedLog?.id]) }, [logsQuery, logDetailQuery, selectedLog?.id])
const handleToggleLive = useCallback(() => { const handleToggleLive = useCallback(() => {
@@ -225,8 +237,6 @@ export default function Logs() {
setIsVisuallyRefreshing(true) setIsVisuallyRefreshing(true)
setTimeout(() => setIsVisuallyRefreshing(false), REFRESH_SPINNER_DURATION_MS) setTimeout(() => setIsVisuallyRefreshing(false), REFRESH_SPINNER_DURATION_MS)
logsQuery.refetch() logsQuery.refetch()
// Also trigger dashboard refresh
setDashboardRefreshTrigger((prev) => prev + 1)
} }
}, [isLive, logsQuery]) }, [isLive, logsQuery])
@@ -292,62 +302,6 @@ export default function Logs() {
} }
}, [logsQuery]) }, [logsQuery])
useEffect(() => {
if (logsQuery.isLoading || !logsQuery.hasNextPage) return
const scrollContainer = scrollContainerRef.current
if (!scrollContainer) return
const handleScroll = () => {
if (!scrollContainer) return
const { scrollTop, scrollHeight, clientHeight } = scrollContainer
const scrollPercentage = (scrollTop / (scrollHeight - clientHeight)) * 100
if (scrollPercentage > 60 && !logsQuery.isFetchingNextPage && logsQuery.hasNextPage) {
loadMoreLogs()
}
}
scrollContainer.addEventListener('scroll', handleScroll)
return () => {
scrollContainer.removeEventListener('scroll', handleScroll)
}
}, [logsQuery.isLoading, logsQuery.hasNextPage, logsQuery.isFetchingNextPage, loadMoreLogs])
useEffect(() => {
const currentLoaderRef = loaderRef.current
const scrollContainer = scrollContainerRef.current
if (!currentLoaderRef || !scrollContainer || logsQuery.isLoading || !logsQuery.hasNextPage)
return
const observer = new IntersectionObserver(
(entries) => {
const e = entries[0]
if (!e?.isIntersecting) return
const { scrollTop, scrollHeight, clientHeight } = scrollContainer
const pct = (scrollTop / (scrollHeight - clientHeight)) * 100
if (pct > 70 && !logsQuery.isFetchingNextPage) {
loadMoreLogs()
}
},
{
root: scrollContainer,
threshold: 0.1,
rootMargin: '200px 0px 0px 0px',
}
)
observer.observe(currentLoaderRef)
return () => {
observer.unobserve(currentLoaderRef)
}
}, [logsQuery.isLoading, logsQuery.hasNextPage, logsQuery.isFetchingNextPage, loadMoreLogs])
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (isSearchOpenRef.current) return if (isSearchOpenRef.current) return
@@ -408,11 +362,15 @@ export default function Logs() {
/> />
</div> </div>
{/* Dashboard view - always mounted to preserve state and query cache */} {/* Dashboard view - uses all logs (non-paginated) for accurate metrics */}
<div <div
className={cn('flex min-h-0 flex-1 flex-col pr-[24px]', !isDashboardView && 'hidden')} className={cn('flex min-h-0 flex-1 flex-col pr-[24px]', !isDashboardView && 'hidden')}
> >
<Dashboard isLive={isLive} refreshTrigger={dashboardRefreshTrigger} /> <Dashboard
logs={dashboardLogsQuery.data ?? []}
isLoading={!dashboardLogsQuery.data}
error={dashboardLogsQuery.error}
/>
</div> </div>
{/* Main content area with table - only show in logs view */} {/* Main content area with table - only show in logs view */}
@@ -451,11 +409,8 @@ export default function Logs() {
</div> </div>
</div> </div>
{/* Table body - scrollable */} {/* Table body - virtualized */}
<div <div className='min-h-0 flex-1 overflow-hidden' ref={scrollContainerRef}>
className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden'
ref={scrollContainerRef}
>
{logsQuery.isLoading && !logsQuery.data ? ( {logsQuery.isLoading && !logsQuery.data ? (
<div className='flex h-full items-center justify-center'> <div className='flex h-full items-center justify-center'>
<div className='flex items-center gap-[8px] text-[var(--text-secondary)]'> <div className='flex items-center gap-[8px] text-[var(--text-secondary)]'>
@@ -479,137 +434,23 @@ export default function Logs() {
</div> </div>
</div> </div>
) : ( ) : (
<div> <LogsList
{logs.map((log) => { logs={logs}
const formattedDate = formatDate(log.createdAt) selectedLogId={selectedLog?.id ?? null}
const isSelected = selectedLog?.id === log.id onLogClick={handleLogClick}
const baseLevel = (log.level || 'info').toLowerCase() selectedRowRef={selectedRowRef}
const isError = baseLevel === 'error' hasNextPage={logsQuery.hasNextPage ?? false}
const isPending = !isError && log.hasPendingPause === true isFetchingNextPage={logsQuery.isFetchingNextPage}
const isRunning = !isError && !isPending && log.duration === null onLoadMore={loadMoreLogs}
loaderRef={loaderRef}
return ( />
<div
key={log.id}
ref={isSelected ? selectedRowRef : null}
className={cn(
'relative flex h-[44px] cursor-pointer items-center px-[24px] hover:bg-[var(--c-2A2A2A)]',
isSelected && 'bg-[var(--c-2A2A2A)]'
)}
onClick={() => handleLogClick(log)}
>
<div className='flex flex-1 items-center'>
{/* Date */}
<span className='w-[8%] min-w-[70px] font-medium text-[12px] text-[var(--text-primary)]'>
{formattedDate.compactDate}
</span>
{/* Time */}
<span className='w-[12%] min-w-[90px] font-medium text-[12px] text-[var(--text-primary)]'>
{formattedDate.compactTime}
</span>
{/* Status */}
<div className='w-[12%] min-w-[100px]'>
<StatusBadge
status={
isError
? 'error'
: isPending
? 'pending'
: isRunning
? 'running'
: 'info'
}
/>
</div>
{/* Workflow */}
<div className='flex w-[22%] min-w-[140px] items-center gap-[8px] pr-[8px]'>
<div
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
style={{ backgroundColor: log.workflow?.color }}
/>
<span className='min-w-0 truncate font-medium text-[12px] text-[var(--text-primary)]'>
{log.workflow?.name || 'Unknown'}
</span>
</div>
{/* Cost */}
<span className='w-[12%] min-w-[90px] font-medium text-[12px] text-[var(--text-primary)]'>
{typeof log.cost?.total === 'number'
? `$${log.cost.total.toFixed(4)}`
: '—'}
</span>
{/* Trigger */}
<div className='w-[14%] min-w-[110px]'>
{log.trigger ? (
<TriggerBadge trigger={log.trigger} />
) : (
<span className='font-medium text-[12px] text-[var(--text-primary)]'>
</span>
)}
</div>
{/* Duration */}
<div className='w-[20%] min-w-[100px]'>
<Badge
variant='default'
className='rounded-[6px] px-[9px] py-[2px] text-[12px]'
>
{formatDuration(log.duration) || '—'}
</Badge>
</div>
</div>
{/* Resume Link */}
{isPending && log.executionId && (log.workflow?.id || log.workflowId) && (
<Link
href={`/resume/${log.workflow?.id || log.workflowId}/${log.executionId}`}
target='_blank'
rel='noopener noreferrer'
className={cn(
buttonVariants({ variant: 'active' }),
'absolute right-[24px] h-[26px] w-[26px] rounded-[6px] p-0'
)}
aria-label='Open resume console'
onClick={(e) => e.stopPropagation()}
>
<ArrowUpRight className='h-[14px] w-[14px]' />
</Link>
)}
</div>
)
})}
{/* Infinite scroll loader */}
{logsQuery.hasNextPage && (
<div className='flex items-center justify-center py-[16px]'>
<div
ref={loaderRef}
className='flex items-center gap-[8px] text-[var(--text-secondary)]'
>
{logsQuery.isFetchingNextPage ? (
<>
<Loader2 className='h-[16px] w-[16px] animate-spin' />
<span className='text-[13px]'>Loading more...</span>
</>
) : (
<span className='text-[13px]'>Scroll to load more</span>
)}
</div>
</div>
)}
</div>
)} )}
</div> </div>
</div> </div>
{/* Log Details - rendered inside table container */} {/* Log Details - rendered inside table container */}
<LogDetails <LogDetails
log={logDetailQuery.data ? { ...selectedLog, ...logDetailQuery.data } : selectedLog} log={mergedSelectedLog}
isOpen={isSidebarOpen} isOpen={isSidebarOpen}
onClose={handleCloseSidebar} onClose={handleCloseSidebar}
onNavigateNext={handleNavigateNext} onNavigateNext={handleNavigateNext}

View File

@@ -448,7 +448,7 @@ export const formatDate = (dateString: string) => {
formatted: format(date, 'HH:mm:ss'), formatted: format(date, 'HH:mm:ss'),
compact: format(date, 'MMM d HH:mm:ss'), compact: format(date, 'MMM d HH:mm:ss'),
compactDate: format(date, 'MMM d').toUpperCase(), compactDate: format(date, 'MMM d').toUpperCase(),
compactTime: format(date, 'h:mm:ss a'), compactTime: format(date, 'h:mm a'),
relative: (() => { relative: (() => {
const now = new Date() const now = new Date()
const diffMs = now.getTime() - date.getTime() const diffMs = now.getTime() - date.getTime()

View File

@@ -2265,7 +2265,14 @@ const WorkflowContent = React.memo(() => {
className={`absolute inset-0 z-[5] flex items-center justify-center bg-[var(--bg)] transition-opacity duration-150 ${isWorkflowReady ? 'pointer-events-none opacity-0' : 'opacity-100'}`} className={`absolute inset-0 z-[5] flex items-center justify-center bg-[var(--bg)] transition-opacity duration-150 ${isWorkflowReady ? 'pointer-events-none opacity-0' : 'opacity-100'}`}
> >
<div <div
className={`h-[18px] w-[18px] rounded-full border-[1.5px] border-muted-foreground border-t-transparent ${isWorkflowReady ? '' : 'animate-spin'}`} className={`h-[18px] w-[18px] rounded-full ${isWorkflowReady ? '' : 'animate-spin'}`}
style={{
background:
'conic-gradient(from 0deg, hsl(var(--muted-foreground)) 0deg 120deg, transparent 120deg 180deg, hsl(var(--muted-foreground)) 180deg 300deg, transparent 300deg 360deg)',
mask: 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
WebkitMask:
'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
}}
/> />
</div> </div>

View File

@@ -58,7 +58,16 @@ export default function WorkflowsPage() {
<div className='flex h-full w-full flex-col overflow-hidden bg-[var(--bg)]'> <div className='flex h-full w-full flex-col overflow-hidden bg-[var(--bg)]'>
<div className='relative h-full w-full flex-1 bg-[var(--bg)]'> <div className='relative h-full w-full flex-1 bg-[var(--bg)]'>
<div className='workflow-container flex h-full items-center justify-center bg-[var(--bg)]'> <div className='workflow-container flex h-full items-center justify-center bg-[var(--bg)]'>
<div className='h-[18px] w-[18px] animate-spin rounded-full border-[1.5px] border-muted-foreground border-t-transparent' /> <div
className='h-[18px] w-[18px] animate-spin rounded-full'
style={{
background:
'conic-gradient(from 0deg, hsl(var(--muted-foreground)) 0deg 120deg, transparent 120deg 180deg, hsl(var(--muted-foreground)) 180deg 300deg, transparent 300deg 360deg)',
mask: 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
WebkitMask:
'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
}}
/>
</div> </div>
<Panel /> <Panel />
</div> </div>

View File

@@ -117,7 +117,16 @@ export default function WorkspacePage() {
if (isPending) { if (isPending) {
return ( return (
<div className='flex h-screen w-full items-center justify-center'> <div className='flex h-screen w-full items-center justify-center'>
<div className='h-[18px] w-[18px] animate-spin rounded-full border-[1.5px] border-muted-foreground border-t-transparent' /> <div
className='h-[18px] w-[18px] animate-spin rounded-full'
style={{
background:
'conic-gradient(from 0deg, hsl(var(--muted-foreground)) 0deg 120deg, transparent 120deg 180deg, hsl(var(--muted-foreground)) 180deg 300deg, transparent 300deg 360deg)',
mask: 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
WebkitMask:
'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
}}
/>
</div> </div>
) )
} }

View File

@@ -0,0 +1,20 @@
/**
* Loader icon animation
* Continuous spinning animation for loading states
* Uses GPU acceleration for smooth performance
*/
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animated-loader-svg {
animation: spin 1s linear infinite;
transform-origin: center center;
will-change: transform;
}

View File

@@ -12,6 +12,7 @@ export { HexSimple } from './hex-simple'
export { Key } from './key' export { Key } from './key'
export { Layout } from './layout' export { Layout } from './layout'
export { Library } from './library' export { Library } from './library'
export { Loader } from './loader'
export { MoreHorizontal } from './more-horizontal' export { MoreHorizontal } from './more-horizontal'
export { NoWrap } from './no-wrap' export { NoWrap } from './no-wrap'
export { PanelLeft } from './panel-left' export { PanelLeft } from './panel-left'

View File

@@ -0,0 +1,42 @@
import type { SVGProps } from 'react'
import styles from '@/components/emcn/icons/animate/loader.module.css'
export interface LoaderProps extends SVGProps<SVGSVGElement> {
/**
* Enable animation on the loader icon
* @default false
*/
animate?: boolean
}
/**
* Loader icon component with optional CSS-based spinning animation
* Based on refresh-cw but without the arrows, just the circular arcs.
* When animate is false, this is a lightweight static icon with no animation overhead.
* When animate is true, CSS module animations are applied for continuous spin.
* @param props - SVG properties including className, animate, etc.
*/
export function Loader({ animate = false, className, ...props }: LoaderProps) {
const svgClassName = animate
? `${styles['animated-loader-svg']} ${className || ''}`.trim()
: className
return (
<svg
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
className={svgClassName}
{...props}
>
<path d='M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74' />
<path d='M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74' />
</svg>
)
}

View File

@@ -9,16 +9,8 @@ export const logKeys = {
[...logKeys.lists(), workspaceId ?? '', filters] as const, [...logKeys.lists(), workspaceId ?? '', filters] as const,
details: () => [...logKeys.all, 'detail'] as const, details: () => [...logKeys.all, 'detail'] as const,
detail: (logId: string | undefined) => [...logKeys.details(), logId ?? ''] as const, detail: (logId: string | undefined) => [...logKeys.details(), logId ?? ''] as const,
metrics: () => [...logKeys.all, 'metrics'] as const, dashboard: (workspaceId: string | undefined, filters: Record<string, unknown>) =>
executions: (workspaceId: string | undefined, filters: Record<string, any>) => [...logKeys.all, 'dashboard', workspaceId ?? '', filters] as const,
[...logKeys.metrics(), 'executions', workspaceId ?? '', filters] as const,
workflowLogs: (
workspaceId: string | undefined,
workflowId: string | undefined,
filters: Record<string, any>
) => [...logKeys.all, 'workflow-logs', workspaceId ?? '', workflowId ?? '', filters] as const,
globalLogs: (workspaceId: string | undefined, filters: Record<string, any>) =>
[...logKeys.all, 'global-logs', workspaceId ?? '', filters] as const,
} }
interface LogFilters { interface LogFilters {
@@ -31,6 +23,87 @@ interface LogFilters {
limit: number limit: number
} }
/**
* Calculates start date from a time range string.
* Returns null for 'All time' to indicate no date filtering.
*/
function getStartDateFromTimeRange(timeRange: string): Date | null {
if (timeRange === 'All time') return null
const now = new Date()
switch (timeRange) {
case 'Past 30 minutes':
return new Date(now.getTime() - 30 * 60 * 1000)
case 'Past hour':
return new Date(now.getTime() - 60 * 60 * 1000)
case 'Past 6 hours':
return new Date(now.getTime() - 6 * 60 * 60 * 1000)
case 'Past 12 hours':
return new Date(now.getTime() - 12 * 60 * 60 * 1000)
case 'Past 24 hours':
return new Date(now.getTime() - 24 * 60 * 60 * 1000)
case 'Past 3 days':
return new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000)
case 'Past 7 days':
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
case 'Past 14 days':
return new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000)
case 'Past 30 days':
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
default:
return new Date(0)
}
}
/**
* Applies common filter parameters to a URLSearchParams object.
* Shared between paginated and non-paginated log fetches.
*/
function applyFilterParams(params: URLSearchParams, filters: Omit<LogFilters, 'limit'>): void {
if (filters.level !== 'all') {
params.set('level', filters.level)
}
if (filters.triggers.length > 0) {
params.set('triggers', filters.triggers.join(','))
}
if (filters.workflowIds.length > 0) {
params.set('workflowIds', filters.workflowIds.join(','))
}
if (filters.folderIds.length > 0) {
params.set('folderIds', filters.folderIds.join(','))
}
const startDate = getStartDateFromTimeRange(filters.timeRange)
if (startDate) {
params.set('startDate', startDate.toISOString())
}
if (filters.searchQuery.trim()) {
const parsedQuery = parseQuery(filters.searchQuery.trim())
const searchParams = queryToApiParams(parsedQuery)
for (const [key, value] of Object.entries(searchParams)) {
params.set(key, value)
}
}
}
function buildQueryParams(workspaceId: string, filters: LogFilters, page: number): string {
const params = new URLSearchParams()
params.set('workspaceId', workspaceId)
params.set('limit', filters.limit.toString())
params.set('offset', ((page - 1) * filters.limit).toString())
applyFilterParams(params, filters)
return params.toString()
}
async function fetchLogsPage( async function fetchLogsPage(
workspaceId: string, workspaceId: string,
filters: LogFilters, filters: LogFilters,
@@ -64,80 +137,6 @@ async function fetchLogDetail(logId: string): Promise<WorkflowLog> {
return data return data
} }
function buildQueryParams(workspaceId: string, filters: LogFilters, page: number): string {
const params = new URLSearchParams()
params.set('workspaceId', workspaceId)
params.set('limit', filters.limit.toString())
params.set('offset', ((page - 1) * filters.limit).toString())
if (filters.level !== 'all') {
params.set('level', filters.level)
}
if (filters.triggers.length > 0) {
params.set('triggers', filters.triggers.join(','))
}
if (filters.workflowIds.length > 0) {
params.set('workflowIds', filters.workflowIds.join(','))
}
if (filters.folderIds.length > 0) {
params.set('folderIds', filters.folderIds.join(','))
}
if (filters.timeRange !== 'All time') {
const now = new Date()
let startDate: Date
switch (filters.timeRange) {
case 'Past 30 minutes':
startDate = new Date(now.getTime() - 30 * 60 * 1000)
break
case 'Past hour':
startDate = new Date(now.getTime() - 60 * 60 * 1000)
break
case 'Past 6 hours':
startDate = new Date(now.getTime() - 6 * 60 * 60 * 1000)
break
case 'Past 12 hours':
startDate = new Date(now.getTime() - 12 * 60 * 60 * 1000)
break
case 'Past 24 hours':
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
break
case 'Past 3 days':
startDate = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000)
break
case 'Past 7 days':
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
break
case 'Past 14 days':
startDate = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000)
break
case 'Past 30 days':
startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
break
default:
startDate = new Date(0)
}
params.set('startDate', startDate.toISOString())
}
if (filters.searchQuery.trim()) {
const parsedQuery = parseQuery(filters.searchQuery.trim())
const searchParams = queryToApiParams(parsedQuery)
for (const [key, value] of Object.entries(searchParams)) {
params.set(key, value)
}
}
return params.toString()
}
interface UseLogsListOptions { interface UseLogsListOptions {
enabled?: boolean enabled?: boolean
refetchInterval?: number | false refetchInterval?: number | false
@@ -153,8 +152,8 @@ export function useLogsList(
queryFn: ({ pageParam }) => fetchLogsPage(workspaceId as string, filters, pageParam), queryFn: ({ pageParam }) => fetchLogsPage(workspaceId as string, filters, pageParam),
enabled: Boolean(workspaceId) && (options?.enabled ?? true), enabled: Boolean(workspaceId) && (options?.enabled ?? true),
refetchInterval: options?.refetchInterval ?? false, refetchInterval: options?.refetchInterval ?? false,
staleTime: 0, // Always consider stale for real-time logs staleTime: 0,
placeholderData: keepPreviousData, // Keep showing old data while fetching new data placeholderData: keepPreviousData,
initialPageParam: 1, initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextPage, getNextPageParam: (lastPage) => lastPage.nextPage,
}) })
@@ -165,303 +164,60 @@ export function useLogDetail(logId: string | undefined) {
queryKey: logKeys.detail(logId), queryKey: logKeys.detail(logId),
queryFn: () => fetchLogDetail(logId as string), queryFn: () => fetchLogDetail(logId as string),
enabled: Boolean(logId), enabled: Boolean(logId),
staleTime: 30 * 1000, // Details can be slightly stale (30 seconds) staleTime: 30 * 1000,
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}) })
} }
interface WorkflowSegment { const DASHBOARD_LOGS_LIMIT = 10000
timestamp: string
hasExecutions: boolean
totalExecutions: number
successfulExecutions: number
successRate: number
avgDurationMs?: number
p50Ms?: number
p90Ms?: number
p99Ms?: number
}
interface WorkflowExecution { /**
workflowId: string * Fetches all logs for dashboard metrics (non-paginated).
workflowName: string * Uses same filters as the logs list but with a high limit to get all data.
segments: WorkflowSegment[] */
overallSuccessRate: number async function fetchAllLogs(
} workspaceId: string,
filters: Omit<LogFilters, 'limit'>
): Promise<WorkflowLog[]> {
const params = new URLSearchParams()
interface AggregateSegment { params.set('workspaceId', workspaceId)
timestamp: string params.set('limit', DASHBOARD_LOGS_LIMIT.toString())
totalExecutions: number params.set('offset', '0')
successfulExecutions: number
avgDurationMs?: number
}
interface ExecutionsMetricsResponse { applyFilterParams(params, filters)
workflows: WorkflowExecution[]
aggregateSegments: AggregateSegment[]
}
interface DashboardMetricsFilters {
workspaceId: string
segments: number
startTime: string
endTime: string
workflowIds?: string[]
folderIds?: string[]
triggers?: string[]
level?: string
}
async function fetchExecutionsMetrics(
filters: DashboardMetricsFilters
): Promise<ExecutionsMetricsResponse> {
const params = new URLSearchParams({
segments: String(filters.segments),
startTime: filters.startTime,
endTime: filters.endTime,
})
if (filters.workflowIds && filters.workflowIds.length > 0) {
params.set('workflowIds', filters.workflowIds.join(','))
}
if (filters.folderIds && filters.folderIds.length > 0) {
params.set('folderIds', filters.folderIds.join(','))
}
if (filters.triggers && filters.triggers.length > 0) {
params.set('triggers', filters.triggers.join(','))
}
if (filters.level && filters.level !== 'all') {
params.set('level', filters.level)
}
const response = await fetch(
`/api/workspaces/${filters.workspaceId}/metrics/executions?${params.toString()}`
)
if (!response.ok) {
throw new Error('Failed to fetch execution metrics')
}
const data = await response.json()
const workflows: WorkflowExecution[] = (data.workflows || []).map((wf: any) => {
const segments = (wf.segments || []).map((s: any) => {
const total = s.totalExecutions || 0
const success = s.successfulExecutions || 0
const hasExecutions = total > 0
const successRate = hasExecutions ? (success / total) * 100 : 100
return {
timestamp: s.timestamp,
hasExecutions,
totalExecutions: total,
successfulExecutions: success,
successRate,
avgDurationMs: typeof s.avgDurationMs === 'number' ? s.avgDurationMs : 0,
p50Ms: typeof s.p50Ms === 'number' ? s.p50Ms : 0,
p90Ms: typeof s.p90Ms === 'number' ? s.p90Ms : 0,
p99Ms: typeof s.p99Ms === 'number' ? s.p99Ms : 0,
}
})
const totals = segments.reduce(
(acc: { total: number; success: number }, seg: WorkflowSegment) => {
acc.total += seg.totalExecutions
acc.success += seg.successfulExecutions
return acc
},
{ total: 0, success: 0 }
)
const overallSuccessRate = totals.total > 0 ? (totals.success / totals.total) * 100 : 100
return {
workflowId: wf.workflowId,
workflowName: wf.workflowName,
segments,
overallSuccessRate,
}
})
const sortedWorkflows = workflows.sort((a, b) => {
const errA = a.overallSuccessRate < 100 ? 1 - a.overallSuccessRate / 100 : 0
const errB = b.overallSuccessRate < 100 ? 1 - b.overallSuccessRate / 100 : 0
return errB - errA
})
const segmentCount = filters.segments
const startTime = new Date(filters.startTime)
const endTime = new Date(filters.endTime)
const aggregateSegments: AggregateSegment[] = Array.from({ length: segmentCount }, (_, i) => {
const base = startTime.getTime()
const ts = new Date(base + Math.floor((i * (endTime.getTime() - base)) / segmentCount))
return {
timestamp: ts.toISOString(),
totalExecutions: 0,
successfulExecutions: 0,
avgDurationMs: 0,
}
})
const weightedDurationSums: number[] = Array(segmentCount).fill(0)
const executionCounts: number[] = Array(segmentCount).fill(0)
for (const wf of data.workflows as any[]) {
wf.segments.forEach((s: any, i: number) => {
const index = Math.min(i, segmentCount - 1)
const execCount = s.totalExecutions || 0
aggregateSegments[index].totalExecutions += execCount
aggregateSegments[index].successfulExecutions += s.successfulExecutions || 0
if (typeof s.avgDurationMs === 'number' && s.avgDurationMs > 0 && execCount > 0) {
weightedDurationSums[index] += s.avgDurationMs * execCount
executionCounts[index] += execCount
}
})
}
aggregateSegments.forEach((seg, i) => {
if (executionCounts[i] > 0) {
seg.avgDurationMs = weightedDurationSums[i] / executionCounts[i]
} else {
seg.avgDurationMs = 0
}
})
return {
workflows: sortedWorkflows,
aggregateSegments,
}
}
interface UseExecutionsMetricsOptions {
enabled?: boolean
refetchInterval?: number | false
}
export function useExecutionsMetrics(
filters: DashboardMetricsFilters,
options?: UseExecutionsMetricsOptions
) {
return useQuery({
queryKey: logKeys.executions(filters.workspaceId, filters),
queryFn: () => fetchExecutionsMetrics(filters),
enabled: Boolean(filters.workspaceId) && (options?.enabled ?? true),
refetchInterval: options?.refetchInterval ?? false,
staleTime: 10 * 1000, // Metrics can be slightly stale (10 seconds)
placeholderData: keepPreviousData,
})
}
interface DashboardLogsFilters {
workspaceId: string
startDate: string
endDate: string
workflowIds?: string[]
folderIds?: string[]
triggers?: string[]
level?: string
searchQuery?: string
limit: number
}
interface DashboardLogsPage {
logs: any[] // Will be mapped by the consumer
hasMore: boolean
nextPage: number | undefined
}
async function fetchDashboardLogsPage(
filters: DashboardLogsFilters,
page: number,
workflowId?: string
): Promise<DashboardLogsPage> {
const params = new URLSearchParams({
limit: filters.limit.toString(),
offset: ((page - 1) * filters.limit).toString(),
workspaceId: filters.workspaceId,
startDate: filters.startDate,
endDate: filters.endDate,
order: 'desc',
details: 'full',
})
if (workflowId) {
params.set('workflowIds', workflowId)
} else if (filters.workflowIds && filters.workflowIds.length > 0) {
params.set('workflowIds', filters.workflowIds.join(','))
}
if (filters.folderIds && filters.folderIds.length > 0) {
params.set('folderIds', filters.folderIds.join(','))
}
if (filters.triggers && filters.triggers.length > 0) {
params.set('triggers', filters.triggers.join(','))
}
if (filters.level && filters.level !== 'all') {
params.set('level', filters.level)
}
if (filters.searchQuery?.trim()) {
const parsed = parseQuery(filters.searchQuery)
const extraParams = queryToApiParams(parsed)
Object.entries(extraParams).forEach(([key, value]) => {
params.set(key, value)
})
}
const response = await fetch(`/api/logs?${params.toString()}`) const response = await fetch(`/api/logs?${params.toString()}`)
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch dashboard logs') throw new Error('Failed to fetch logs for dashboard')
} }
const data = await response.json() const apiData: LogsResponse = await response.json()
const logs = data.data || [] return apiData.data || []
const hasMore = logs.length === filters.limit
return {
logs,
hasMore,
nextPage: hasMore ? page + 1 : undefined,
}
} }
interface UseDashboardLogsOptions { interface UseDashboardLogsOptions {
enabled?: boolean enabled?: boolean
refetchInterval?: number | false
} }
export function useGlobalDashboardLogs( /**
filters: DashboardLogsFilters, * Hook for fetching all logs for dashboard metrics.
* Unlike useLogsList, this fetches all logs in a single request
* to ensure dashboard metrics are computed from complete data.
*/
export function useDashboardLogs(
workspaceId: string | undefined,
filters: Omit<LogFilters, 'limit'>,
options?: UseDashboardLogsOptions options?: UseDashboardLogsOptions
) { ) {
return useInfiniteQuery({ return useQuery({
queryKey: logKeys.globalLogs(filters.workspaceId, filters), queryKey: logKeys.dashboard(workspaceId, filters),
queryFn: ({ pageParam }) => fetchDashboardLogsPage(filters, pageParam), queryFn: () => fetchAllLogs(workspaceId as string, filters),
enabled: Boolean(filters.workspaceId) && (options?.enabled ?? true), enabled: Boolean(workspaceId) && (options?.enabled ?? true),
staleTime: 10 * 1000, // Slightly stale (10 seconds) refetchInterval: options?.refetchInterval ?? false,
initialPageParam: 1, staleTime: 0,
getNextPageParam: (lastPage) => lastPage.nextPage, placeholderData: keepPreviousData,
})
}
export function useWorkflowDashboardLogs(
workflowId: string | undefined,
filters: DashboardLogsFilters,
options?: UseDashboardLogsOptions
) {
return useInfiniteQuery({
queryKey: logKeys.workflowLogs(filters.workspaceId, workflowId, filters),
queryFn: ({ pageParam }) => fetchDashboardLogsPage(filters, pageParam, workflowId),
enabled: Boolean(filters.workspaceId) && Boolean(workflowId) && (options?.enabled ?? true),
staleTime: 10 * 1000, // Slightly stale (10 seconds)
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextPage,
}) })
} }

View File

@@ -8,7 +8,6 @@ const getSearchParams = () => {
const updateURL = (params: URLSearchParams) => { const updateURL = (params: URLSearchParams) => {
if (typeof window === 'undefined') return if (typeof window === 'undefined') return
const url = new URL(window.location.href) const url = new URL(window.location.href)
url.search = params.toString() url.search = params.toString()
window.history.replaceState({}, '', url) window.history.replaceState({}, '', url)
@@ -45,14 +44,12 @@ const parseTimeRangeFromURL = (value: string | null): TimeRange => {
const parseLogLevelFromURL = (value: string | null): LogLevel => { const parseLogLevelFromURL = (value: string | null): LogLevel => {
if (!value) return 'all' if (!value) return 'all'
// Support comma-separated values for multiple selections
const levels = value.split(',').filter(Boolean) const levels = value.split(',').filter(Boolean)
const validLevels = levels.filter( const validLevels = levels.filter(
(l) => l === 'error' || l === 'info' || l === 'running' || l === 'pending' (l) => l === 'error' || l === 'info' || l === 'running' || l === 'pending'
) )
if (validLevels.length === 0) return 'all' if (validLevels.length === 0) return 'all'
if (validLevels.length === 1) return validLevels[0] as LogLevel if (validLevels.length === 1) return validLevels[0] as LogLevel
// Return comma-separated string for multiple selections
return validLevels.join(',') as LogLevel return validLevels.join(',') as LogLevel
} }
@@ -102,7 +99,7 @@ export const useFilterStore = create<FilterState>((set, get) => ({
folderIds: [], folderIds: [],
searchQuery: '', searchQuery: '',
triggers: [], triggers: [],
_isInitializing: false, // Internal flag to prevent URL sync during initialization isInitializing: false,
setWorkspaceId: (workspaceId) => set({ workspaceId }), setWorkspaceId: (workspaceId) => set({ workspaceId }),
@@ -110,21 +107,21 @@ export const useFilterStore = create<FilterState>((set, get) => ({
setTimeRange: (timeRange) => { setTimeRange: (timeRange) => {
set({ timeRange }) set({ timeRange })
if (!get()._isInitializing) { if (!get().isInitializing) {
get().syncWithURL() get().syncWithURL()
} }
}, },
setLevel: (level) => { setLevel: (level) => {
set({ level }) set({ level })
if (!get()._isInitializing) { if (!get().isInitializing) {
get().syncWithURL() get().syncWithURL()
} }
}, },
setWorkflowIds: (workflowIds) => { setWorkflowIds: (workflowIds) => {
set({ workflowIds }) set({ workflowIds })
if (!get()._isInitializing) { if (!get().isInitializing) {
get().syncWithURL() get().syncWithURL()
} }
}, },
@@ -140,14 +137,14 @@ export const useFilterStore = create<FilterState>((set, get) => ({
} }
set({ workflowIds: currentWorkflowIds }) set({ workflowIds: currentWorkflowIds })
if (!get()._isInitializing) { if (!get().isInitializing) {
get().syncWithURL() get().syncWithURL()
} }
}, },
setFolderIds: (folderIds) => { setFolderIds: (folderIds) => {
set({ folderIds }) set({ folderIds })
if (!get()._isInitializing) { if (!get().isInitializing) {
get().syncWithURL() get().syncWithURL()
} }
}, },
@@ -163,21 +160,21 @@ export const useFilterStore = create<FilterState>((set, get) => ({
} }
set({ folderIds: currentFolderIds }) set({ folderIds: currentFolderIds })
if (!get()._isInitializing) { if (!get().isInitializing) {
get().syncWithURL() get().syncWithURL()
} }
}, },
setSearchQuery: (searchQuery) => { setSearchQuery: (searchQuery) => {
set({ searchQuery }) set({ searchQuery })
if (!get()._isInitializing) { if (!get().isInitializing) {
get().syncWithURL() get().syncWithURL()
} }
}, },
setTriggers: (triggers: TriggerType[]) => { setTriggers: (triggers: TriggerType[]) => {
set({ triggers }) set({ triggers })
if (!get()._isInitializing) { if (!get().isInitializing) {
get().syncWithURL() get().syncWithURL()
} }
}, },
@@ -193,16 +190,15 @@ export const useFilterStore = create<FilterState>((set, get) => ({
} }
set({ triggers: currentTriggers }) set({ triggers: currentTriggers })
if (!get()._isInitializing) { if (!get().isInitializing) {
get().syncWithURL() get().syncWithURL()
} }
}, },
initializeFromURL: () => { initializeFromURL: () => {
set({ _isInitializing: true }) set({ isInitializing: true })
const params = getSearchParams() const params = getSearchParams()
const timeRange = parseTimeRangeFromURL(params.get('timeRange')) const timeRange = parseTimeRangeFromURL(params.get('timeRange'))
const level = parseLogLevelFromURL(params.get('level')) const level = parseLogLevelFromURL(params.get('level'))
const workflowIds = parseStringArrayFromURL(params.get('workflowIds')) const workflowIds = parseStringArrayFromURL(params.get('workflowIds'))
@@ -217,7 +213,7 @@ export const useFilterStore = create<FilterState>((set, get) => ({
folderIds, folderIds,
triggers, triggers,
searchQuery, searchQuery,
_isInitializing: false, isInitializing: false,
}) })
}, },

View File

@@ -165,28 +165,22 @@ export type TimeRange =
| 'Past 14 days' | 'Past 14 days'
| 'Past 30 days' | 'Past 30 days'
| 'All time' | 'All time'
export type LogLevel = 'error' | 'info' | 'running' | 'pending' | 'all'
export type LogLevel = 'error' | 'info' | 'running' | 'pending' | 'all' | (string & {})
export type TriggerType = 'chat' | 'api' | 'webhook' | 'manual' | 'schedule' | 'all' | string export type TriggerType = 'chat' | 'api' | 'webhook' | 'manual' | 'schedule' | 'all' | string
/** Filter state for logs and dashboard views */
export interface FilterState { export interface FilterState {
// Workspace context
workspaceId: string workspaceId: string
// View mode
viewMode: 'logs' | 'dashboard' viewMode: 'logs' | 'dashboard'
// Filter states
timeRange: TimeRange timeRange: TimeRange
level: LogLevel level: LogLevel
workflowIds: string[] workflowIds: string[]
folderIds: string[] folderIds: string[]
searchQuery: string searchQuery: string
triggers: TriggerType[] triggers: TriggerType[]
isInitializing: boolean
// Internal state
_isInitializing: boolean
// Actions
setWorkspaceId: (workspaceId: string) => void setWorkspaceId: (workspaceId: string) => void
setViewMode: (viewMode: 'logs' | 'dashboard') => void setViewMode: (viewMode: 'logs' | 'dashboard') => void
setTimeRange: (timeRange: TimeRange) => void setTimeRange: (timeRange: TimeRange) => void
@@ -198,8 +192,6 @@ export interface FilterState {
setSearchQuery: (query: string) => void setSearchQuery: (query: string) => void
setTriggers: (triggers: TriggerType[]) => void setTriggers: (triggers: TriggerType[]) => void
toggleTrigger: (trigger: TriggerType) => void toggleTrigger: (trigger: TriggerType) => void
// URL synchronization methods
initializeFromURL: () => void initializeFromURL: () => void
syncWithURL: () => void syncWithURL: () => void
} }