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(),
triggers: z.string().optional(),
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 }> }) {
@@ -29,17 +33,18 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
}
const userId = session.user.id
const end = qp.endTime ? new Date(qp.endTime) : new Date()
const start = qp.startTime
let end = qp.endTime ? new Date(qp.endTime) : new Date()
let start = qp.startTime
? new Date(qp.startTime)
: 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 })
}
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
.select()
@@ -75,23 +80,18 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
workflows: [],
startTime: start.toISOString(),
endTime: end.toISOString(),
segmentMs,
segmentMs: 0,
})
}
const workflowIdList = workflows.map((w) => w.id)
const logWhere = [
inArray(workflowExecutionLogs.workflowId, workflowIdList),
gte(workflowExecutionLogs.startedAt, start),
lte(workflowExecutionLogs.startedAt, end),
] as SQL[]
const baseLogWhere = [inArray(workflowExecutionLogs.workflowId, workflowIdList)] as SQL[]
if (qp.triggers) {
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') {
const levels = qp.level.split(',').filter(Boolean)
const levelConditions: SQL[] = []
@@ -100,21 +100,18 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
if (level === 'error') {
levelConditions.push(eq(workflowExecutionLogs.level, 'error'))
} else if (level === 'info') {
// Completed info logs only
const condition = and(
eq(workflowExecutionLogs.level, 'info'),
isNotNull(workflowExecutionLogs.endedAt)
)
if (condition) levelConditions.push(condition)
} else if (level === 'running') {
// Running logs: info level with no endedAt
const condition = and(
eq(workflowExecutionLogs.level, 'info'),
isNull(workflowExecutionLogs.endedAt)
)
if (condition) levelConditions.push(condition)
} else if (level === 'pending') {
// Pending logs: info level with pause status indicators
const condition = and(
eq(workflowExecutionLogs.level, 'info'),
or(
@@ -132,10 +129,55 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
if (levelConditions.length > 0) {
const combinedCondition =
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
.select({
workflowId: workflowExecutionLogs.workflowId,

View File

@@ -1,2 +1 @@
export type { LineChartMultiSeries, LineChartPoint } from './line-chart'
export { default, LineChart } from './line-chart'
export { default, LineChart, type LineChartMultiSeries, type LineChartPoint } 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 { formatDate, formatLatency } from '@/app/workspace/[workspaceId]/logs/utils'
@@ -15,7 +15,7 @@ export interface LineChartMultiSeries {
dashed?: boolean
}
export function LineChart({
function LineChartComponent({
data,
label,
color,
@@ -95,6 +95,133 @@ export function LineChart({
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) {
return (
<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 (
<div
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 isHovered = hoverSeriesId ? hoverSeriesId === s.id : false
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

View File

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

View File

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

View File

@@ -2,24 +2,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Skeleton } from '@/components/ui/skeleton'
import {
formatLatency,
mapToExecutionLog,
mapToExecutionLogAlt,
} from '@/app/workspace/[workspaceId]/logs/utils'
import {
useExecutionsMetrics,
useGlobalDashboardLogs,
useWorkflowDashboardLogs,
} from '@/hooks/queries/logs'
import { formatLatency, parseDuration } from '@/app/workspace/[workspaceId]/logs/utils'
import { useFilterStore } from '@/stores/logs/filters/store'
import type { WorkflowLog } from '@/stores/logs/filters/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { LineChart, WorkflowsList } from './components'
type TimeFilter = '30m' | '1h' | '6h' | '12h' | '24h' | '3d' | '7d' | '14d' | '30d'
interface WorkflowExecution {
workflowId: string
workflowName: string
@@ -39,18 +28,12 @@ interface WorkflowExecution {
const DEFAULT_SEGMENTS = 72
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 = [
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 }) {
return (
<div className='flex flex-col overflow-hidden rounded-[6px] bg-[var(--surface-elevated)]'>
@@ -62,7 +45,6 @@ function GraphCardSkeleton({ title }: { title: string }) {
</div>
<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]'>
{/* Skeleton bars simulating chart */}
<div className='flex items-end gap-[2px]'>
{SKELETON_BAR_HEIGHTS.map((height, i) => (
<Skeleton
@@ -80,24 +62,16 @@ function GraphCardSkeleton({ title }: { title: string }) {
)
}
/**
* Skeleton loader for a workflow row in the workflows list
*/
function WorkflowRowSkeleton() {
return (
<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]'>
<Skeleton className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]' />
<Skeleton className='h-[16px] flex-1' />
</div>
{/* Status bar - takes most of the space */}
<div className='flex-1'>
<Skeleton className='h-[24px] w-full rounded-[4px]' />
</div>
{/* Success rate */}
<div className='w-[100px] flex-shrink-0 pl-[16px]'>
<Skeleton className='h-[16px] w-[50px]' />
</div>
@@ -105,13 +79,9 @@ function WorkflowRowSkeleton() {
)
}
/**
* Skeleton loader for the workflows list table
*/
function WorkflowsListSkeleton({ rowCount = 5 }: { rowCount?: number }) {
return (
<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 items-center gap-[16px]'>
<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>
</div>
</div>
{/* Table body - scrollable */}
<div className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden'>
{Array.from({ length: rowCount }).map((_, i) => (
<WorkflowRowSkeleton key={i} />
@@ -134,13 +102,9 @@ function WorkflowsListSkeleton({ rowCount = 5 }: { rowCount?: number }) {
)
}
/**
* Complete skeleton loader for the entire dashboard
*/
function DashboardSkeleton() {
return (
<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='grid grid-cols-1 gap-[16px] md:grid-cols-3'>
<GraphCardSkeleton title='Runs' />
@@ -148,8 +112,6 @@ function DashboardSkeleton() {
<GraphCardSkeleton title='Latency' />
</div>
</div>
{/* Workflows Table - takes remaining space */}
<div className='min-h-0 flex-1 overflow-hidden'>
<WorkflowsListSkeleton rowCount={14} />
</div>
@@ -158,225 +120,237 @@ function DashboardSkeleton() {
}
interface DashboardProps {
isLive?: boolean
refreshTrigger?: number
onCustomTimeRangeChange?: (isCustom: boolean) => void
logs: WorkflowLog[]
isLoading: boolean
error?: Error | null
}
export default function Dashboard({
isLive = false,
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)
export default function Dashboard({ logs, isLoading, error }: DashboardProps) {
const [segmentCount, setSegmentCount] = useState<number>(DEFAULT_SEGMENTS)
const [selectedSegments, setSelectedSegments] = 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 {
workflowIds,
folderIds,
triggers,
timeRange: sidebarTimeRange,
level,
searchQuery,
} = useFilterStore()
const { workflowIds, searchQuery, toggleWorkflowId, timeRange } = 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 start = new Date(endTime)
const lastExecutionByWorkflow = useMemo(() => {
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) {
case '30m':
start.setMinutes(endTime.getMinutes() - 30)
break
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)
const timeBounds = useMemo(() => {
if (logs.length === 0) {
const now = new Date()
return { start: now, end: now }
}
return start
}, [endTime, timeFilter])
let minTime = Number.POSITIVE_INFINITY
let maxTime = Number.NEGATIVE_INFINITY
const metricsFilters = useMemo(
() => ({
workspaceId,
segments: segmentCount || DEFAULT_SEGMENTS,
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]
)
for (const log of logs) {
const ts = new Date(log.createdAt).getTime()
if (ts < minTime) minTime = ts
if (ts > maxTime) maxTime = ts
}
const logsFilters = useMemo(
() => ({
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 end = new Date(Math.max(maxTime, Date.now()))
const start = new Date(minTime)
const metricsQuery = useExecutionsMetrics(metricsFilters, {
enabled: Boolean(workspaceId),
})
return { start, end }
}, [logs])
const globalLogsQuery = useGlobalDashboardLogs(logsFilters, {
enabled: Boolean(workspaceId),
})
const { executions, aggregateSegments, segmentMs } = useMemo(() => {
const allWorkflowsList = Object.values(allWorkflows)
const workflowLogsQuery = useWorkflowDashboardLogs(expandedWorkflowId ?? undefined, logsFilters, {
enabled: Boolean(workspaceId) && Boolean(expandedWorkflowId),
})
if (allWorkflowsList.length === 0) {
return { executions: [], aggregateSegments: [], segmentMs: 0 }
}
const executions = metricsQuery.data?.workflows ?? []
const aggregateSegments = metricsQuery.data?.aggregateSegments ?? []
const error = metricsQuery.error?.message ?? null
const { start, end } =
logs.length > 0
? timeBounds
: { start: new Date(Date.now() - 24 * 60 * 60 * 1000), end: new Date() }
/**
* Loading state logic using TanStack Query best practices:
* - isPending: true when there's no cached data (initial load only)
* - isFetching: true when any fetch is in progress
* - 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
const totalMs = Math.max(1, end.getTime() - start.getTime())
const calculatedSegmentMs = Math.max(
MIN_SEGMENT_MS,
Math.floor(totalMs / Math.max(1, segmentCount))
)
// Check if any filters are actually applied
const hasActiveFilters = useMemo(
() =>
level !== 'all' ||
workflowIds.length > 0 ||
folderIds.length > 0 ||
triggers.length > 0 ||
searchQuery.trim() !== '',
[level, workflowIds, folderIds, triggers, searchQuery]
)
const logsByWorkflow = new Map<string, WorkflowLog[]>()
for (const log of logs) {
const wfId = log.workflowId
if (!logsByWorkflow.has(wfId)) {
logsByWorkflow.set(wfId, [])
}
logsByWorkflow.get(wfId)!.push(log)
}
// Filter workflows based on search query and whether they have any executions matching the filters
const filteredExecutions = useMemo(() => {
let filtered = executions
const workflowExecutions: WorkflowExecution[] = []
// Only filter out workflows with no executions if filters are active
if (hasActiveFilters) {
filtered = filtered.filter((workflow) => {
const hasExecutions = workflow.segments.some((seg) => seg.hasExecutions === true)
return hasExecutions
for (const workflow of allWorkflowsList) {
const workflowLogs = logsByWorkflow.get(workflow.id) || []
const segments: WorkflowExecution['segments'] = Array.from(
{ 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
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase().trim()
filtered = filtered.filter((workflow) => workflow.workflowName.toLowerCase().includes(query))
}
// 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()
workflowExecutions.sort((a, b) => {
const errA = a.overallSuccessRate < 100 ? 1 - a.overallSuccessRate / 100 : 0
const errB = b.overallSuccessRate < 100 ? 1 - b.overallSuccessRate / 100 : 0
if (errA !== errB) return errB - errA
return a.workflowName.localeCompare(b.workflowName)
})
return filtered
}, [executions, searchQuery, hasActiveFilters, workflows])
const aggSegments: {
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(() => {
if (!globalLogsQuery.data?.pages) return []
return globalLogsQuery.data.pages.flatMap((page) => page.logs).map(mapToExecutionLog)
}, [globalLogsQuery.data?.pages])
const weightedDurationSums: number[] = Array(segmentCount).fill(0)
const executionCounts: number[] = Array(segmentCount).fill(0)
const workflowLogs = useMemo(() => {
if (!workflowLogsQuery.data?.pages) return []
return workflowLogsQuery.data.pages.flatMap((page) => page.logs).map(mapToExecutionLogAlt)
}, [workflowLogsQuery.data?.pages])
for (const wf of workflowExecutions) {
wf.segments.forEach((s, i) => {
aggSegments[i].totalExecutions += s.totalExecutions
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(() => {
if (!aggregateSegments.length) return null
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
? (() => {
// Get all selected segment indices across all workflows
const allSelectedIndices = new Set<number>()
Object.values(selectedSegments).forEach((indices) => {
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)
.sort((a, b) => a - b)
.map((idx) => {
@@ -386,11 +360,8 @@ export default function Dashboard({
let latencyCount = 0
const timestamp = aggregateSegments[idx]?.timestamp || ''
// Sum up data from workflows that have this segment selected
Object.entries(selectedSegments).forEach(([workflowId, indices]) => {
if (!indices.includes(idx)) return
// If workflow filter is active, skip other workflows
if (hasWorkflowFilter && workflowId !== expandedWorkflowId) return
const workflow = filteredExecutions.find((w) => w.workflowId === workflowId)
@@ -416,7 +387,6 @@ export default function Dashboard({
})()
: hasWorkflowFilter
? (() => {
// Filter to show only the expanded workflow's data
const workflow = filteredExecutions.find((w) => w.workflowId === expandedWorkflowId)
if (!workflow) return aggregateSegments
@@ -427,42 +397,7 @@ export default function Dashboard({
avgDurationMs: segment.avgDurationMs ?? 0,
}))
})()
: hasActiveFilters
? (() => {
// 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,
}))
: aggregateSegments
const executionCounts = segmentsToUse.map((s) => ({
timestamp: s.timestamp,
@@ -479,128 +414,46 @@ export default function Dashboard({
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 {
errorRates,
durations: [],
executionCounts,
failureCounts,
latencies,
logs: globalLogs,
allLogs: globalLogs,
}
}, [
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,
totalRuns,
totalErrors,
avgLatency,
}
}, [filteredExecutions, selectedSegments, expandedWorkflowId])
}, [aggregateSegments, selectedSegments, filteredExecutions, expandedWorkflowId])
const loadMoreLogs = useCallback(
const handleToggleWorkflow = useCallback(
(workflowId: string) => {
if (
workflowId === expandedWorkflowId &&
workflowLogsQuery.hasNextPage &&
!workflowLogsQuery.isFetchingNextPage
) {
workflowLogsQuery.fetchNextPage()
}
toggleWorkflowId(workflowId)
},
[expandedWorkflowId, workflowLogsQuery]
)
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]
[toggleWorkflowId]
)
/**
* 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(
(
workflowId: string,
@@ -618,22 +471,10 @@ export default function Dashboard({
if (nextSegments.length === 0) {
const { [workflowId]: _, ...rest } = prev
if (Object.keys(rest).length === 0) {
setExpandedWorkflowId(null)
}
return rest
}
const newState = { ...prev, [workflowId]: nextSegments }
const selectedWorkflowIds = Object.keys(newState)
if (selectedWorkflowIds.length > 1) {
setExpandedWorkflowId('__multi__')
} else if (selectedWorkflowIds.length === 1) {
setExpandedWorkflowId(selectedWorkflowIds[0])
}
return newState
return { ...prev, [workflowId]: nextSegments }
})
setLastAnchorIndices((prev) => ({ ...prev, [workflowId]: segmentIndex }))
@@ -645,85 +486,32 @@ export default function Dashboard({
const isOnlyWorkflowSelected = Object.keys(prev).length === 1 && prev[workflowId]
if (isOnlySelectedSegment && isOnlyWorkflowSelected) {
setExpandedWorkflowId(null)
setLastAnchorIndices({})
return {}
}
setExpandedWorkflowId(workflowId)
setLastAnchorIndices({ [workflowId]: segmentIndex })
return { [workflowId]: [segmentIndex] }
})
} else if (mode === 'range') {
if (expandedWorkflowId === workflowId) {
setSelectedSegments((prev) => {
const currentSegments = prev[workflowId] || []
const anchor = lastAnchorIndices[workflowId] ?? segmentIndex
const [start, end] =
anchor < segmentIndex ? [anchor, segmentIndex] : [segmentIndex, anchor]
const range = Array.from({ length: end - start + 1 }, (_, i) => start + i)
const union = new Set([...currentSegments, ...range])
return { ...prev, [workflowId]: Array.from(union).sort((a, b) => a - b) }
})
} else {
setExpandedWorkflowId(workflowId)
setSelectedSegments({ [workflowId]: [segmentIndex] })
setLastAnchorIndices({ [workflowId]: segmentIndex })
}
setSelectedSegments((prev) => {
const currentSegments = prev[workflowId] || []
const anchor = lastAnchorIndices[workflowId] ?? segmentIndex
const [start, end] =
anchor < segmentIndex ? [anchor, segmentIndex] : [segmentIndex, anchor]
const range = Array.from({ length: end - start + 1 }, (_, i) => start + i)
const union = new Set([...currentSegments, ...range])
return { ...prev, [workflowId]: Array.from(union).sort((a, b) => a - b) }
})
}
},
[expandedWorkflowId, lastAnchorIndices]
[lastAnchorIndices]
)
// Update endTime when filters change to ensure consistent time ranges with logs view
useEffect(() => {
setEndTime(new Date())
setSelectedSegments({})
setLastAnchorIndices({})
}, [timeFilter, workflowIds, folderIds, triggers, level, 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])
}, [logs, timeRange, workflowIds, searchQuery])
useEffect(() => {
if (!barsAreaRef.current) return
@@ -749,43 +537,30 @@ export default function Dashboard({
}
}, [])
// Live mode: refresh endTime periodically
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) {
if (isLoading && Object.keys(allWorkflows).length === 0) {
return <DashboardSkeleton />
}
// Show error state
if (error) {
return (
<div className='mt-[24px] flex flex-1 items-center justify-center'>
<div className='text-[var(--text-error)]'>
<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>
)
}
if (executions.length === 0) {
if (Object.keys(allWorkflows).length === 0) {
return (
<div className='mt-[24px] flex flex-1 items-center justify-center'>
<div className='text-center text-[var(--text-secondary)]'>
<p className='font-medium text-[13px]'>No execution history</p>
<p className='mt-[4px] text-[12px]'>Execute some workflows to see their history here</p>
<p className='font-medium text-[13px]'>No workflows</p>
<p className='mt-[4px] text-[12px]'>
Create a workflow to see its execution history here
</p>
</div>
</div>
)
@@ -793,18 +568,16 @@ export default function Dashboard({
return (
<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='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 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'>
Runs
</span>
{globalDetails && globalDetails.executionCounts.length > 0 && (
{globalDetails && (
<span className='flex-shrink-0 font-medium text-[var(--text-secondary)] text-sm'>
{aggregate.totalExecutions}
{globalDetails.totalRuns}
</span>
)}
</div>
@@ -824,15 +597,14 @@ export default function Dashboard({
</div>
</div>
{/* Errors Graph */}
<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]'>
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-sm'>
Errors
</span>
{globalDetails && globalDetails.failureCounts.length > 0 && (
{globalDetails && (
<span className='flex-shrink-0 font-medium text-[var(--text-secondary)] text-sm'>
{aggregate.failedExecutions}
{globalDetails.totalErrors}
</span>
)}
</div>
@@ -852,15 +624,14 @@ export default function Dashboard({
</div>
</div>
{/* Latency Graph */}
<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]'>
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-sm'>
Latency
</span>
{globalDetails && globalDetails.latencies.length > 0 && (
{globalDetails && (
<span className='flex-shrink-0 font-medium text-[var(--text-secondary)] text-sm'>
{formatLatency(aggregate.avgLatency)}
{formatLatency(globalDetails.avgLatency)}
</span>
)}
</div>
@@ -882,19 +653,15 @@ export default function Dashboard({
</div>
</div>
{/* Workflows Table - takes remaining space */}
<div className='min-h-0 flex-1 overflow-hidden' ref={barsAreaRef}>
<WorkflowsList
executions={executions as WorkflowExecution[]}
filteredExecutions={filteredExecutions as WorkflowExecution[]}
expandedWorkflowId={expandedWorkflowId}
onToggleWorkflow={toggleWorkflow}
onToggleWorkflow={handleToggleWorkflow}
selectedSegments={selectedSegments}
onSegmentClick={handleSegmentClick}
searchQuery={searchQuery}
segmentDurationMs={
(endTime.getTime() - getStartTime().getTime()) / Math.max(1, segmentCount)
}
segmentDurationMs={segmentMs}
/>
</div>
</div>

View File

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

View File

@@ -34,9 +34,6 @@ interface FileCardProps {
workspaceId?: string
}
/**
* Formats file size to human readable format
*/
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
@@ -45,9 +42,6 @@ function formatFileSize(bytes: number): string {
return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`
}
/**
* Individual file card component
*/
function FileCard({ file, isExecutionFile = false, workspaceId }: FileCardProps) {
const [isDownloading, setIsDownloading] = useState(false)
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) {
if (!files || files.length === 0) {
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({
file,
isExecutionFile = false,

View File

@@ -1,7 +1,7 @@
'use client'
import type React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { memo, useCallback, useMemo, useState } from 'react'
import clsx from 'clsx'
import { ChevronDown, Code } from '@/components/emcn'
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,
totalDuration,
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(() => {
if (!traceSpans || traceSpans.length === 0) {
return { workflowStartTime: 0, actualTotalDuration: totalDuration, normalizedSpans: [] }
@@ -827,4 +832,4 @@ export function TraceSpans({ traceSpans, totalDuration = 0 }: TraceSpansProps) {
</div>
</div>
)
}
})

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,17 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { AlertCircle, ArrowUpRight, Loader2 } from 'lucide-react'
import Link from 'next/link'
import { AlertCircle, Loader2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Badge, buttonVariants } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
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 { useFilterStore } from '@/stores/logs/filters/store'
import type { WorkflowLog } from '@/stores/logs/filters/types'
import { useUserPermissionsContext } from '../providers/workspace-permissions-provider'
import { Dashboard, LogDetails, LogsToolbar, NotificationSettings } from './components'
import { formatDate, formatDuration, StatusBadge, TriggerBadge } from './utils'
import { Dashboard, LogDetails, LogsList, LogsToolbar, NotificationSettings } from './components'
const LOGS_PER_PAGE = 50 as const
const REFRESH_SPINNER_DURATION_MS = 1000 as const
@@ -56,20 +53,17 @@ export default function Logs() {
const [searchQuery, setSearchQuery] = useState('')
const debouncedSearchQuery = useDebounce(searchQuery, 300)
// Sync search query from URL on mount (client-side only)
useEffect(() => {
const urlSearch = new URLSearchParams(window.location.search).get('search') || ''
if (urlSearch && urlSearch !== searchQuery) {
setSearchQuery(urlSearch)
}
// Only run on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const [isLive, setIsLive] = useState(false)
const [isVisuallyRefreshing, setIsVisuallyRefreshing] = useState(false)
const [isExporting, setIsExporting] = useState(false)
const [dashboardRefreshTrigger, setDashboardRefreshTrigger] = useState(0)
const isSearchOpenRef = useRef<boolean>(false)
const [isNotificationSettingsOpen, setIsNotificationSettingsOpen] = useState(false)
const userPermissions = useUserPermissionsContext()
@@ -92,8 +86,31 @@ export default function Logs() {
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 mergedSelectedLog = useMemo(() => {
if (!selectedLog) return null
if (!logDetailQuery.data) return selectedLog
return { ...selectedLog, ...logDetailQuery.data }
}, [selectedLog, logDetailQuery.data])
const logs = useMemo(() => {
if (!logsQuery.data?.pages) return []
return logsQuery.data.pages.flatMap((page) => page.logs)
@@ -107,10 +124,8 @@ export default function Logs() {
}
}, [debouncedSearchQuery, setStoreSearchQuery])
// Track previous log state for efficient change detection
const prevSelectedLogRef = useRef<WorkflowLog | null>(null)
// Sync selected log with refreshed data from logs list
useEffect(() => {
if (!selectedLog?.id || logs.length === 0) return
@@ -119,32 +134,27 @@ export default function Logs() {
const prevLog = prevSelectedLogRef.current
// Check if status-related fields have changed (e.g., running -> done)
const hasStatusChange =
prevLog?.id === updatedLog.id &&
(updatedLog.duration !== prevLog.duration ||
updatedLog.level !== prevLog.level ||
updatedLog.hasPendingPause !== prevLog.hasPendingPause)
// Only update if the log data actually changed
if (updatedLog !== selectedLog) {
setSelectedLog(updatedLog)
prevSelectedLogRef.current = updatedLog
}
// Update index in case position changed
const newIndex = logs.findIndex((l) => l.id === selectedLog.id)
if (newIndex !== selectedLogIndex) {
setSelectedLogIndex(newIndex)
}
// Refetch log details if status changed to keep details panel in sync
if (hasStatusChange) {
logDetailQuery.refetch()
}
}, [logs, selectedLog?.id, selectedLogIndex, logDetailQuery.refetch])
}, [logs, selectedLog?.id, selectedLogIndex, logDetailQuery])
// Refetch log details during live mode
useEffect(() => {
if (!isLive || !selectedLog?.id) return
@@ -155,20 +165,24 @@ export default function Logs() {
return () => clearInterval(interval)
}, [isLive, selectedLog?.id, logDetailQuery])
const handleLogClick = (log: WorkflowLog) => {
// If clicking on the same log that's already selected and sidebar is open, close it
if (selectedLog?.id === log.id && isSidebarOpen) {
handleCloseSidebar()
return
}
const handleLogClick = useCallback(
(log: WorkflowLog) => {
if (selectedLog?.id === log.id && isSidebarOpen) {
setIsSidebarOpen(false)
setSelectedLog(null)
setSelectedLogIndex(-1)
prevSelectedLogRef.current = null
return
}
// Otherwise, select the log and open the sidebar
setSelectedLog(log)
prevSelectedLogRef.current = log
const index = logs.findIndex((l) => l.id === log.id)
setSelectedLogIndex(index)
setIsSidebarOpen(true)
}
setSelectedLog(log)
prevSelectedLogRef.current = log
const index = logs.findIndex((l) => l.id === log.id)
setSelectedLogIndex(index)
setIsSidebarOpen(true)
},
[selectedLog?.id, isSidebarOpen, logs]
)
const handleNavigateNext = useCallback(() => {
if (selectedLogIndex < logs.length - 1) {
@@ -190,12 +204,12 @@ export default function Logs() {
}
}, [selectedLogIndex, logs])
const handleCloseSidebar = () => {
const handleCloseSidebar = useCallback(() => {
setIsSidebarOpen(false)
setSelectedLog(null)
setSelectedLogIndex(-1)
prevSelectedLogRef.current = null
}
}, [])
useEffect(() => {
if (selectedRowRef.current) {
@@ -213,8 +227,6 @@ export default function Logs() {
if (selectedLog?.id) {
logDetailQuery.refetch()
}
// Also trigger dashboard refresh
setDashboardRefreshTrigger((prev) => prev + 1)
}, [logsQuery, logDetailQuery, selectedLog?.id])
const handleToggleLive = useCallback(() => {
@@ -225,8 +237,6 @@ export default function Logs() {
setIsVisuallyRefreshing(true)
setTimeout(() => setIsVisuallyRefreshing(false), REFRESH_SPINNER_DURATION_MS)
logsQuery.refetch()
// Also trigger dashboard refresh
setDashboardRefreshTrigger((prev) => prev + 1)
}
}, [isLive, logsQuery])
@@ -292,62 +302,6 @@ export default function Logs() {
}
}, [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(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (isSearchOpenRef.current) return
@@ -408,11 +362,15 @@ export default function Logs() {
/>
</div>
{/* Dashboard view - always mounted to preserve state and query cache */}
{/* Dashboard view - uses all logs (non-paginated) for accurate metrics */}
<div
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>
{/* Main content area with table - only show in logs view */}
@@ -451,11 +409,8 @@ export default function Logs() {
</div>
</div>
{/* Table body - scrollable */}
<div
className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden'
ref={scrollContainerRef}
>
{/* Table body - virtualized */}
<div className='min-h-0 flex-1 overflow-hidden' ref={scrollContainerRef}>
{logsQuery.isLoading && !logsQuery.data ? (
<div className='flex h-full items-center justify-center'>
<div className='flex items-center gap-[8px] text-[var(--text-secondary)]'>
@@ -479,137 +434,23 @@ export default function Logs() {
</div>
</div>
) : (
<div>
{logs.map((log) => {
const formattedDate = formatDate(log.createdAt)
const isSelected = selectedLog?.id === log.id
const baseLevel = (log.level || 'info').toLowerCase()
const isError = baseLevel === 'error'
const isPending = !isError && log.hasPendingPause === true
const isRunning = !isError && !isPending && log.duration === null
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>
<LogsList
logs={logs}
selectedLogId={selectedLog?.id ?? null}
onLogClick={handleLogClick}
selectedRowRef={selectedRowRef}
hasNextPage={logsQuery.hasNextPage ?? false}
isFetchingNextPage={logsQuery.isFetchingNextPage}
onLoadMore={loadMoreLogs}
loaderRef={loaderRef}
/>
)}
</div>
</div>
{/* Log Details - rendered inside table container */}
<LogDetails
log={logDetailQuery.data ? { ...selectedLog, ...logDetailQuery.data } : selectedLog}
log={mergedSelectedLog}
isOpen={isSidebarOpen}
onClose={handleCloseSidebar}
onNavigateNext={handleNavigateNext}

View File

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

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='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='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>
<Panel />
</div>

View File

@@ -117,7 +117,16 @@ export default function WorkspacePage() {
if (isPending) {
return (
<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>
)
}

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 { Layout } from './layout'
export { Library } from './library'
export { Loader } from './loader'
export { MoreHorizontal } from './more-horizontal'
export { NoWrap } from './no-wrap'
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,
details: () => [...logKeys.all, 'detail'] as const,
detail: (logId: string | undefined) => [...logKeys.details(), logId ?? ''] as const,
metrics: () => [...logKeys.all, 'metrics'] as const,
executions: (workspaceId: string | undefined, filters: Record<string, any>) =>
[...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,
dashboard: (workspaceId: string | undefined, filters: Record<string, unknown>) =>
[...logKeys.all, 'dashboard', workspaceId ?? '', filters] as const,
}
interface LogFilters {
@@ -31,6 +23,87 @@ interface LogFilters {
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(
workspaceId: string,
filters: LogFilters,
@@ -64,80 +137,6 @@ async function fetchLogDetail(logId: string): Promise<WorkflowLog> {
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 {
enabled?: boolean
refetchInterval?: number | false
@@ -153,8 +152,8 @@ export function useLogsList(
queryFn: ({ pageParam }) => fetchLogsPage(workspaceId as string, filters, pageParam),
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
refetchInterval: options?.refetchInterval ?? false,
staleTime: 0, // Always consider stale for real-time logs
placeholderData: keepPreviousData, // Keep showing old data while fetching new data
staleTime: 0,
placeholderData: keepPreviousData,
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextPage,
})
@@ -165,303 +164,60 @@ export function useLogDetail(logId: string | undefined) {
queryKey: logKeys.detail(logId),
queryFn: () => fetchLogDetail(logId as string),
enabled: Boolean(logId),
staleTime: 30 * 1000, // Details can be slightly stale (30 seconds)
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
})
}
interface WorkflowSegment {
timestamp: string
hasExecutions: boolean
totalExecutions: number
successfulExecutions: number
successRate: number
avgDurationMs?: number
p50Ms?: number
p90Ms?: number
p99Ms?: number
}
const DASHBOARD_LOGS_LIMIT = 10000
interface WorkflowExecution {
workflowId: string
workflowName: string
segments: WorkflowSegment[]
overallSuccessRate: number
}
/**
* Fetches all logs for dashboard metrics (non-paginated).
* Uses same filters as the logs list but with a high limit to get all data.
*/
async function fetchAllLogs(
workspaceId: string,
filters: Omit<LogFilters, 'limit'>
): Promise<WorkflowLog[]> {
const params = new URLSearchParams()
interface AggregateSegment {
timestamp: string
totalExecutions: number
successfulExecutions: number
avgDurationMs?: number
}
params.set('workspaceId', workspaceId)
params.set('limit', DASHBOARD_LOGS_LIMIT.toString())
params.set('offset', '0')
interface ExecutionsMetricsResponse {
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)
})
}
applyFilterParams(params, filters)
const response = await fetch(`/api/logs?${params.toString()}`)
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 logs = data.data || []
const hasMore = logs.length === filters.limit
return {
logs,
hasMore,
nextPage: hasMore ? page + 1 : undefined,
}
const apiData: LogsResponse = await response.json()
return apiData.data || []
}
interface UseDashboardLogsOptions {
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
) {
return useInfiniteQuery({
queryKey: logKeys.globalLogs(filters.workspaceId, filters),
queryFn: ({ pageParam }) => fetchDashboardLogsPage(filters, pageParam),
enabled: Boolean(filters.workspaceId) && (options?.enabled ?? true),
staleTime: 10 * 1000, // Slightly stale (10 seconds)
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextPage,
})
}
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,
return useQuery({
queryKey: logKeys.dashboard(workspaceId, filters),
queryFn: () => fetchAllLogs(workspaceId as string, filters),
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
refetchInterval: options?.refetchInterval ?? false,
staleTime: 0,
placeholderData: keepPreviousData,
})
}

View File

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

View File

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