mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 06:58:07 -05:00
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:
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}%`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { LogsList, type LogsListProps } from './logs-list'
|
||||
@@ -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
|
||||
@@ -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 === ' ') {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
20
apps/sim/components/emcn/icons/animate/loader.module.css
Normal file
20
apps/sim/components/emcn/icons/animate/loader.module.css
Normal 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;
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
42
apps/sim/components/emcn/icons/loader.tsx
Normal file
42
apps/sim/components/emcn/icons/loader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user