mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -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(),
|
folderIds: z.string().optional(),
|
||||||
triggers: z.string().optional(),
|
triggers: z.string().optional(),
|
||||||
level: z.string().optional(), // Supports comma-separated values: 'error,running'
|
level: z.string().optional(), // Supports comma-separated values: 'error,running'
|
||||||
|
allTime: z
|
||||||
|
.enum(['true', 'false'])
|
||||||
|
.optional()
|
||||||
|
.transform((v) => v === 'true'),
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
@@ -29,17 +33,18 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
|||||||
}
|
}
|
||||||
const userId = session.user.id
|
const userId = session.user.id
|
||||||
|
|
||||||
const end = qp.endTime ? new Date(qp.endTime) : new Date()
|
let end = qp.endTime ? new Date(qp.endTime) : new Date()
|
||||||
const start = qp.startTime
|
let start = qp.startTime
|
||||||
? new Date(qp.startTime)
|
? new Date(qp.startTime)
|
||||||
: new Date(end.getTime() - 24 * 60 * 60 * 1000)
|
: new Date(end.getTime() - 24 * 60 * 60 * 1000)
|
||||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || start >= end) {
|
|
||||||
|
const isAllTime = qp.allTime === true
|
||||||
|
|
||||||
|
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||||
return NextResponse.json({ error: 'Invalid time range' }, { status: 400 })
|
return NextResponse.json({ error: 'Invalid time range' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const segments = qp.segments
|
const segments = qp.segments
|
||||||
const totalMs = Math.max(1, end.getTime() - start.getTime())
|
|
||||||
const segmentMs = Math.max(1, Math.floor(totalMs / Math.max(1, segments)))
|
|
||||||
|
|
||||||
const [permission] = await db
|
const [permission] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -75,23 +80,18 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
|||||||
workflows: [],
|
workflows: [],
|
||||||
startTime: start.toISOString(),
|
startTime: start.toISOString(),
|
||||||
endTime: end.toISOString(),
|
endTime: end.toISOString(),
|
||||||
segmentMs,
|
segmentMs: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const workflowIdList = workflows.map((w) => w.id)
|
const workflowIdList = workflows.map((w) => w.id)
|
||||||
|
|
||||||
const logWhere = [
|
const baseLogWhere = [inArray(workflowExecutionLogs.workflowId, workflowIdList)] as SQL[]
|
||||||
inArray(workflowExecutionLogs.workflowId, workflowIdList),
|
|
||||||
gte(workflowExecutionLogs.startedAt, start),
|
|
||||||
lte(workflowExecutionLogs.startedAt, end),
|
|
||||||
] as SQL[]
|
|
||||||
if (qp.triggers) {
|
if (qp.triggers) {
|
||||||
const t = qp.triggers.split(',').filter(Boolean)
|
const t = qp.triggers.split(',').filter(Boolean)
|
||||||
logWhere.push(inArray(workflowExecutionLogs.trigger, t))
|
baseLogWhere.push(inArray(workflowExecutionLogs.trigger, t))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle level filtering with support for derived statuses and multiple selections
|
|
||||||
if (qp.level && qp.level !== 'all') {
|
if (qp.level && qp.level !== 'all') {
|
||||||
const levels = qp.level.split(',').filter(Boolean)
|
const levels = qp.level.split(',').filter(Boolean)
|
||||||
const levelConditions: SQL[] = []
|
const levelConditions: SQL[] = []
|
||||||
@@ -100,21 +100,18 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
|||||||
if (level === 'error') {
|
if (level === 'error') {
|
||||||
levelConditions.push(eq(workflowExecutionLogs.level, 'error'))
|
levelConditions.push(eq(workflowExecutionLogs.level, 'error'))
|
||||||
} else if (level === 'info') {
|
} else if (level === 'info') {
|
||||||
// Completed info logs only
|
|
||||||
const condition = and(
|
const condition = and(
|
||||||
eq(workflowExecutionLogs.level, 'info'),
|
eq(workflowExecutionLogs.level, 'info'),
|
||||||
isNotNull(workflowExecutionLogs.endedAt)
|
isNotNull(workflowExecutionLogs.endedAt)
|
||||||
)
|
)
|
||||||
if (condition) levelConditions.push(condition)
|
if (condition) levelConditions.push(condition)
|
||||||
} else if (level === 'running') {
|
} else if (level === 'running') {
|
||||||
// Running logs: info level with no endedAt
|
|
||||||
const condition = and(
|
const condition = and(
|
||||||
eq(workflowExecutionLogs.level, 'info'),
|
eq(workflowExecutionLogs.level, 'info'),
|
||||||
isNull(workflowExecutionLogs.endedAt)
|
isNull(workflowExecutionLogs.endedAt)
|
||||||
)
|
)
|
||||||
if (condition) levelConditions.push(condition)
|
if (condition) levelConditions.push(condition)
|
||||||
} else if (level === 'pending') {
|
} else if (level === 'pending') {
|
||||||
// Pending logs: info level with pause status indicators
|
|
||||||
const condition = and(
|
const condition = and(
|
||||||
eq(workflowExecutionLogs.level, 'info'),
|
eq(workflowExecutionLogs.level, 'info'),
|
||||||
or(
|
or(
|
||||||
@@ -132,10 +129,55 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
|||||||
if (levelConditions.length > 0) {
|
if (levelConditions.length > 0) {
|
||||||
const combinedCondition =
|
const combinedCondition =
|
||||||
levelConditions.length === 1 ? levelConditions[0] : or(...levelConditions)
|
levelConditions.length === 1 ? levelConditions[0] : or(...levelConditions)
|
||||||
if (combinedCondition) logWhere.push(combinedCondition)
|
if (combinedCondition) baseLogWhere.push(combinedCondition)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isAllTime) {
|
||||||
|
const boundsQuery = db
|
||||||
|
.select({
|
||||||
|
minDate: sql<Date>`MIN(${workflowExecutionLogs.startedAt})`,
|
||||||
|
maxDate: sql<Date>`MAX(${workflowExecutionLogs.startedAt})`,
|
||||||
|
})
|
||||||
|
.from(workflowExecutionLogs)
|
||||||
|
.leftJoin(
|
||||||
|
pausedExecutions,
|
||||||
|
eq(pausedExecutions.executionId, workflowExecutionLogs.executionId)
|
||||||
|
)
|
||||||
|
.where(and(...baseLogWhere))
|
||||||
|
|
||||||
|
const [bounds] = await boundsQuery
|
||||||
|
|
||||||
|
if (bounds?.minDate && bounds?.maxDate) {
|
||||||
|
start = new Date(bounds.minDate)
|
||||||
|
end = new Date(Math.max(new Date(bounds.maxDate).getTime(), Date.now()))
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({
|
||||||
|
workflows: workflows.map((wf) => ({
|
||||||
|
workflowId: wf.id,
|
||||||
|
workflowName: wf.name,
|
||||||
|
segments: [],
|
||||||
|
})),
|
||||||
|
startTime: new Date().toISOString(),
|
||||||
|
endTime: new Date().toISOString(),
|
||||||
|
segmentMs: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start >= end) {
|
||||||
|
return NextResponse.json({ error: 'Invalid time range' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalMs = Math.max(1, end.getTime() - start.getTime())
|
||||||
|
const segmentMs = Math.max(1, Math.floor(totalMs / Math.max(1, segments)))
|
||||||
|
|
||||||
|
const logWhere = [
|
||||||
|
...baseLogWhere,
|
||||||
|
gte(workflowExecutionLogs.startedAt, start),
|
||||||
|
lte(workflowExecutionLogs.startedAt, end),
|
||||||
|
]
|
||||||
|
|
||||||
const logs = await db
|
const logs = await db
|
||||||
.select({
|
.select({
|
||||||
workflowId: workflowExecutionLogs.workflowId,
|
workflowId: workflowExecutionLogs.workflowId,
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
export type { LineChartMultiSeries, LineChartPoint } from './line-chart'
|
export { default, LineChart, type LineChartMultiSeries, type LineChartPoint } from './line-chart'
|
||||||
export { default, LineChart } from './line-chart'
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { memo, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import { formatDate, formatLatency } from '@/app/workspace/[workspaceId]/logs/utils'
|
import { formatDate, formatLatency } from '@/app/workspace/[workspaceId]/logs/utils'
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ export interface LineChartMultiSeries {
|
|||||||
dashed?: boolean
|
dashed?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LineChart({
|
function LineChartComponent({
|
||||||
data,
|
data,
|
||||||
label,
|
label,
|
||||||
color,
|
color,
|
||||||
@@ -95,6 +95,133 @@ export function LineChart({
|
|||||||
|
|
||||||
const hasExternalWrapper = !label || label === ''
|
const hasExternalWrapper = !label || label === ''
|
||||||
|
|
||||||
|
const allSeries = useMemo(
|
||||||
|
() =>
|
||||||
|
(Array.isArray(series) && series.length > 0
|
||||||
|
? [{ id: 'base', label, color, data }, ...series]
|
||||||
|
: [{ id: 'base', label, color, data }]
|
||||||
|
).map((s, idx) => ({ ...s, id: s.id || s.label || String(idx) })),
|
||||||
|
[series, label, color, data]
|
||||||
|
)
|
||||||
|
|
||||||
|
const { maxValue, minValue, valueRange } = useMemo(() => {
|
||||||
|
const flatValues = allSeries.flatMap((s) => s.data.map((d) => d.value))
|
||||||
|
const rawMax = Math.max(...flatValues, 1)
|
||||||
|
const rawMin = Math.min(...flatValues, 0)
|
||||||
|
const paddedMax = rawMax === 0 ? 1 : rawMax * 1.1
|
||||||
|
const paddedMin = Math.min(0, rawMin)
|
||||||
|
const unitSuffixPre = (unit || '').trim().toLowerCase()
|
||||||
|
let maxVal = Math.ceil(paddedMax)
|
||||||
|
let minVal = Math.floor(paddedMin)
|
||||||
|
if (unitSuffixPre === 'ms' || unitSuffixPre === 'latency') {
|
||||||
|
minVal = 0
|
||||||
|
if (paddedMax < 10) {
|
||||||
|
maxVal = Math.ceil(paddedMax)
|
||||||
|
} else if (paddedMax < 100) {
|
||||||
|
maxVal = Math.ceil(paddedMax / 10) * 10
|
||||||
|
} else if (paddedMax < 1000) {
|
||||||
|
maxVal = Math.ceil(paddedMax / 50) * 50
|
||||||
|
} else if (paddedMax < 10000) {
|
||||||
|
maxVal = Math.ceil(paddedMax / 500) * 500
|
||||||
|
} else {
|
||||||
|
maxVal = Math.ceil(paddedMax / 1000) * 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
maxValue: maxVal,
|
||||||
|
minValue: minVal,
|
||||||
|
valueRange: maxVal - minVal || 1,
|
||||||
|
}
|
||||||
|
}, [allSeries, unit])
|
||||||
|
|
||||||
|
const yMin = padding.top + 3
|
||||||
|
const yMax = padding.top + chartHeight - 3
|
||||||
|
|
||||||
|
const scaledPoints = useMemo(
|
||||||
|
() =>
|
||||||
|
data.map((d, i) => {
|
||||||
|
const usableW = Math.max(1, chartWidth)
|
||||||
|
const x = padding.left + (i / (data.length - 1 || 1)) * usableW
|
||||||
|
const rawY = padding.top + chartHeight - ((d.value - minValue) / valueRange) * chartHeight
|
||||||
|
const y = Math.max(yMin, Math.min(yMax, rawY))
|
||||||
|
return { x, y }
|
||||||
|
}),
|
||||||
|
[data, chartWidth, chartHeight, minValue, valueRange, yMin, yMax, padding.left, padding.top]
|
||||||
|
)
|
||||||
|
|
||||||
|
const scaledSeries = useMemo(
|
||||||
|
() =>
|
||||||
|
allSeries.map((s) => {
|
||||||
|
const pts = s.data.map((d, i) => {
|
||||||
|
const usableW = Math.max(1, chartWidth)
|
||||||
|
const x = padding.left + (i / (s.data.length - 1 || 1)) * usableW
|
||||||
|
const rawY = padding.top + chartHeight - ((d.value - minValue) / valueRange) * chartHeight
|
||||||
|
const y = Math.max(yMin, Math.min(yMax, rawY))
|
||||||
|
return { x, y }
|
||||||
|
})
|
||||||
|
return { ...s, pts }
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
allSeries,
|
||||||
|
chartWidth,
|
||||||
|
chartHeight,
|
||||||
|
minValue,
|
||||||
|
valueRange,
|
||||||
|
yMin,
|
||||||
|
yMax,
|
||||||
|
padding.left,
|
||||||
|
padding.top,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
const getSeriesById = (id?: string | null) => scaledSeries.find((s) => s.id === id)
|
||||||
|
const visibleSeries = useMemo(
|
||||||
|
() => (activeSeriesId ? scaledSeries.filter((s) => s.id === activeSeriesId) : scaledSeries),
|
||||||
|
[activeSeriesId, scaledSeries]
|
||||||
|
)
|
||||||
|
|
||||||
|
const pathD = useMemo(() => {
|
||||||
|
if (scaledPoints.length <= 1) return ''
|
||||||
|
const p = scaledPoints
|
||||||
|
const tension = 0.2
|
||||||
|
let d = `M ${p[0].x} ${p[0].y}`
|
||||||
|
for (let i = 0; i < p.length - 1; i++) {
|
||||||
|
const p0 = p[i - 1] || p[i]
|
||||||
|
const p1 = p[i]
|
||||||
|
const p2 = p[i + 1]
|
||||||
|
const p3 = p[i + 2] || p[i + 1]
|
||||||
|
const cp1x = p1.x + ((p2.x - p0.x) / 6) * tension
|
||||||
|
let cp1y = p1.y + ((p2.y - p0.y) / 6) * tension
|
||||||
|
const cp2x = p2.x - ((p3.x - p1.x) / 6) * tension
|
||||||
|
let cp2y = p2.y - ((p3.y - p1.y) / 6) * tension
|
||||||
|
cp1y = Math.max(yMin, Math.min(yMax, cp1y))
|
||||||
|
cp2y = Math.max(yMin, Math.min(yMax, cp2y))
|
||||||
|
d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}, [scaledPoints, yMin, yMax])
|
||||||
|
|
||||||
|
const getCompactDateLabel = (timestamp?: string) => {
|
||||||
|
if (!timestamp) return ''
|
||||||
|
try {
|
||||||
|
const f = formatDate(timestamp)
|
||||||
|
return `${f.compactDate} ${f.compactTime}`
|
||||||
|
} catch (e) {
|
||||||
|
const d = new Date(timestamp)
|
||||||
|
if (Number.isNaN(d.getTime())) return ''
|
||||||
|
return d.toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentHoverDate =
|
||||||
|
hoverIndex !== null && data[hoverIndex] ? getCompactDateLabel(data[hoverIndex].timestamp) : ''
|
||||||
|
|
||||||
if (containerWidth === null) {
|
if (containerWidth === null) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -119,109 +246,6 @@ export function LineChart({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const allSeries = (
|
|
||||||
Array.isArray(series) && series.length > 0
|
|
||||||
? [{ id: 'base', label, color, data }, ...series]
|
|
||||||
: [{ id: 'base', label, color, data }]
|
|
||||||
).map((s, idx) => ({ ...s, id: s.id || s.label || String(idx) }))
|
|
||||||
|
|
||||||
const flatValues = allSeries.flatMap((s) => s.data.map((d) => d.value))
|
|
||||||
const rawMax = Math.max(...flatValues, 1)
|
|
||||||
const rawMin = Math.min(...flatValues, 0)
|
|
||||||
const paddedMax = rawMax === 0 ? 1 : rawMax * 1.1
|
|
||||||
const paddedMin = Math.min(0, rawMin)
|
|
||||||
const unitSuffixPre = (unit || '').trim().toLowerCase()
|
|
||||||
let maxValue = Math.ceil(paddedMax)
|
|
||||||
let minValue = Math.floor(paddedMin)
|
|
||||||
if (unitSuffixPre === 'ms' || unitSuffixPre === 'latency') {
|
|
||||||
minValue = 0
|
|
||||||
if (paddedMax < 10) {
|
|
||||||
maxValue = Math.ceil(paddedMax)
|
|
||||||
} else if (paddedMax < 100) {
|
|
||||||
maxValue = Math.ceil(paddedMax / 10) * 10
|
|
||||||
} else if (paddedMax < 1000) {
|
|
||||||
maxValue = Math.ceil(paddedMax / 50) * 50
|
|
||||||
} else if (paddedMax < 10000) {
|
|
||||||
maxValue = Math.ceil(paddedMax / 500) * 500
|
|
||||||
} else {
|
|
||||||
maxValue = Math.ceil(paddedMax / 1000) * 1000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const valueRange = maxValue - minValue || 1
|
|
||||||
|
|
||||||
const yMin = padding.top + 3
|
|
||||||
const yMax = padding.top + chartHeight - 3
|
|
||||||
|
|
||||||
const scaledPoints = data.map((d, i) => {
|
|
||||||
const usableW = Math.max(1, chartWidth)
|
|
||||||
const x = padding.left + (i / (data.length - 1 || 1)) * usableW
|
|
||||||
const rawY = padding.top + chartHeight - ((d.value - minValue) / valueRange) * chartHeight
|
|
||||||
const y = Math.max(yMin, Math.min(yMax, rawY))
|
|
||||||
return { x, y }
|
|
||||||
})
|
|
||||||
|
|
||||||
const scaledSeries = allSeries.map((s) => {
|
|
||||||
const pts = s.data.map((d, i) => {
|
|
||||||
const usableW = Math.max(1, chartWidth)
|
|
||||||
const x = padding.left + (i / (s.data.length - 1 || 1)) * usableW
|
|
||||||
const rawY = padding.top + chartHeight - ((d.value - minValue) / valueRange) * chartHeight
|
|
||||||
const y = Math.max(yMin, Math.min(yMax, rawY))
|
|
||||||
return { x, y }
|
|
||||||
})
|
|
||||||
return { ...s, pts }
|
|
||||||
})
|
|
||||||
|
|
||||||
const getSeriesById = (id?: string | null) => scaledSeries.find((s) => s.id === id)
|
|
||||||
const visibleSeries = activeSeriesId
|
|
||||||
? scaledSeries.filter((s) => s.id === activeSeriesId)
|
|
||||||
: scaledSeries
|
|
||||||
const orderedSeries = (() => {
|
|
||||||
if (!activeSeriesId) return visibleSeries
|
|
||||||
return visibleSeries
|
|
||||||
})()
|
|
||||||
|
|
||||||
const pathD = (() => {
|
|
||||||
if (scaledPoints.length <= 1) return ''
|
|
||||||
const p = scaledPoints
|
|
||||||
const tension = 0.2
|
|
||||||
let d = `M ${p[0].x} ${p[0].y}`
|
|
||||||
for (let i = 0; i < p.length - 1; i++) {
|
|
||||||
const p0 = p[i - 1] || p[i]
|
|
||||||
const p1 = p[i]
|
|
||||||
const p2 = p[i + 1]
|
|
||||||
const p3 = p[i + 2] || p[i + 1]
|
|
||||||
const cp1x = p1.x + ((p2.x - p0.x) / 6) * tension
|
|
||||||
let cp1y = p1.y + ((p2.y - p0.y) / 6) * tension
|
|
||||||
const cp2x = p2.x - ((p3.x - p1.x) / 6) * tension
|
|
||||||
let cp2y = p2.y - ((p3.y - p1.y) / 6) * tension
|
|
||||||
cp1y = Math.max(yMin, Math.min(yMax, cp1y))
|
|
||||||
cp2y = Math.max(yMin, Math.min(yMax, cp2y))
|
|
||||||
d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
})()
|
|
||||||
|
|
||||||
const getCompactDateLabel = (timestamp?: string) => {
|
|
||||||
if (!timestamp) return ''
|
|
||||||
try {
|
|
||||||
const f = formatDate(timestamp)
|
|
||||||
return `${f.compactDate} ${f.compactTime}`
|
|
||||||
} catch (e) {
|
|
||||||
const d = new Date(timestamp)
|
|
||||||
if (Number.isNaN(d.getTime())) return ''
|
|
||||||
return d.toLocaleString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentHoverDate =
|
|
||||||
hoverIndex !== null && data[hoverIndex] ? getCompactDateLabel(data[hoverIndex].timestamp) : ''
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
@@ -386,7 +410,7 @@ export function LineChart({
|
|||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{orderedSeries.map((s, idx) => {
|
{visibleSeries.map((s, idx) => {
|
||||||
const isActive = activeSeriesId ? activeSeriesId === s.id : true
|
const isActive = activeSeriesId ? activeSeriesId === s.id : true
|
||||||
const isHovered = hoverSeriesId ? hoverSeriesId === s.id : false
|
const isHovered = hoverSeriesId ? hoverSeriesId === s.id : false
|
||||||
const baseOpacity = isActive ? 1 : 0.12
|
const baseOpacity = isActive ? 1 : 0.12
|
||||||
@@ -682,4 +706,8 @@ export function LineChart({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Memoized LineChart component to prevent re-renders when parent updates.
|
||||||
|
*/
|
||||||
|
export const LineChart = memo(LineChartComponent)
|
||||||
export default LineChart
|
export default LineChart
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export function StatusBar({
|
|||||||
const end = new Date(start.getTime() + (segmentDurationMs || 0))
|
const end = new Date(start.getTime() + (segmentDurationMs || 0))
|
||||||
const rangeLabel = Number.isNaN(start.getTime())
|
const rangeLabel = Number.isNaN(start.getTime())
|
||||||
? ''
|
? ''
|
||||||
: `${start.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric' })} – ${end.toLocaleString('en-US', { hour: 'numeric', minute: '2-digit' })}`
|
: `${start.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })} – ${end.toLocaleString('en-US', { hour: 'numeric', minute: '2-digit' })}`
|
||||||
return {
|
return {
|
||||||
rangeLabel,
|
rangeLabel,
|
||||||
successLabel: `${segment.successRate.toFixed(1)}%`,
|
successLabel: `${segment.successRate.toFixed(1)}%`,
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export interface WorkflowExecutionItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WorkflowsList({
|
export function WorkflowsList({
|
||||||
executions,
|
|
||||||
filteredExecutions,
|
filteredExecutions,
|
||||||
expandedWorkflowId,
|
expandedWorkflowId,
|
||||||
onToggleWorkflow,
|
onToggleWorkflow,
|
||||||
@@ -20,7 +19,6 @@ export function WorkflowsList({
|
|||||||
searchQuery,
|
searchQuery,
|
||||||
segmentDurationMs,
|
segmentDurationMs,
|
||||||
}: {
|
}: {
|
||||||
executions: WorkflowExecutionItem[]
|
|
||||||
filteredExecutions: WorkflowExecutionItem[]
|
filteredExecutions: WorkflowExecutionItem[]
|
||||||
expandedWorkflowId: string | null
|
expandedWorkflowId: string | null
|
||||||
onToggleWorkflow: (workflowId: string) => void
|
onToggleWorkflow: (workflowId: string) => void
|
||||||
|
|||||||
@@ -2,24 +2,13 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Loader2 } from 'lucide-react'
|
import { Loader2 } from 'lucide-react'
|
||||||
import { useParams } from 'next/navigation'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import {
|
import { formatLatency, parseDuration } from '@/app/workspace/[workspaceId]/logs/utils'
|
||||||
formatLatency,
|
|
||||||
mapToExecutionLog,
|
|
||||||
mapToExecutionLogAlt,
|
|
||||||
} from '@/app/workspace/[workspaceId]/logs/utils'
|
|
||||||
import {
|
|
||||||
useExecutionsMetrics,
|
|
||||||
useGlobalDashboardLogs,
|
|
||||||
useWorkflowDashboardLogs,
|
|
||||||
} from '@/hooks/queries/logs'
|
|
||||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
import { useFilterStore } from '@/stores/logs/filters/store'
|
||||||
|
import type { WorkflowLog } from '@/stores/logs/filters/types'
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
import { LineChart, WorkflowsList } from './components'
|
import { LineChart, WorkflowsList } from './components'
|
||||||
|
|
||||||
type TimeFilter = '30m' | '1h' | '6h' | '12h' | '24h' | '3d' | '7d' | '14d' | '30d'
|
|
||||||
|
|
||||||
interface WorkflowExecution {
|
interface WorkflowExecution {
|
||||||
workflowId: string
|
workflowId: string
|
||||||
workflowName: string
|
workflowName: string
|
||||||
@@ -39,18 +28,12 @@ interface WorkflowExecution {
|
|||||||
|
|
||||||
const DEFAULT_SEGMENTS = 72
|
const DEFAULT_SEGMENTS = 72
|
||||||
const MIN_SEGMENT_PX = 10
|
const MIN_SEGMENT_PX = 10
|
||||||
|
const MIN_SEGMENT_MS = 60000
|
||||||
|
|
||||||
/**
|
|
||||||
* Predetermined heights for skeleton bars to avoid hydration mismatch.
|
|
||||||
* Using static values instead of Math.random() ensures server/client consistency.
|
|
||||||
*/
|
|
||||||
const SKELETON_BAR_HEIGHTS = [
|
const SKELETON_BAR_HEIGHTS = [
|
||||||
45, 72, 38, 85, 52, 68, 30, 90, 55, 42, 78, 35, 88, 48, 65, 28, 82, 58, 40, 75, 32, 95, 50, 70,
|
45, 72, 38, 85, 52, 68, 30, 90, 55, 42, 78, 35, 88, 48, 65, 28, 82, 58, 40, 75, 32, 95, 50, 70,
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
|
||||||
* Skeleton loader for a single graph card
|
|
||||||
*/
|
|
||||||
function GraphCardSkeleton({ title }: { title: string }) {
|
function GraphCardSkeleton({ title }: { title: string }) {
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col overflow-hidden rounded-[6px] bg-[var(--surface-elevated)]'>
|
<div className='flex flex-col overflow-hidden rounded-[6px] bg-[var(--surface-elevated)]'>
|
||||||
@@ -62,7 +45,6 @@ function GraphCardSkeleton({ title }: { title: string }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className='flex-1 overflow-y-auto rounded-t-[6px] bg-[var(--surface-1)] px-[14px] py-[10px]'>
|
<div className='flex-1 overflow-y-auto rounded-t-[6px] bg-[var(--surface-1)] px-[14px] py-[10px]'>
|
||||||
<div className='flex h-[166px] flex-col justify-end gap-[4px]'>
|
<div className='flex h-[166px] flex-col justify-end gap-[4px]'>
|
||||||
{/* Skeleton bars simulating chart */}
|
|
||||||
<div className='flex items-end gap-[2px]'>
|
<div className='flex items-end gap-[2px]'>
|
||||||
{SKELETON_BAR_HEIGHTS.map((height, i) => (
|
{SKELETON_BAR_HEIGHTS.map((height, i) => (
|
||||||
<Skeleton
|
<Skeleton
|
||||||
@@ -80,24 +62,16 @@ function GraphCardSkeleton({ title }: { title: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Skeleton loader for a workflow row in the workflows list
|
|
||||||
*/
|
|
||||||
function WorkflowRowSkeleton() {
|
function WorkflowRowSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className='flex h-[44px] items-center gap-[16px] px-[24px]'>
|
<div className='flex h-[44px] items-center gap-[16px] px-[24px]'>
|
||||||
{/* Workflow name with color */}
|
|
||||||
<div className='flex w-[160px] flex-shrink-0 items-center gap-[8px] pr-[8px]'>
|
<div className='flex w-[160px] flex-shrink-0 items-center gap-[8px] pr-[8px]'>
|
||||||
<Skeleton className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]' />
|
<Skeleton className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]' />
|
||||||
<Skeleton className='h-[16px] flex-1' />
|
<Skeleton className='h-[16px] flex-1' />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status bar - takes most of the space */}
|
|
||||||
<div className='flex-1'>
|
<div className='flex-1'>
|
||||||
<Skeleton className='h-[24px] w-full rounded-[4px]' />
|
<Skeleton className='h-[24px] w-full rounded-[4px]' />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Success rate */}
|
|
||||||
<div className='w-[100px] flex-shrink-0 pl-[16px]'>
|
<div className='w-[100px] flex-shrink-0 pl-[16px]'>
|
||||||
<Skeleton className='h-[16px] w-[50px]' />
|
<Skeleton className='h-[16px] w-[50px]' />
|
||||||
</div>
|
</div>
|
||||||
@@ -105,13 +79,9 @@ function WorkflowRowSkeleton() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Skeleton loader for the workflows list table
|
|
||||||
*/
|
|
||||||
function WorkflowsListSkeleton({ rowCount = 5 }: { rowCount?: number }) {
|
function WorkflowsListSkeleton({ rowCount = 5 }: { rowCount?: number }) {
|
||||||
return (
|
return (
|
||||||
<div className='flex h-full flex-col overflow-hidden rounded-[6px] bg-[var(--surface-1)]'>
|
<div className='flex h-full flex-col overflow-hidden rounded-[6px] bg-[var(--surface-1)]'>
|
||||||
{/* Table header */}
|
|
||||||
<div className='flex-shrink-0 rounded-t-[6px] bg-[var(--surface-3)] px-[24px] py-[10px]'>
|
<div className='flex-shrink-0 rounded-t-[6px] bg-[var(--surface-3)] px-[24px] py-[10px]'>
|
||||||
<div className='flex items-center gap-[16px]'>
|
<div className='flex items-center gap-[16px]'>
|
||||||
<span className='w-[160px] flex-shrink-0 font-medium text-[12px] text-[var(--text-tertiary)]'>
|
<span className='w-[160px] flex-shrink-0 font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||||
@@ -123,8 +93,6 @@ function WorkflowsListSkeleton({ rowCount = 5 }: { rowCount?: number }) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table body - scrollable */}
|
|
||||||
<div className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden'>
|
<div className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden'>
|
||||||
{Array.from({ length: rowCount }).map((_, i) => (
|
{Array.from({ length: rowCount }).map((_, i) => (
|
||||||
<WorkflowRowSkeleton key={i} />
|
<WorkflowRowSkeleton key={i} />
|
||||||
@@ -134,13 +102,9 @@ function WorkflowsListSkeleton({ rowCount = 5 }: { rowCount?: number }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Complete skeleton loader for the entire dashboard
|
|
||||||
*/
|
|
||||||
function DashboardSkeleton() {
|
function DashboardSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className='mt-[24px] flex min-h-0 flex-1 flex-col pb-[24px]'>
|
<div className='mt-[24px] flex min-h-0 flex-1 flex-col pb-[24px]'>
|
||||||
{/* Graphs Section */}
|
|
||||||
<div className='mb-[16px] flex-shrink-0'>
|
<div className='mb-[16px] flex-shrink-0'>
|
||||||
<div className='grid grid-cols-1 gap-[16px] md:grid-cols-3'>
|
<div className='grid grid-cols-1 gap-[16px] md:grid-cols-3'>
|
||||||
<GraphCardSkeleton title='Runs' />
|
<GraphCardSkeleton title='Runs' />
|
||||||
@@ -148,8 +112,6 @@ function DashboardSkeleton() {
|
|||||||
<GraphCardSkeleton title='Latency' />
|
<GraphCardSkeleton title='Latency' />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Workflows Table - takes remaining space */}
|
|
||||||
<div className='min-h-0 flex-1 overflow-hidden'>
|
<div className='min-h-0 flex-1 overflow-hidden'>
|
||||||
<WorkflowsListSkeleton rowCount={14} />
|
<WorkflowsListSkeleton rowCount={14} />
|
||||||
</div>
|
</div>
|
||||||
@@ -158,225 +120,237 @@ function DashboardSkeleton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface DashboardProps {
|
interface DashboardProps {
|
||||||
isLive?: boolean
|
logs: WorkflowLog[]
|
||||||
refreshTrigger?: number
|
isLoading: boolean
|
||||||
onCustomTimeRangeChange?: (isCustom: boolean) => void
|
error?: Error | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Dashboard({
|
export default function Dashboard({ logs, isLoading, error }: DashboardProps) {
|
||||||
isLive = false,
|
const [segmentCount, setSegmentCount] = useState<number>(DEFAULT_SEGMENTS)
|
||||||
refreshTrigger = 0,
|
|
||||||
onCustomTimeRangeChange,
|
|
||||||
}: DashboardProps) {
|
|
||||||
const params = useParams()
|
|
||||||
const workspaceId = params.workspaceId as string
|
|
||||||
|
|
||||||
const getTimeFilterFromRange = (range: string): TimeFilter => {
|
|
||||||
switch (range) {
|
|
||||||
case 'Past 30 minutes':
|
|
||||||
return '30m'
|
|
||||||
case 'Past hour':
|
|
||||||
return '1h'
|
|
||||||
case 'Past 6 hours':
|
|
||||||
return '6h'
|
|
||||||
case 'Past 12 hours':
|
|
||||||
return '12h'
|
|
||||||
case 'Past 24 hours':
|
|
||||||
return '24h'
|
|
||||||
case 'Past 3 days':
|
|
||||||
return '3d'
|
|
||||||
case 'Past 7 days':
|
|
||||||
return '7d'
|
|
||||||
case 'Past 14 days':
|
|
||||||
return '14d'
|
|
||||||
case 'Past 30 days':
|
|
||||||
return '30d'
|
|
||||||
default:
|
|
||||||
return '30d'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [endTime, setEndTime] = useState<Date>(new Date())
|
|
||||||
const [expandedWorkflowId, setExpandedWorkflowId] = useState<string | null>(null)
|
|
||||||
const [selectedSegments, setSelectedSegments] = useState<Record<string, number[]>>({})
|
const [selectedSegments, setSelectedSegments] = useState<Record<string, number[]>>({})
|
||||||
const [lastAnchorIndices, setLastAnchorIndices] = useState<Record<string, number>>({})
|
const [lastAnchorIndices, setLastAnchorIndices] = useState<Record<string, number>>({})
|
||||||
const [segmentCount, setSegmentCount] = useState<number>(DEFAULT_SEGMENTS)
|
|
||||||
const barsAreaRef = useRef<HTMLDivElement | null>(null)
|
const barsAreaRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
const {
|
const { workflowIds, searchQuery, toggleWorkflowId, timeRange } = useFilterStore()
|
||||||
workflowIds,
|
|
||||||
folderIds,
|
|
||||||
triggers,
|
|
||||||
timeRange: sidebarTimeRange,
|
|
||||||
level,
|
|
||||||
searchQuery,
|
|
||||||
} = useFilterStore()
|
|
||||||
|
|
||||||
const { workflows } = useWorkflowRegistry()
|
const allWorkflows = useWorkflowRegistry((state) => state.workflows)
|
||||||
|
|
||||||
const timeFilter = getTimeFilterFromRange(sidebarTimeRange)
|
const expandedWorkflowId = workflowIds.length === 1 ? workflowIds[0] : null
|
||||||
|
|
||||||
const getStartTime = useCallback(() => {
|
const lastExecutionByWorkflow = useMemo(() => {
|
||||||
const start = new Date(endTime)
|
const map = new Map<string, number>()
|
||||||
|
for (const log of logs) {
|
||||||
|
const wfId = log.workflowId
|
||||||
|
if (!wfId) continue
|
||||||
|
const ts = new Date(log.createdAt).getTime()
|
||||||
|
const existing = map.get(wfId)
|
||||||
|
if (!existing || ts > existing) {
|
||||||
|
map.set(wfId, ts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [logs])
|
||||||
|
|
||||||
switch (timeFilter) {
|
const timeBounds = useMemo(() => {
|
||||||
case '30m':
|
if (logs.length === 0) {
|
||||||
start.setMinutes(endTime.getMinutes() - 30)
|
const now = new Date()
|
||||||
break
|
return { start: now, end: now }
|
||||||
case '1h':
|
|
||||||
start.setHours(endTime.getHours() - 1)
|
|
||||||
break
|
|
||||||
case '6h':
|
|
||||||
start.setHours(endTime.getHours() - 6)
|
|
||||||
break
|
|
||||||
case '12h':
|
|
||||||
start.setHours(endTime.getHours() - 12)
|
|
||||||
break
|
|
||||||
case '24h':
|
|
||||||
start.setHours(endTime.getHours() - 24)
|
|
||||||
break
|
|
||||||
case '3d':
|
|
||||||
start.setDate(endTime.getDate() - 3)
|
|
||||||
break
|
|
||||||
case '7d':
|
|
||||||
start.setDate(endTime.getDate() - 7)
|
|
||||||
break
|
|
||||||
case '14d':
|
|
||||||
start.setDate(endTime.getDate() - 14)
|
|
||||||
break
|
|
||||||
case '30d':
|
|
||||||
start.setDate(endTime.getDate() - 30)
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
start.setHours(endTime.getHours() - 24)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return start
|
let minTime = Number.POSITIVE_INFINITY
|
||||||
}, [endTime, timeFilter])
|
let maxTime = Number.NEGATIVE_INFINITY
|
||||||
|
|
||||||
const metricsFilters = useMemo(
|
for (const log of logs) {
|
||||||
() => ({
|
const ts = new Date(log.createdAt).getTime()
|
||||||
workspaceId,
|
if (ts < minTime) minTime = ts
|
||||||
segments: segmentCount || DEFAULT_SEGMENTS,
|
if (ts > maxTime) maxTime = ts
|
||||||
startTime: getStartTime().toISOString(),
|
}
|
||||||
endTime: endTime.toISOString(),
|
|
||||||
workflowIds: workflowIds.length > 0 ? workflowIds : undefined,
|
|
||||||
folderIds: folderIds.length > 0 ? folderIds : undefined,
|
|
||||||
triggers: triggers.length > 0 ? triggers : undefined,
|
|
||||||
level: level !== 'all' ? level : undefined,
|
|
||||||
}),
|
|
||||||
[workspaceId, segmentCount, getStartTime, endTime, workflowIds, folderIds, triggers, level]
|
|
||||||
)
|
|
||||||
|
|
||||||
const logsFilters = useMemo(
|
const end = new Date(Math.max(maxTime, Date.now()))
|
||||||
() => ({
|
const start = new Date(minTime)
|
||||||
workspaceId,
|
|
||||||
startDate: getStartTime().toISOString(),
|
|
||||||
endDate: endTime.toISOString(),
|
|
||||||
workflowIds: workflowIds.length > 0 ? workflowIds : undefined,
|
|
||||||
folderIds: folderIds.length > 0 ? folderIds : undefined,
|
|
||||||
triggers: triggers.length > 0 ? triggers : undefined,
|
|
||||||
level: level !== 'all' ? level : undefined,
|
|
||||||
searchQuery: searchQuery.trim() || undefined,
|
|
||||||
limit: 50,
|
|
||||||
}),
|
|
||||||
[workspaceId, getStartTime, endTime, workflowIds, folderIds, triggers, level, searchQuery]
|
|
||||||
)
|
|
||||||
|
|
||||||
const metricsQuery = useExecutionsMetrics(metricsFilters, {
|
return { start, end }
|
||||||
enabled: Boolean(workspaceId),
|
}, [logs])
|
||||||
})
|
|
||||||
|
|
||||||
const globalLogsQuery = useGlobalDashboardLogs(logsFilters, {
|
const { executions, aggregateSegments, segmentMs } = useMemo(() => {
|
||||||
enabled: Boolean(workspaceId),
|
const allWorkflowsList = Object.values(allWorkflows)
|
||||||
})
|
|
||||||
|
|
||||||
const workflowLogsQuery = useWorkflowDashboardLogs(expandedWorkflowId ?? undefined, logsFilters, {
|
if (allWorkflowsList.length === 0) {
|
||||||
enabled: Boolean(workspaceId) && Boolean(expandedWorkflowId),
|
return { executions: [], aggregateSegments: [], segmentMs: 0 }
|
||||||
})
|
}
|
||||||
|
|
||||||
const executions = metricsQuery.data?.workflows ?? []
|
const { start, end } =
|
||||||
const aggregateSegments = metricsQuery.data?.aggregateSegments ?? []
|
logs.length > 0
|
||||||
const error = metricsQuery.error?.message ?? null
|
? timeBounds
|
||||||
|
: { start: new Date(Date.now() - 24 * 60 * 60 * 1000), end: new Date() }
|
||||||
|
|
||||||
/**
|
const totalMs = Math.max(1, end.getTime() - start.getTime())
|
||||||
* Loading state logic using TanStack Query best practices:
|
const calculatedSegmentMs = Math.max(
|
||||||
* - isPending: true when there's no cached data (initial load only)
|
MIN_SEGMENT_MS,
|
||||||
* - isFetching: true when any fetch is in progress
|
Math.floor(totalMs / Math.max(1, segmentCount))
|
||||||
* - isPlaceholderData: true when showing stale data from keepPreviousData
|
)
|
||||||
*
|
|
||||||
* We only show skeleton on initial load (isPending + no data).
|
|
||||||
* For subsequent fetches, keepPreviousData shows stale content while fetching.
|
|
||||||
*/
|
|
||||||
const showSkeleton = metricsQuery.isPending && !metricsQuery.data
|
|
||||||
|
|
||||||
// Check if any filters are actually applied
|
const logsByWorkflow = new Map<string, WorkflowLog[]>()
|
||||||
const hasActiveFilters = useMemo(
|
for (const log of logs) {
|
||||||
() =>
|
const wfId = log.workflowId
|
||||||
level !== 'all' ||
|
if (!logsByWorkflow.has(wfId)) {
|
||||||
workflowIds.length > 0 ||
|
logsByWorkflow.set(wfId, [])
|
||||||
folderIds.length > 0 ||
|
}
|
||||||
triggers.length > 0 ||
|
logsByWorkflow.get(wfId)!.push(log)
|
||||||
searchQuery.trim() !== '',
|
}
|
||||||
[level, workflowIds, folderIds, triggers, searchQuery]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Filter workflows based on search query and whether they have any executions matching the filters
|
const workflowExecutions: WorkflowExecution[] = []
|
||||||
const filteredExecutions = useMemo(() => {
|
|
||||||
let filtered = executions
|
|
||||||
|
|
||||||
// Only filter out workflows with no executions if filters are active
|
for (const workflow of allWorkflowsList) {
|
||||||
if (hasActiveFilters) {
|
const workflowLogs = logsByWorkflow.get(workflow.id) || []
|
||||||
filtered = filtered.filter((workflow) => {
|
|
||||||
const hasExecutions = workflow.segments.some((seg) => seg.hasExecutions === true)
|
const segments: WorkflowExecution['segments'] = Array.from(
|
||||||
return hasExecutions
|
{ length: segmentCount },
|
||||||
|
(_, i) => ({
|
||||||
|
timestamp: new Date(start.getTime() + i * calculatedSegmentMs).toISOString(),
|
||||||
|
hasExecutions: false,
|
||||||
|
totalExecutions: 0,
|
||||||
|
successfulExecutions: 0,
|
||||||
|
successRate: 100,
|
||||||
|
avgDurationMs: 0,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const durations: number[][] = Array.from({ length: segmentCount }, () => [])
|
||||||
|
|
||||||
|
for (const log of workflowLogs) {
|
||||||
|
const logTime = new Date(log.createdAt).getTime()
|
||||||
|
const idx = Math.min(
|
||||||
|
segmentCount - 1,
|
||||||
|
Math.max(0, Math.floor((logTime - start.getTime()) / calculatedSegmentMs))
|
||||||
|
)
|
||||||
|
|
||||||
|
segments[idx].totalExecutions += 1
|
||||||
|
segments[idx].hasExecutions = true
|
||||||
|
|
||||||
|
if (log.level !== 'error') {
|
||||||
|
segments[idx].successfulExecutions += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = parseDuration({ duration: log.duration ?? undefined })
|
||||||
|
if (duration !== null && duration > 0) {
|
||||||
|
durations[idx].push(duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalExecs = 0
|
||||||
|
let totalSuccess = 0
|
||||||
|
|
||||||
|
for (let i = 0; i < segmentCount; i++) {
|
||||||
|
const seg = segments[i]
|
||||||
|
totalExecs += seg.totalExecutions
|
||||||
|
totalSuccess += seg.successfulExecutions
|
||||||
|
|
||||||
|
if (seg.totalExecutions > 0) {
|
||||||
|
seg.successRate = (seg.successfulExecutions / seg.totalExecutions) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
if (durations[i].length > 0) {
|
||||||
|
seg.avgDurationMs = Math.round(
|
||||||
|
durations[i].reduce((sum, d) => sum + d, 0) / durations[i].length
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const overallSuccessRate = totalExecs > 0 ? (totalSuccess / totalExecs) * 100 : 100
|
||||||
|
|
||||||
|
workflowExecutions.push({
|
||||||
|
workflowId: workflow.id,
|
||||||
|
workflowName: workflow.name,
|
||||||
|
segments,
|
||||||
|
overallSuccessRate,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply search query filter
|
workflowExecutions.sort((a, b) => {
|
||||||
if (searchQuery.trim()) {
|
const errA = a.overallSuccessRate < 100 ? 1 - a.overallSuccessRate / 100 : 0
|
||||||
const query = searchQuery.toLowerCase().trim()
|
const errB = b.overallSuccessRate < 100 ? 1 - b.overallSuccessRate / 100 : 0
|
||||||
filtered = filtered.filter((workflow) => workflow.workflowName.toLowerCase().includes(query))
|
if (errA !== errB) return errB - errA
|
||||||
}
|
return a.workflowName.localeCompare(b.workflowName)
|
||||||
|
|
||||||
// Sort by creation date (newest first) to match sidebar ordering
|
|
||||||
filtered = filtered.sort((a, b) => {
|
|
||||||
const workflowA = workflows[a.workflowId]
|
|
||||||
const workflowB = workflows[b.workflowId]
|
|
||||||
if (!workflowA || !workflowB) return 0
|
|
||||||
return workflowB.createdAt.getTime() - workflowA.createdAt.getTime()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return filtered
|
const aggSegments: {
|
||||||
}, [executions, searchQuery, hasActiveFilters, workflows])
|
timestamp: string
|
||||||
|
totalExecutions: number
|
||||||
|
successfulExecutions: number
|
||||||
|
avgDurationMs: number
|
||||||
|
}[] = Array.from({ length: segmentCount }, (_, i) => ({
|
||||||
|
timestamp: new Date(start.getTime() + i * calculatedSegmentMs).toISOString(),
|
||||||
|
totalExecutions: 0,
|
||||||
|
successfulExecutions: 0,
|
||||||
|
avgDurationMs: 0,
|
||||||
|
}))
|
||||||
|
|
||||||
const globalLogs = useMemo(() => {
|
const weightedDurationSums: number[] = Array(segmentCount).fill(0)
|
||||||
if (!globalLogsQuery.data?.pages) return []
|
const executionCounts: number[] = Array(segmentCount).fill(0)
|
||||||
return globalLogsQuery.data.pages.flatMap((page) => page.logs).map(mapToExecutionLog)
|
|
||||||
}, [globalLogsQuery.data?.pages])
|
|
||||||
|
|
||||||
const workflowLogs = useMemo(() => {
|
for (const wf of workflowExecutions) {
|
||||||
if (!workflowLogsQuery.data?.pages) return []
|
wf.segments.forEach((s, i) => {
|
||||||
return workflowLogsQuery.data.pages.flatMap((page) => page.logs).map(mapToExecutionLogAlt)
|
aggSegments[i].totalExecutions += s.totalExecutions
|
||||||
}, [workflowLogsQuery.data?.pages])
|
aggSegments[i].successfulExecutions += s.successfulExecutions
|
||||||
|
|
||||||
|
if (s.avgDurationMs && s.avgDurationMs > 0 && s.totalExecutions > 0) {
|
||||||
|
weightedDurationSums[i] += s.avgDurationMs * s.totalExecutions
|
||||||
|
executionCounts[i] += s.totalExecutions
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
aggSegments.forEach((seg, i) => {
|
||||||
|
if (executionCounts[i] > 0) {
|
||||||
|
seg.avgDurationMs = weightedDurationSums[i] / executionCounts[i]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
executions: workflowExecutions,
|
||||||
|
aggregateSegments: aggSegments,
|
||||||
|
segmentMs: calculatedSegmentMs,
|
||||||
|
}
|
||||||
|
}, [logs, timeBounds, segmentCount, allWorkflows])
|
||||||
|
|
||||||
|
const filteredExecutions = useMemo(() => {
|
||||||
|
let filtered = executions
|
||||||
|
|
||||||
|
if (workflowIds.length > 0) {
|
||||||
|
filtered = filtered.filter((wf) => workflowIds.includes(wf.workflowId))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const query = searchQuery.toLowerCase().trim()
|
||||||
|
filtered = filtered.filter((wf) => wf.workflowName.toLowerCase().includes(query))
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered.slice().sort((a, b) => {
|
||||||
|
const timeA = lastExecutionByWorkflow.get(a.workflowId) ?? 0
|
||||||
|
const timeB = lastExecutionByWorkflow.get(b.workflowId) ?? 0
|
||||||
|
|
||||||
|
if (!timeA && !timeB) return a.workflowName.localeCompare(b.workflowName)
|
||||||
|
if (!timeA) return 1
|
||||||
|
if (!timeB) return -1
|
||||||
|
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
}, [executions, lastExecutionByWorkflow, workflowIds, searchQuery])
|
||||||
|
|
||||||
const globalDetails = useMemo(() => {
|
const globalDetails = useMemo(() => {
|
||||||
if (!aggregateSegments.length) return null
|
if (!aggregateSegments.length) return null
|
||||||
|
|
||||||
const hasSelection = Object.keys(selectedSegments).length > 0
|
const hasSelection = Object.keys(selectedSegments).length > 0
|
||||||
const hasWorkflowFilter = expandedWorkflowId && expandedWorkflowId !== '__multi__'
|
const hasWorkflowFilter = expandedWorkflowId !== null
|
||||||
|
|
||||||
// Stack filters: workflow filter + segment selection
|
|
||||||
const segmentsToUse = hasSelection
|
const segmentsToUse = hasSelection
|
||||||
? (() => {
|
? (() => {
|
||||||
// Get all selected segment indices across all workflows
|
|
||||||
const allSelectedIndices = new Set<number>()
|
const allSelectedIndices = new Set<number>()
|
||||||
Object.values(selectedSegments).forEach((indices) => {
|
Object.values(selectedSegments).forEach((indices) => {
|
||||||
indices.forEach((idx) => allSelectedIndices.add(idx))
|
indices.forEach((idx) => allSelectedIndices.add(idx))
|
||||||
})
|
})
|
||||||
|
|
||||||
// For each selected index, aggregate data from workflows that have that segment selected
|
|
||||||
// If a workflow filter is active, only include that workflow's data
|
|
||||||
return Array.from(allSelectedIndices)
|
return Array.from(allSelectedIndices)
|
||||||
.sort((a, b) => a - b)
|
.sort((a, b) => a - b)
|
||||||
.map((idx) => {
|
.map((idx) => {
|
||||||
@@ -386,11 +360,8 @@ export default function Dashboard({
|
|||||||
let latencyCount = 0
|
let latencyCount = 0
|
||||||
const timestamp = aggregateSegments[idx]?.timestamp || ''
|
const timestamp = aggregateSegments[idx]?.timestamp || ''
|
||||||
|
|
||||||
// Sum up data from workflows that have this segment selected
|
|
||||||
Object.entries(selectedSegments).forEach(([workflowId, indices]) => {
|
Object.entries(selectedSegments).forEach(([workflowId, indices]) => {
|
||||||
if (!indices.includes(idx)) return
|
if (!indices.includes(idx)) return
|
||||||
|
|
||||||
// If workflow filter is active, skip other workflows
|
|
||||||
if (hasWorkflowFilter && workflowId !== expandedWorkflowId) return
|
if (hasWorkflowFilter && workflowId !== expandedWorkflowId) return
|
||||||
|
|
||||||
const workflow = filteredExecutions.find((w) => w.workflowId === workflowId)
|
const workflow = filteredExecutions.find((w) => w.workflowId === workflowId)
|
||||||
@@ -416,7 +387,6 @@ export default function Dashboard({
|
|||||||
})()
|
})()
|
||||||
: hasWorkflowFilter
|
: hasWorkflowFilter
|
||||||
? (() => {
|
? (() => {
|
||||||
// Filter to show only the expanded workflow's data
|
|
||||||
const workflow = filteredExecutions.find((w) => w.workflowId === expandedWorkflowId)
|
const workflow = filteredExecutions.find((w) => w.workflowId === expandedWorkflowId)
|
||||||
if (!workflow) return aggregateSegments
|
if (!workflow) return aggregateSegments
|
||||||
|
|
||||||
@@ -427,42 +397,7 @@ export default function Dashboard({
|
|||||||
avgDurationMs: segment.avgDurationMs ?? 0,
|
avgDurationMs: segment.avgDurationMs ?? 0,
|
||||||
}))
|
}))
|
||||||
})()
|
})()
|
||||||
: hasActiveFilters
|
: aggregateSegments
|
||||||
? (() => {
|
|
||||||
// Always recalculate aggregate segments based on filtered workflows when filters are active
|
|
||||||
return aggregateSegments.map((aggSeg, idx) => {
|
|
||||||
let totalExecutions = 0
|
|
||||||
let successfulExecutions = 0
|
|
||||||
let weightedLatencySum = 0
|
|
||||||
let latencyCount = 0
|
|
||||||
|
|
||||||
filteredExecutions.forEach((workflow) => {
|
|
||||||
const segment = workflow.segments[idx]
|
|
||||||
if (!segment) return
|
|
||||||
|
|
||||||
totalExecutions += segment.totalExecutions || 0
|
|
||||||
successfulExecutions += segment.successfulExecutions || 0
|
|
||||||
|
|
||||||
if (segment.avgDurationMs && segment.totalExecutions) {
|
|
||||||
weightedLatencySum += segment.avgDurationMs * segment.totalExecutions
|
|
||||||
latencyCount += segment.totalExecutions
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
timestamp: aggSeg.timestamp,
|
|
||||||
totalExecutions,
|
|
||||||
successfulExecutions,
|
|
||||||
avgDurationMs: latencyCount > 0 ? weightedLatencySum / latencyCount : 0,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})()
|
|
||||||
: aggregateSegments
|
|
||||||
|
|
||||||
const errorRates = segmentsToUse.map((s) => ({
|
|
||||||
timestamp: s.timestamp,
|
|
||||||
value: s.totalExecutions > 0 ? (1 - s.successfulExecutions / s.totalExecutions) * 100 : 0,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const executionCounts = segmentsToUse.map((s) => ({
|
const executionCounts = segmentsToUse.map((s) => ({
|
||||||
timestamp: s.timestamp,
|
timestamp: s.timestamp,
|
||||||
@@ -479,128 +414,46 @@ export default function Dashboard({
|
|||||||
value: s.avgDurationMs ?? 0,
|
value: s.avgDurationMs ?? 0,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const totalRuns = segmentsToUse.reduce((sum, s) => sum + s.totalExecutions, 0)
|
||||||
|
const totalErrors = segmentsToUse.reduce(
|
||||||
|
(sum, s) => sum + (s.totalExecutions - s.successfulExecutions),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
|
||||||
|
let weightedLatencySum = 0
|
||||||
|
let latencyCount = 0
|
||||||
|
for (const s of segmentsToUse) {
|
||||||
|
if (s.avgDurationMs && s.totalExecutions > 0) {
|
||||||
|
weightedLatencySum += s.avgDurationMs * s.totalExecutions
|
||||||
|
latencyCount += s.totalExecutions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const avgLatency = latencyCount > 0 ? weightedLatencySum / latencyCount : 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
errorRates,
|
|
||||||
durations: [],
|
|
||||||
executionCounts,
|
executionCounts,
|
||||||
failureCounts,
|
failureCounts,
|
||||||
latencies,
|
latencies,
|
||||||
logs: globalLogs,
|
totalRuns,
|
||||||
allLogs: globalLogs,
|
totalErrors,
|
||||||
}
|
|
||||||
}, [
|
|
||||||
aggregateSegments,
|
|
||||||
globalLogs,
|
|
||||||
selectedSegments,
|
|
||||||
filteredExecutions,
|
|
||||||
expandedWorkflowId,
|
|
||||||
hasActiveFilters,
|
|
||||||
])
|
|
||||||
|
|
||||||
const workflowDetails = useMemo(() => {
|
|
||||||
if (!expandedWorkflowId || !workflowLogs.length) return {}
|
|
||||||
|
|
||||||
return {
|
|
||||||
[expandedWorkflowId]: {
|
|
||||||
errorRates: [],
|
|
||||||
durations: [],
|
|
||||||
executionCounts: [],
|
|
||||||
logs: workflowLogs,
|
|
||||||
allLogs: workflowLogs,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}, [expandedWorkflowId, workflowLogs])
|
|
||||||
|
|
||||||
const aggregate = useMemo(() => {
|
|
||||||
const hasSelection = Object.keys(selectedSegments).length > 0
|
|
||||||
const hasWorkflowFilter = expandedWorkflowId && expandedWorkflowId !== '__multi__'
|
|
||||||
let totalExecutions = 0
|
|
||||||
let successfulExecutions = 0
|
|
||||||
let activeWorkflows = 0
|
|
||||||
let weightedLatencySum = 0
|
|
||||||
let latencyExecutionCount = 0
|
|
||||||
|
|
||||||
// Apply workflow filter first if present, otherwise use filtered executions
|
|
||||||
const workflowsToProcess = hasWorkflowFilter
|
|
||||||
? filteredExecutions.filter((wf) => wf.workflowId === expandedWorkflowId)
|
|
||||||
: filteredExecutions
|
|
||||||
|
|
||||||
for (const wf of workflowsToProcess) {
|
|
||||||
const selectedIndices = hasSelection ? selectedSegments[wf.workflowId] : null
|
|
||||||
let workflowHasExecutions = false
|
|
||||||
|
|
||||||
wf.segments.forEach((seg, idx) => {
|
|
||||||
// If segment selection exists, only count selected segments
|
|
||||||
// Otherwise, count all segments
|
|
||||||
if (!selectedIndices || selectedIndices.includes(idx)) {
|
|
||||||
const execCount = seg.totalExecutions || 0
|
|
||||||
totalExecutions += execCount
|
|
||||||
successfulExecutions += seg.successfulExecutions || 0
|
|
||||||
|
|
||||||
if (
|
|
||||||
seg.avgDurationMs !== undefined &&
|
|
||||||
seg.avgDurationMs !== null &&
|
|
||||||
seg.avgDurationMs > 0 &&
|
|
||||||
execCount > 0
|
|
||||||
) {
|
|
||||||
weightedLatencySum += seg.avgDurationMs * execCount
|
|
||||||
latencyExecutionCount += execCount
|
|
||||||
}
|
|
||||||
if (seg.hasExecutions) workflowHasExecutions = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (workflowHasExecutions) activeWorkflows += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
const failedExecutions = Math.max(totalExecutions - successfulExecutions, 0)
|
|
||||||
const successRate = totalExecutions > 0 ? (successfulExecutions / totalExecutions) * 100 : 100
|
|
||||||
const avgLatency = latencyExecutionCount > 0 ? weightedLatencySum / latencyExecutionCount : 0
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalExecutions,
|
|
||||||
successfulExecutions,
|
|
||||||
failedExecutions,
|
|
||||||
activeWorkflows,
|
|
||||||
successRate,
|
|
||||||
avgLatency,
|
avgLatency,
|
||||||
}
|
}
|
||||||
}, [filteredExecutions, selectedSegments, expandedWorkflowId])
|
}, [aggregateSegments, selectedSegments, filteredExecutions, expandedWorkflowId])
|
||||||
|
|
||||||
const loadMoreLogs = useCallback(
|
const handleToggleWorkflow = useCallback(
|
||||||
(workflowId: string) => {
|
(workflowId: string) => {
|
||||||
if (
|
toggleWorkflowId(workflowId)
|
||||||
workflowId === expandedWorkflowId &&
|
|
||||||
workflowLogsQuery.hasNextPage &&
|
|
||||||
!workflowLogsQuery.isFetchingNextPage
|
|
||||||
) {
|
|
||||||
workflowLogsQuery.fetchNextPage()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[expandedWorkflowId, workflowLogsQuery]
|
[toggleWorkflowId]
|
||||||
)
|
|
||||||
|
|
||||||
const loadMoreGlobalLogs = useCallback(() => {
|
|
||||||
if (globalLogsQuery.hasNextPage && !globalLogsQuery.isFetchingNextPage) {
|
|
||||||
globalLogsQuery.fetchNextPage()
|
|
||||||
}
|
|
||||||
}, [globalLogsQuery])
|
|
||||||
|
|
||||||
const toggleWorkflow = useCallback(
|
|
||||||
(workflowId: string) => {
|
|
||||||
if (expandedWorkflowId === workflowId) {
|
|
||||||
setExpandedWorkflowId(null)
|
|
||||||
setSelectedSegments({})
|
|
||||||
setLastAnchorIndices({})
|
|
||||||
} else {
|
|
||||||
setExpandedWorkflowId(workflowId)
|
|
||||||
setSelectedSegments({})
|
|
||||||
setLastAnchorIndices({})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[expandedWorkflowId]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles segment click for selecting time segments.
|
||||||
|
* @param workflowId - The workflow containing the segment
|
||||||
|
* @param segmentIndex - Index of the clicked segment
|
||||||
|
* @param _timestamp - Timestamp of the segment (unused)
|
||||||
|
* @param mode - Selection mode: 'single', 'toggle' (cmd+click), or 'range' (shift+click)
|
||||||
|
*/
|
||||||
const handleSegmentClick = useCallback(
|
const handleSegmentClick = useCallback(
|
||||||
(
|
(
|
||||||
workflowId: string,
|
workflowId: string,
|
||||||
@@ -618,22 +471,10 @@ export default function Dashboard({
|
|||||||
|
|
||||||
if (nextSegments.length === 0) {
|
if (nextSegments.length === 0) {
|
||||||
const { [workflowId]: _, ...rest } = prev
|
const { [workflowId]: _, ...rest } = prev
|
||||||
if (Object.keys(rest).length === 0) {
|
|
||||||
setExpandedWorkflowId(null)
|
|
||||||
}
|
|
||||||
return rest
|
return rest
|
||||||
}
|
}
|
||||||
|
|
||||||
const newState = { ...prev, [workflowId]: nextSegments }
|
return { ...prev, [workflowId]: nextSegments }
|
||||||
|
|
||||||
const selectedWorkflowIds = Object.keys(newState)
|
|
||||||
if (selectedWorkflowIds.length > 1) {
|
|
||||||
setExpandedWorkflowId('__multi__')
|
|
||||||
} else if (selectedWorkflowIds.length === 1) {
|
|
||||||
setExpandedWorkflowId(selectedWorkflowIds[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
return newState
|
|
||||||
})
|
})
|
||||||
|
|
||||||
setLastAnchorIndices((prev) => ({ ...prev, [workflowId]: segmentIndex }))
|
setLastAnchorIndices((prev) => ({ ...prev, [workflowId]: segmentIndex }))
|
||||||
@@ -645,85 +486,32 @@ export default function Dashboard({
|
|||||||
const isOnlyWorkflowSelected = Object.keys(prev).length === 1 && prev[workflowId]
|
const isOnlyWorkflowSelected = Object.keys(prev).length === 1 && prev[workflowId]
|
||||||
|
|
||||||
if (isOnlySelectedSegment && isOnlyWorkflowSelected) {
|
if (isOnlySelectedSegment && isOnlyWorkflowSelected) {
|
||||||
setExpandedWorkflowId(null)
|
|
||||||
setLastAnchorIndices({})
|
setLastAnchorIndices({})
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
setExpandedWorkflowId(workflowId)
|
|
||||||
setLastAnchorIndices({ [workflowId]: segmentIndex })
|
setLastAnchorIndices({ [workflowId]: segmentIndex })
|
||||||
return { [workflowId]: [segmentIndex] }
|
return { [workflowId]: [segmentIndex] }
|
||||||
})
|
})
|
||||||
} else if (mode === 'range') {
|
} else if (mode === 'range') {
|
||||||
if (expandedWorkflowId === workflowId) {
|
setSelectedSegments((prev) => {
|
||||||
setSelectedSegments((prev) => {
|
const currentSegments = prev[workflowId] || []
|
||||||
const currentSegments = prev[workflowId] || []
|
const anchor = lastAnchorIndices[workflowId] ?? segmentIndex
|
||||||
const anchor = lastAnchorIndices[workflowId] ?? segmentIndex
|
const [start, end] =
|
||||||
const [start, end] =
|
anchor < segmentIndex ? [anchor, segmentIndex] : [segmentIndex, anchor]
|
||||||
anchor < segmentIndex ? [anchor, segmentIndex] : [segmentIndex, anchor]
|
const range = Array.from({ length: end - start + 1 }, (_, i) => start + i)
|
||||||
const range = Array.from({ length: end - start + 1 }, (_, i) => start + i)
|
const union = new Set([...currentSegments, ...range])
|
||||||
const union = new Set([...currentSegments, ...range])
|
return { ...prev, [workflowId]: Array.from(union).sort((a, b) => a - b) }
|
||||||
return { ...prev, [workflowId]: Array.from(union).sort((a, b) => a - b) }
|
})
|
||||||
})
|
|
||||||
} else {
|
|
||||||
setExpandedWorkflowId(workflowId)
|
|
||||||
setSelectedSegments({ [workflowId]: [segmentIndex] })
|
|
||||||
setLastAnchorIndices({ [workflowId]: segmentIndex })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[expandedWorkflowId, lastAnchorIndices]
|
[lastAnchorIndices]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Update endTime when filters change to ensure consistent time ranges with logs view
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEndTime(new Date())
|
|
||||||
setSelectedSegments({})
|
setSelectedSegments({})
|
||||||
setLastAnchorIndices({})
|
setLastAnchorIndices({})
|
||||||
}, [timeFilter, workflowIds, folderIds, triggers, level, searchQuery])
|
}, [logs, timeRange, workflowIds, searchQuery])
|
||||||
|
|
||||||
// Clear expanded workflow if it's no longer in filtered executions
|
|
||||||
useEffect(() => {
|
|
||||||
if (expandedWorkflowId && expandedWorkflowId !== '__multi__') {
|
|
||||||
const isStillVisible = filteredExecutions.some((wf) => wf.workflowId === expandedWorkflowId)
|
|
||||||
if (!isStillVisible) {
|
|
||||||
setExpandedWorkflowId(null)
|
|
||||||
setSelectedSegments({})
|
|
||||||
setLastAnchorIndices({})
|
|
||||||
}
|
|
||||||
} else if (expandedWorkflowId === '__multi__') {
|
|
||||||
// Check if any of the selected workflows are still visible
|
|
||||||
const selectedWorkflowIds = Object.keys(selectedSegments)
|
|
||||||
const stillVisibleIds = selectedWorkflowIds.filter((id) =>
|
|
||||||
filteredExecutions.some((wf) => wf.workflowId === id)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (stillVisibleIds.length === 0) {
|
|
||||||
setExpandedWorkflowId(null)
|
|
||||||
setSelectedSegments({})
|
|
||||||
setLastAnchorIndices({})
|
|
||||||
} else if (stillVisibleIds.length !== selectedWorkflowIds.length) {
|
|
||||||
// Remove segments for workflows that are no longer visible
|
|
||||||
const updatedSegments: Record<string, number[]> = {}
|
|
||||||
stillVisibleIds.forEach((id) => {
|
|
||||||
if (selectedSegments[id]) {
|
|
||||||
updatedSegments[id] = selectedSegments[id]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
setSelectedSegments(updatedSegments)
|
|
||||||
|
|
||||||
if (stillVisibleIds.length === 1) {
|
|
||||||
setExpandedWorkflowId(stillVisibleIds[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [filteredExecutions, expandedWorkflowId, selectedSegments])
|
|
||||||
|
|
||||||
// Notify parent when custom time range is active
|
|
||||||
useEffect(() => {
|
|
||||||
const hasCustomRange = Object.keys(selectedSegments).length > 0
|
|
||||||
onCustomTimeRangeChange?.(hasCustomRange)
|
|
||||||
}, [selectedSegments, onCustomTimeRangeChange])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!barsAreaRef.current) return
|
if (!barsAreaRef.current) return
|
||||||
@@ -749,43 +537,30 @@ export default function Dashboard({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Live mode: refresh endTime periodically
|
if (isLoading && Object.keys(allWorkflows).length === 0) {
|
||||||
useEffect(() => {
|
|
||||||
if (!isLive) return
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
setEndTime(new Date())
|
|
||||||
}, 5000)
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [isLive])
|
|
||||||
|
|
||||||
// Refresh when trigger changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (refreshTrigger > 0) {
|
|
||||||
setEndTime(new Date())
|
|
||||||
}
|
|
||||||
}, [refreshTrigger])
|
|
||||||
|
|
||||||
if (showSkeleton) {
|
|
||||||
return <DashboardSkeleton />
|
return <DashboardSkeleton />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show error state
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className='mt-[24px] flex flex-1 items-center justify-center'>
|
<div className='mt-[24px] flex flex-1 items-center justify-center'>
|
||||||
<div className='text-[var(--text-error)]'>
|
<div className='text-[var(--text-error)]'>
|
||||||
<p className='font-medium text-[13px]'>Error loading data</p>
|
<p className='font-medium text-[13px]'>Error loading data</p>
|
||||||
<p className='text-[12px]'>{error}</p>
|
<p className='text-[12px]'>{error.message}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (executions.length === 0) {
|
if (Object.keys(allWorkflows).length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className='mt-[24px] flex flex-1 items-center justify-center'>
|
<div className='mt-[24px] flex flex-1 items-center justify-center'>
|
||||||
<div className='text-center text-[var(--text-secondary)]'>
|
<div className='text-center text-[var(--text-secondary)]'>
|
||||||
<p className='font-medium text-[13px]'>No execution history</p>
|
<p className='font-medium text-[13px]'>No workflows</p>
|
||||||
<p className='mt-[4px] text-[12px]'>Execute some workflows to see their history here</p>
|
<p className='mt-[4px] text-[12px]'>
|
||||||
|
Create a workflow to see its execution history here
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -793,18 +568,16 @@ export default function Dashboard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mt-[24px] flex min-h-0 flex-1 flex-col pb-[24px]'>
|
<div className='mt-[24px] flex min-h-0 flex-1 flex-col pb-[24px]'>
|
||||||
{/* Graphs Section */}
|
|
||||||
<div className='mb-[16px] flex-shrink-0'>
|
<div className='mb-[16px] flex-shrink-0'>
|
||||||
<div className='grid grid-cols-1 gap-[16px] md:grid-cols-3'>
|
<div className='grid grid-cols-1 gap-[16px] md:grid-cols-3'>
|
||||||
{/* Runs Graph */}
|
|
||||||
<div className='flex flex-col overflow-hidden rounded-[6px] bg-[var(--surface-elevated)]'>
|
<div className='flex flex-col overflow-hidden rounded-[6px] bg-[var(--surface-elevated)]'>
|
||||||
<div className='flex min-w-0 items-center justify-between gap-[8px] bg-[var(--surface-3)] px-[16px] py-[9px]'>
|
<div className='flex min-w-0 items-center justify-between gap-[8px] bg-[var(--surface-3)] px-[16px] py-[9px]'>
|
||||||
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-sm'>
|
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-sm'>
|
||||||
Runs
|
Runs
|
||||||
</span>
|
</span>
|
||||||
{globalDetails && globalDetails.executionCounts.length > 0 && (
|
{globalDetails && (
|
||||||
<span className='flex-shrink-0 font-medium text-[var(--text-secondary)] text-sm'>
|
<span className='flex-shrink-0 font-medium text-[var(--text-secondary)] text-sm'>
|
||||||
{aggregate.totalExecutions}
|
{globalDetails.totalRuns}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -824,15 +597,14 @@ export default function Dashboard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Errors Graph */}
|
|
||||||
<div className='flex flex-col overflow-hidden rounded-[6px] bg-[var(--surface-elevated)]'>
|
<div className='flex flex-col overflow-hidden rounded-[6px] bg-[var(--surface-elevated)]'>
|
||||||
<div className='flex min-w-0 items-center justify-between gap-[8px] bg-[var(--surface-3)] px-[16px] py-[9px]'>
|
<div className='flex min-w-0 items-center justify-between gap-[8px] bg-[var(--surface-3)] px-[16px] py-[9px]'>
|
||||||
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-sm'>
|
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-sm'>
|
||||||
Errors
|
Errors
|
||||||
</span>
|
</span>
|
||||||
{globalDetails && globalDetails.failureCounts.length > 0 && (
|
{globalDetails && (
|
||||||
<span className='flex-shrink-0 font-medium text-[var(--text-secondary)] text-sm'>
|
<span className='flex-shrink-0 font-medium text-[var(--text-secondary)] text-sm'>
|
||||||
{aggregate.failedExecutions}
|
{globalDetails.totalErrors}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -852,15 +624,14 @@ export default function Dashboard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Latency Graph */}
|
|
||||||
<div className='flex flex-col overflow-hidden rounded-[6px] bg-[var(--surface-elevated)]'>
|
<div className='flex flex-col overflow-hidden rounded-[6px] bg-[var(--surface-elevated)]'>
|
||||||
<div className='flex min-w-0 items-center justify-between gap-[8px] bg-[var(--surface-3)] px-[16px] py-[9px]'>
|
<div className='flex min-w-0 items-center justify-between gap-[8px] bg-[var(--surface-3)] px-[16px] py-[9px]'>
|
||||||
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-sm'>
|
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-sm'>
|
||||||
Latency
|
Latency
|
||||||
</span>
|
</span>
|
||||||
{globalDetails && globalDetails.latencies.length > 0 && (
|
{globalDetails && (
|
||||||
<span className='flex-shrink-0 font-medium text-[var(--text-secondary)] text-sm'>
|
<span className='flex-shrink-0 font-medium text-[var(--text-secondary)] text-sm'>
|
||||||
{formatLatency(aggregate.avgLatency)}
|
{formatLatency(globalDetails.avgLatency)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -882,19 +653,15 @@ export default function Dashboard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Workflows Table - takes remaining space */}
|
|
||||||
<div className='min-h-0 flex-1 overflow-hidden' ref={barsAreaRef}>
|
<div className='min-h-0 flex-1 overflow-hidden' ref={barsAreaRef}>
|
||||||
<WorkflowsList
|
<WorkflowsList
|
||||||
executions={executions as WorkflowExecution[]}
|
|
||||||
filteredExecutions={filteredExecutions as WorkflowExecution[]}
|
filteredExecutions={filteredExecutions as WorkflowExecution[]}
|
||||||
expandedWorkflowId={expandedWorkflowId}
|
expandedWorkflowId={expandedWorkflowId}
|
||||||
onToggleWorkflow={toggleWorkflow}
|
onToggleWorkflow={handleToggleWorkflow}
|
||||||
selectedSegments={selectedSegments}
|
selectedSegments={selectedSegments}
|
||||||
onSegmentClick={handleSegmentClick}
|
onSegmentClick={handleSegmentClick}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
segmentDurationMs={
|
segmentDurationMs={segmentMs}
|
||||||
(endTime.getTime() - getStartTime().getTime()) / Math.max(1, segmentCount)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export { LogDetails } from './log-details'
|
|||||||
export { FileCards } from './log-details/components/file-download'
|
export { FileCards } from './log-details/components/file-download'
|
||||||
export { FrozenCanvas } from './log-details/components/frozen-canvas'
|
export { FrozenCanvas } from './log-details/components/frozen-canvas'
|
||||||
export { TraceSpans } from './log-details/components/trace-spans'
|
export { TraceSpans } from './log-details/components/trace-spans'
|
||||||
|
export { LogsList } from './logs-list'
|
||||||
export {
|
export {
|
||||||
AutocompleteSearch,
|
AutocompleteSearch,
|
||||||
Controls,
|
Controls,
|
||||||
|
|||||||
@@ -34,9 +34,6 @@ interface FileCardProps {
|
|||||||
workspaceId?: string
|
workspaceId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats file size to human readable format
|
|
||||||
*/
|
|
||||||
function formatFileSize(bytes: number): string {
|
function formatFileSize(bytes: number): string {
|
||||||
if (bytes === 0) return '0 B'
|
if (bytes === 0) return '0 B'
|
||||||
const k = 1024
|
const k = 1024
|
||||||
@@ -45,9 +42,6 @@ function formatFileSize(bytes: number): string {
|
|||||||
return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`
|
return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Individual file card component
|
|
||||||
*/
|
|
||||||
function FileCard({ file, isExecutionFile = false, workspaceId }: FileCardProps) {
|
function FileCard({ file, isExecutionFile = false, workspaceId }: FileCardProps) {
|
||||||
const [isDownloading, setIsDownloading] = useState(false)
|
const [isDownloading, setIsDownloading] = useState(false)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -142,10 +136,6 @@ function FileCard({ file, isExecutionFile = false, workspaceId }: FileCardProps)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Container component for displaying workflow execution files.
|
|
||||||
* Each file is displayed as a separate card with consistent styling.
|
|
||||||
*/
|
|
||||||
export function FileCards({ files, isExecutionFile = false, workspaceId }: FileCardsProps) {
|
export function FileCards({ files, isExecutionFile = false, workspaceId }: FileCardsProps) {
|
||||||
if (!files || files.length === 0) {
|
if (!files || files.length === 0) {
|
||||||
return null
|
return null
|
||||||
@@ -170,9 +160,6 @@ export function FileCards({ files, isExecutionFile = false, workspaceId }: FileC
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Single file download button (legacy export for backwards compatibility)
|
|
||||||
*/
|
|
||||||
export function FileDownload({
|
export function FileDownload({
|
||||||
file,
|
file,
|
||||||
isExecutionFile = false,
|
isExecutionFile = false,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
import { useCallback, useMemo, useState } from 'react'
|
import { memo, useCallback, useMemo, useState } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { ChevronDown, Code } from '@/components/emcn'
|
import { ChevronDown, Code } from '@/components/emcn'
|
||||||
import { WorkflowIcon } from '@/components/icons'
|
import { WorkflowIcon } from '@/components/icons'
|
||||||
@@ -531,9 +531,10 @@ interface TraceSpanItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Individual trace span card component
|
* Individual trace span card component.
|
||||||
|
* Memoized to prevent re-renders when sibling spans change.
|
||||||
*/
|
*/
|
||||||
function TraceSpanItem({
|
const TraceSpanItem = memo(function TraceSpanItem({
|
||||||
span,
|
span,
|
||||||
totalDuration,
|
totalDuration,
|
||||||
workflowStartTime,
|
workflowStartTime,
|
||||||
@@ -779,12 +780,16 @@ function TraceSpanItem({
|
|||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Displays workflow execution trace spans with nested structure
|
* Displays workflow execution trace spans with nested structure.
|
||||||
|
* Memoized to prevent re-renders when parent LogDetails updates.
|
||||||
*/
|
*/
|
||||||
export function TraceSpans({ traceSpans, totalDuration = 0 }: TraceSpansProps) {
|
export const TraceSpans = memo(function TraceSpans({
|
||||||
|
traceSpans,
|
||||||
|
totalDuration = 0,
|
||||||
|
}: TraceSpansProps) {
|
||||||
const { workflowStartTime, actualTotalDuration, normalizedSpans } = useMemo(() => {
|
const { workflowStartTime, actualTotalDuration, normalizedSpans } = useMemo(() => {
|
||||||
if (!traceSpans || traceSpans.length === 0) {
|
if (!traceSpans || traceSpans.length === 0) {
|
||||||
return { workflowStartTime: 0, actualTotalDuration: totalDuration, normalizedSpans: [] }
|
return { workflowStartTime: 0, actualTotalDuration: totalDuration, normalizedSpans: [] }
|
||||||
@@ -827,4 +832,4 @@ export function TraceSpans({ traceSpans, totalDuration = 0 }: TraceSpansProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { memo, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { ChevronUp, X } from 'lucide-react'
|
import { ChevronUp, X } from 'lucide-react'
|
||||||
import { Button, Eye } from '@/components/emcn'
|
import { Button, Eye } from '@/components/emcn'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
@@ -36,7 +36,7 @@ interface LogDetailsProps {
|
|||||||
* @param props - Component props
|
* @param props - Component props
|
||||||
* @returns Log details sidebar component
|
* @returns Log details sidebar component
|
||||||
*/
|
*/
|
||||||
export function LogDetails({
|
export const LogDetails = memo(function LogDetails({
|
||||||
log,
|
log,
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -95,7 +95,10 @@ export function LogDetails({
|
|||||||
navigateFunction()
|
navigateFunction()
|
||||||
}
|
}
|
||||||
|
|
||||||
const formattedTimestamp = log ? formatDate(log.createdAt) : null
|
const formattedTimestamp = useMemo(
|
||||||
|
() => (log ? formatDate(log.createdAt) : null),
|
||||||
|
[log?.createdAt]
|
||||||
|
)
|
||||||
|
|
||||||
const logStatus: LogStatus = useMemo(() => {
|
const logStatus: LogStatus = useMemo(() => {
|
||||||
if (!log) return 'info'
|
if (!log) return 'info'
|
||||||
@@ -140,7 +143,7 @@ export function LogDetails({
|
|||||||
disabled={!hasPrev}
|
disabled={!hasPrev}
|
||||||
aria-label='Previous log'
|
aria-label='Previous log'
|
||||||
>
|
>
|
||||||
<ChevronUp className='h-[14px] w-[14px] rotate-180' />
|
<ChevronUp className='h-[14px] w-[14px]' />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
@@ -149,7 +152,7 @@ export function LogDetails({
|
|||||||
disabled={!hasNext}
|
disabled={!hasNext}
|
||||||
aria-label='Next log'
|
aria-label='Next log'
|
||||||
>
|
>
|
||||||
<ChevronUp className='h-[14px] w-[14px]' />
|
<ChevronUp className='h-[14px] w-[14px] rotate-180' />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant='ghost' className='!p-[4px]' onClick={onClose} aria-label='Close'>
|
<Button variant='ghost' className='!p-[4px]' onClick={onClose} aria-label='Close'>
|
||||||
<X className='h-[14px] w-[14px]' />
|
<X className='h-[14px] w-[14px]' />
|
||||||
@@ -374,4 +377,4 @@ export function LogDetails({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -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,
|
suggestions,
|
||||||
sections,
|
sections,
|
||||||
highlightedIndex,
|
highlightedIndex,
|
||||||
|
highlightedBadgeIndex,
|
||||||
inputRef,
|
inputRef,
|
||||||
dropdownRef,
|
dropdownRef,
|
||||||
handleInputChange,
|
handleInputChange,
|
||||||
@@ -162,7 +163,7 @@ export function AutocompleteSearch({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PopoverAnchor asChild>
|
<PopoverAnchor asChild>
|
||||||
<div className='relative flex h-[32px] w-[400px] items-center rounded-[8px] bg-[var(--surface-5)]'>
|
<div className='relative flex h-[32px] w-full items-center rounded-[8px] bg-[var(--surface-5)]'>
|
||||||
{/* Search Icon */}
|
{/* Search Icon */}
|
||||||
<Search className='mr-[6px] ml-[8px] h-[14px] w-[14px] flex-shrink-0 text-[var(--text-subtle)]' />
|
<Search className='mr-[6px] ml-[8px] h-[14px] w-[14px] flex-shrink-0 text-[var(--text-subtle)]' />
|
||||||
|
|
||||||
@@ -175,7 +176,11 @@ export function AutocompleteSearch({
|
|||||||
variant='outline'
|
variant='outline'
|
||||||
role='button'
|
role='button'
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className='h-6 shrink-0 cursor-pointer whitespace-nowrap rounded-md px-2 text-[11px]'
|
className={cn(
|
||||||
|
'h-6 shrink-0 cursor-pointer whitespace-nowrap rounded-md px-2 text-[11px]',
|
||||||
|
highlightedBadgeIndex === index &&
|
||||||
|
'ring-1 ring-[var(--border-focus)] ring-offset-1 ring-offset-[var(--surface-5)]'
|
||||||
|
)}
|
||||||
onClick={() => removeBadge(index)}
|
onClick={() => removeBadge(index)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
|||||||
@@ -1,24 +1,25 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useMemo } from 'react'
|
||||||
import { ArrowUp, Bell, Library, Loader2, MoreHorizontal, RefreshCw } from 'lucide-react'
|
import { ArrowUp, Bell, Library, MoreHorizontal, RefreshCw } from 'lucide-react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Combobox,
|
Combobox,
|
||||||
type ComboboxOption,
|
type ComboboxOption,
|
||||||
|
Loader,
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverItem,
|
PopoverItem,
|
||||||
PopoverScrollArea,
|
PopoverScrollArea,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
Tooltip,
|
|
||||||
} from '@/components/emcn'
|
} from '@/components/emcn'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
|
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
|
||||||
import { getBlock } from '@/blocks/registry'
|
import { getBlock } from '@/blocks/registry'
|
||||||
import { useFolderStore } from '@/stores/folders/store'
|
import { useFolderStore } from '@/stores/folders/store'
|
||||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
import { useFilterStore } from '@/stores/logs/filters/store'
|
||||||
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
import { AutocompleteSearch } from './components/search'
|
import { AutocompleteSearch } from './components/search'
|
||||||
|
|
||||||
const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook'] as const
|
const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook'] as const
|
||||||
@@ -155,22 +156,15 @@ export function LogsToolbar({
|
|||||||
} = useFilterStore()
|
} = useFilterStore()
|
||||||
const folders = useFolderStore((state) => state.folders)
|
const folders = useFolderStore((state) => state.folders)
|
||||||
|
|
||||||
const [workflows, setWorkflows] = useState<Array<{ id: string; name: string; color: string }>>([])
|
const allWorkflows = useWorkflowRegistry((state) => state.workflows)
|
||||||
|
|
||||||
useEffect(() => {
|
const workflows = useMemo(() => {
|
||||||
const fetchWorkflows = async () => {
|
return Object.values(allWorkflows).map((w) => ({
|
||||||
try {
|
id: w.id,
|
||||||
const res = await fetch(`/api/workflows?workspaceId=${encodeURIComponent(workspaceId)}`)
|
name: w.name,
|
||||||
if (res.ok) {
|
color: w.color,
|
||||||
const body = await res.json()
|
}))
|
||||||
setWorkflows(Array.isArray(body?.data) ? body.data : [])
|
}, [allWorkflows])
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setWorkflows([])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (workspaceId) fetchWorkflows()
|
|
||||||
}, [workspaceId])
|
|
||||||
|
|
||||||
const folderList = useMemo(() => {
|
const folderList = useMemo(() => {
|
||||||
return Object.values(folders).filter((f) => f.workspaceId === workspaceId)
|
return Object.values(folders).filter((f) => f.workspaceId === workspaceId)
|
||||||
@@ -178,7 +172,6 @@ export function LogsToolbar({
|
|||||||
|
|
||||||
const isDashboardView = viewMode === 'dashboard'
|
const isDashboardView = viewMode === 'dashboard'
|
||||||
|
|
||||||
// Status filter
|
|
||||||
const selectedStatuses = useMemo((): string[] => {
|
const selectedStatuses = useMemo((): string[] => {
|
||||||
if (level === 'all' || !level) return []
|
if (level === 'all' || !level) return []
|
||||||
return level.split(',').filter(Boolean)
|
return level.split(',').filter(Boolean)
|
||||||
@@ -199,7 +192,7 @@ export function LogsToolbar({
|
|||||||
if (values.length === 0) {
|
if (values.length === 0) {
|
||||||
setLevel('all')
|
setLevel('all')
|
||||||
} else {
|
} else {
|
||||||
setLevel(values.join(',') as any)
|
setLevel(values.join(','))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setLevel]
|
[setLevel]
|
||||||
@@ -224,7 +217,6 @@ export function LogsToolbar({
|
|||||||
return null
|
return null
|
||||||
}, [selectedStatuses])
|
}, [selectedStatuses])
|
||||||
|
|
||||||
// Workflow filter
|
|
||||||
const workflowOptions: ComboboxOption[] = useMemo(
|
const workflowOptions: ComboboxOption[] = useMemo(
|
||||||
() => workflows.map((w) => ({ value: w.id, label: w.name, icon: getColorIcon(w.color) })),
|
() => workflows.map((w) => ({ value: w.id, label: w.name, icon: getColorIcon(w.color) })),
|
||||||
[workflows]
|
[workflows]
|
||||||
@@ -242,7 +234,6 @@ export function LogsToolbar({
|
|||||||
const selectedWorkflow =
|
const selectedWorkflow =
|
||||||
workflowIds.length === 1 ? workflows.find((w) => w.id === workflowIds[0]) : null
|
workflowIds.length === 1 ? workflows.find((w) => w.id === workflowIds[0]) : null
|
||||||
|
|
||||||
// Folder filter
|
|
||||||
const folderOptions: ComboboxOption[] = useMemo(
|
const folderOptions: ComboboxOption[] = useMemo(
|
||||||
() => folderList.map((f) => ({ value: f.id, label: f.name })),
|
() => folderList.map((f) => ({ value: f.id, label: f.name })),
|
||||||
[folderList]
|
[folderList]
|
||||||
@@ -257,7 +248,6 @@ export function LogsToolbar({
|
|||||||
return `${folderIds.length} folders`
|
return `${folderIds.length} folders`
|
||||||
}, [folderIds, folderList])
|
}, [folderIds, folderList])
|
||||||
|
|
||||||
// Trigger filter
|
|
||||||
const triggerOptions: ComboboxOption[] = useMemo(
|
const triggerOptions: ComboboxOption[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
getTriggerOptions().map((t) => ({
|
getTriggerOptions().map((t) => ({
|
||||||
@@ -282,6 +272,24 @@ export function LogsToolbar({
|
|||||||
return timeRange
|
return timeRange
|
||||||
}, [timeRange])
|
}, [timeRange])
|
||||||
|
|
||||||
|
const hasActiveFilters = useMemo(() => {
|
||||||
|
return (
|
||||||
|
level !== 'all' ||
|
||||||
|
workflowIds.length > 0 ||
|
||||||
|
folderIds.length > 0 ||
|
||||||
|
triggers.length > 0 ||
|
||||||
|
timeRange !== 'All time'
|
||||||
|
)
|
||||||
|
}, [level, workflowIds, folderIds, triggers, timeRange])
|
||||||
|
|
||||||
|
const handleClearFilters = useCallback(() => {
|
||||||
|
setLevel('all')
|
||||||
|
setWorkflowIds([])
|
||||||
|
setFolderIds([])
|
||||||
|
setTriggers([])
|
||||||
|
setTimeRange('All time')
|
||||||
|
}, [setLevel, setWorkflowIds, setFolderIds, setTriggers, setTimeRange])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col gap-[19px]'>
|
<div className='flex flex-col gap-[19px]'>
|
||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
@@ -316,22 +324,18 @@ export function LogsToolbar({
|
|||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
{/* Refresh button */}
|
{/* Refresh button */}
|
||||||
<Tooltip.Root>
|
<Button
|
||||||
<Tooltip.Trigger asChild>
|
variant='default'
|
||||||
<Button
|
className='h-[32px] rounded-[6px] px-[10px]'
|
||||||
variant='default'
|
onClick={isRefreshing ? undefined : onRefresh}
|
||||||
className={cn('h-[32px] w-[32px] rounded-[6px] p-0', isRefreshing && 'opacity-50')}
|
disabled={isRefreshing}
|
||||||
onClick={isRefreshing ? undefined : onRefresh}
|
>
|
||||||
>
|
{isRefreshing ? (
|
||||||
{isRefreshing ? (
|
<Loader className='h-[14px] w-[14px]' animate />
|
||||||
<Loader2 className='h-[14px] w-[14px] animate-spin' />
|
) : (
|
||||||
) : (
|
<RefreshCw className='h-[14px] w-[14px]' />
|
||||||
<RefreshCw className='h-[14px] w-[14px]' />
|
)}
|
||||||
)}
|
</Button>
|
||||||
</Button>
|
|
||||||
</Tooltip.Trigger>
|
|
||||||
<Tooltip.Content>{isRefreshing ? 'Refreshing...' : 'Refresh'}</Tooltip.Content>
|
|
||||||
</Tooltip.Root>
|
|
||||||
|
|
||||||
{/* Live button */}
|
{/* Live button */}
|
||||||
<Button
|
<Button
|
||||||
@@ -365,7 +369,7 @@ export function LogsToolbar({
|
|||||||
|
|
||||||
{/* Filter Bar Section */}
|
{/* Filter Bar Section */}
|
||||||
<div className='flex w-full items-center gap-[12px]'>
|
<div className='flex w-full items-center gap-[12px]'>
|
||||||
<div className='min-w-0 flex-1'>
|
<div className='min-w-[200px] max-w-[400px] flex-1'>
|
||||||
<AutocompleteSearch
|
<AutocompleteSearch
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={onSearchQueryChange}
|
onChange={onSearchQueryChange}
|
||||||
@@ -373,110 +377,269 @@ export function LogsToolbar({
|
|||||||
onOpenChange={onSearchOpenChange}
|
onOpenChange={onSearchOpenChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex items-center gap-[8px]'>
|
<div className='ml-auto flex items-center gap-[8px]'>
|
||||||
{/* Status Filter */}
|
{/* Clear Filters Button */}
|
||||||
<Combobox
|
{hasActiveFilters && (
|
||||||
options={statusOptions}
|
<Button
|
||||||
multiSelect
|
variant='active'
|
||||||
multiSelectValues={selectedStatuses}
|
onClick={handleClearFilters}
|
||||||
onMultiSelectChange={handleStatusChange}
|
className='h-[32px] rounded-[6px] px-[10px]'
|
||||||
placeholder='Status'
|
>
|
||||||
overlayContent={
|
<span>Clear</span>
|
||||||
<span className='flex items-center gap-[6px] truncate text-[var(--text-primary)]'>
|
</Button>
|
||||||
{selectedStatusColor && (
|
)}
|
||||||
<div
|
|
||||||
className='flex-shrink-0 rounded-[3px]'
|
{/* Filters Popover - Small screens only */}
|
||||||
style={{ backgroundColor: selectedStatusColor, width: 8, height: 8 }}
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant='active'
|
||||||
|
className='h-[32px] gap-[6px] rounded-[6px] px-[10px] xl:hidden'
|
||||||
|
>
|
||||||
|
<span>Filters</span>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align='end' sideOffset={4} className='w-[280px] p-[12px]'>
|
||||||
|
<div className='flex flex-col gap-[12px]'>
|
||||||
|
{/* Status Filter */}
|
||||||
|
<div className='flex flex-col gap-[6px]'>
|
||||||
|
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||||
|
Status
|
||||||
|
</span>
|
||||||
|
<Combobox
|
||||||
|
options={statusOptions}
|
||||||
|
multiSelect
|
||||||
|
multiSelectValues={selectedStatuses}
|
||||||
|
onMultiSelectChange={handleStatusChange}
|
||||||
|
placeholder='All statuses'
|
||||||
|
overlayContent={
|
||||||
|
<span className='flex items-center gap-[6px] truncate text-[var(--text-primary)]'>
|
||||||
|
{selectedStatusColor && (
|
||||||
|
<div
|
||||||
|
className='flex-shrink-0 rounded-[3px]'
|
||||||
|
style={{ backgroundColor: selectedStatusColor, width: 8, height: 8 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className='truncate'>{statusDisplayLabel}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
showAllOption
|
||||||
|
allOptionLabel='All statuses'
|
||||||
|
size='sm'
|
||||||
|
className='h-[32px] w-full rounded-[6px]'
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
<span className='truncate'>{statusDisplayLabel}</span>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
showAllOption
|
|
||||||
allOptionLabel='All statuses'
|
|
||||||
size='sm'
|
|
||||||
align='end'
|
|
||||||
className='h-[32px] w-[100px] rounded-[6px]'
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Workflow Filter */}
|
{/* Workflow Filter */}
|
||||||
<Combobox
|
<div className='flex flex-col gap-[6px]'>
|
||||||
options={workflowOptions}
|
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||||
multiSelect
|
Workflow
|
||||||
multiSelectValues={workflowIds}
|
</span>
|
||||||
onMultiSelectChange={setWorkflowIds}
|
<Combobox
|
||||||
placeholder='Workflow'
|
options={workflowOptions}
|
||||||
overlayContent={
|
multiSelect
|
||||||
<span className='flex items-center gap-[6px] truncate text-[var(--text-primary)]'>
|
multiSelectValues={workflowIds}
|
||||||
{selectedWorkflow && (
|
onMultiSelectChange={setWorkflowIds}
|
||||||
<div
|
placeholder='All workflows'
|
||||||
className='h-[8px] w-[8px] flex-shrink-0 rounded-[2px]'
|
overlayContent={
|
||||||
style={{ backgroundColor: selectedWorkflow.color }}
|
<span className='flex items-center gap-[6px] truncate text-[var(--text-primary)]'>
|
||||||
|
{selectedWorkflow && (
|
||||||
|
<div
|
||||||
|
className='h-[8px] w-[8px] flex-shrink-0 rounded-[2px]'
|
||||||
|
style={{ backgroundColor: selectedWorkflow.color }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className='truncate'>{workflowDisplayLabel}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
searchable
|
||||||
|
searchPlaceholder='Search workflows...'
|
||||||
|
showAllOption
|
||||||
|
allOptionLabel='All workflows'
|
||||||
|
size='sm'
|
||||||
|
className='h-[32px] w-full rounded-[6px]'
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
<span className='truncate'>{workflowDisplayLabel}</span>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
searchable
|
|
||||||
searchPlaceholder='Search workflows...'
|
|
||||||
showAllOption
|
|
||||||
allOptionLabel='All workflows'
|
|
||||||
size='sm'
|
|
||||||
align='end'
|
|
||||||
className='h-[32px] w-[120px] rounded-[6px]'
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Folder Filter */}
|
{/* Folder Filter */}
|
||||||
<Combobox
|
<div className='flex flex-col gap-[6px]'>
|
||||||
options={folderOptions}
|
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||||
multiSelect
|
Folder
|
||||||
multiSelectValues={folderIds}
|
</span>
|
||||||
onMultiSelectChange={setFolderIds}
|
<Combobox
|
||||||
placeholder='Folder'
|
options={folderOptions}
|
||||||
overlayContent={
|
multiSelect
|
||||||
<span className='truncate text-[var(--text-primary)]'>{folderDisplayLabel}</span>
|
multiSelectValues={folderIds}
|
||||||
}
|
onMultiSelectChange={setFolderIds}
|
||||||
searchable
|
placeholder='All folders'
|
||||||
searchPlaceholder='Search folders...'
|
overlayContent={
|
||||||
showAllOption
|
<span className='truncate text-[var(--text-primary)]'>
|
||||||
allOptionLabel='All folders'
|
{folderDisplayLabel}
|
||||||
size='sm'
|
</span>
|
||||||
align='end'
|
}
|
||||||
className='h-[32px] w-[100px] rounded-[6px]'
|
searchable
|
||||||
/>
|
searchPlaceholder='Search folders...'
|
||||||
|
showAllOption
|
||||||
|
allOptionLabel='All folders'
|
||||||
|
size='sm'
|
||||||
|
className='h-[32px] w-full rounded-[6px]'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Trigger Filter */}
|
{/* Trigger Filter */}
|
||||||
<Combobox
|
<div className='flex flex-col gap-[6px]'>
|
||||||
options={triggerOptions}
|
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||||
multiSelect
|
Trigger
|
||||||
multiSelectValues={triggers}
|
</span>
|
||||||
onMultiSelectChange={setTriggers}
|
<Combobox
|
||||||
placeholder='Trigger'
|
options={triggerOptions}
|
||||||
overlayContent={
|
multiSelect
|
||||||
<span className='truncate text-[var(--text-primary)]'>{triggerDisplayLabel}</span>
|
multiSelectValues={triggers}
|
||||||
}
|
onMultiSelectChange={setTriggers}
|
||||||
searchable
|
placeholder='All triggers'
|
||||||
searchPlaceholder='Search triggers...'
|
overlayContent={
|
||||||
showAllOption
|
<span className='truncate text-[var(--text-primary)]'>
|
||||||
allOptionLabel='All triggers'
|
{triggerDisplayLabel}
|
||||||
size='sm'
|
</span>
|
||||||
align='end'
|
}
|
||||||
className='h-[32px] w-[100px] rounded-[6px]'
|
searchable
|
||||||
/>
|
searchPlaceholder='Search triggers...'
|
||||||
|
showAllOption
|
||||||
|
allOptionLabel='All triggers'
|
||||||
|
size='sm'
|
||||||
|
className='h-[32px] w-full rounded-[6px]'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Timeline Filter */}
|
{/* Time Filter */}
|
||||||
<Combobox
|
<div className='flex flex-col gap-[6px]'>
|
||||||
options={TIME_RANGE_OPTIONS as unknown as ComboboxOption[]}
|
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||||
value={timeRange}
|
Time Range
|
||||||
onChange={(val) => setTimeRange(val as typeof timeRange)}
|
</span>
|
||||||
placeholder='Time'
|
<Combobox
|
||||||
overlayContent={
|
options={TIME_RANGE_OPTIONS as unknown as ComboboxOption[]}
|
||||||
<span className='truncate text-[var(--text-primary)]'>{timeDisplayLabel}</span>
|
value={timeRange}
|
||||||
}
|
onChange={(val) => setTimeRange(val as typeof timeRange)}
|
||||||
size='sm'
|
placeholder='All time'
|
||||||
align='end'
|
overlayContent={
|
||||||
className='h-[32px] w-[140px] rounded-[6px]'
|
<span className='truncate text-[var(--text-primary)]'>
|
||||||
/>
|
{timeDisplayLabel}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
size='sm'
|
||||||
|
className='h-[32px] w-full rounded-[6px]'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{/* Inline Filters - Large screens only */}
|
||||||
|
<div className='hidden items-center gap-[8px] xl:flex'>
|
||||||
|
{/* Status Filter */}
|
||||||
|
<Combobox
|
||||||
|
options={statusOptions}
|
||||||
|
multiSelect
|
||||||
|
multiSelectValues={selectedStatuses}
|
||||||
|
onMultiSelectChange={handleStatusChange}
|
||||||
|
placeholder='Status'
|
||||||
|
overlayContent={
|
||||||
|
<span className='flex items-center gap-[6px] truncate text-[var(--text-primary)]'>
|
||||||
|
{selectedStatusColor && (
|
||||||
|
<div
|
||||||
|
className='flex-shrink-0 rounded-[3px]'
|
||||||
|
style={{ backgroundColor: selectedStatusColor, width: 8, height: 8 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className='truncate'>{statusDisplayLabel}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
showAllOption
|
||||||
|
allOptionLabel='All statuses'
|
||||||
|
size='sm'
|
||||||
|
align='end'
|
||||||
|
className='h-[32px] w-[120px] rounded-[6px]'
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Workflow Filter */}
|
||||||
|
<Combobox
|
||||||
|
options={workflowOptions}
|
||||||
|
multiSelect
|
||||||
|
multiSelectValues={workflowIds}
|
||||||
|
onMultiSelectChange={setWorkflowIds}
|
||||||
|
placeholder='Workflow'
|
||||||
|
overlayContent={
|
||||||
|
<span className='flex items-center gap-[6px] truncate text-[var(--text-primary)]'>
|
||||||
|
{selectedWorkflow && (
|
||||||
|
<div
|
||||||
|
className='h-[8px] w-[8px] flex-shrink-0 rounded-[2px]'
|
||||||
|
style={{ backgroundColor: selectedWorkflow.color }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className='truncate'>{workflowDisplayLabel}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
searchable
|
||||||
|
searchPlaceholder='Search workflows...'
|
||||||
|
showAllOption
|
||||||
|
allOptionLabel='All workflows'
|
||||||
|
size='sm'
|
||||||
|
align='end'
|
||||||
|
className='h-[32px] w-[120px] rounded-[6px]'
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Folder Filter */}
|
||||||
|
<Combobox
|
||||||
|
options={folderOptions}
|
||||||
|
multiSelect
|
||||||
|
multiSelectValues={folderIds}
|
||||||
|
onMultiSelectChange={setFolderIds}
|
||||||
|
placeholder='Folder'
|
||||||
|
overlayContent={
|
||||||
|
<span className='truncate text-[var(--text-primary)]'>{folderDisplayLabel}</span>
|
||||||
|
}
|
||||||
|
searchable
|
||||||
|
searchPlaceholder='Search folders...'
|
||||||
|
showAllOption
|
||||||
|
allOptionLabel='All folders'
|
||||||
|
size='sm'
|
||||||
|
align='end'
|
||||||
|
className='h-[32px] w-[120px] rounded-[6px]'
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Trigger Filter */}
|
||||||
|
<Combobox
|
||||||
|
options={triggerOptions}
|
||||||
|
multiSelect
|
||||||
|
multiSelectValues={triggers}
|
||||||
|
onMultiSelectChange={setTriggers}
|
||||||
|
placeholder='Trigger'
|
||||||
|
overlayContent={
|
||||||
|
<span className='truncate text-[var(--text-primary)]'>{triggerDisplayLabel}</span>
|
||||||
|
}
|
||||||
|
searchable
|
||||||
|
searchPlaceholder='Search triggers...'
|
||||||
|
showAllOption
|
||||||
|
allOptionLabel='All triggers'
|
||||||
|
size='sm'
|
||||||
|
align='end'
|
||||||
|
className='h-[32px] w-[120px] rounded-[6px]'
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Timeline Filter */}
|
||||||
|
<Combobox
|
||||||
|
options={TIME_RANGE_OPTIONS as unknown as ComboboxOption[]}
|
||||||
|
value={timeRange}
|
||||||
|
onChange={(val) => setTimeRange(val as typeof timeRange)}
|
||||||
|
placeholder='Time'
|
||||||
|
overlayContent={
|
||||||
|
<span className='truncate text-[var(--text-primary)]'>{timeDisplayLabel}</span>
|
||||||
|
}
|
||||||
|
size='sm'
|
||||||
|
align='end'
|
||||||
|
className='h-[32px] w-[120px] rounded-[6px]'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export function useSearchState({
|
|||||||
const [sections, setSections] = useState<SuggestionSection[]>([])
|
const [sections, setSections] = useState<SuggestionSection[]>([])
|
||||||
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
||||||
|
|
||||||
|
const [highlightedBadgeIndex, setHighlightedBadgeIndex] = useState<number | null>(null)
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
const debounceRef = useRef<NodeJS.Timeout | null>(null)
|
const debounceRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
@@ -52,6 +54,7 @@ export function useSearchState({
|
|||||||
const handleInputChange = useCallback(
|
const handleInputChange = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
setCurrentInput(value)
|
setCurrentInput(value)
|
||||||
|
setHighlightedBadgeIndex(null)
|
||||||
|
|
||||||
if (debounceRef.current) {
|
if (debounceRef.current) {
|
||||||
clearTimeout(debounceRef.current)
|
clearTimeout(debounceRef.current)
|
||||||
@@ -125,13 +128,24 @@ export function useSearchState({
|
|||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(event: React.KeyboardEvent) => {
|
(event: React.KeyboardEvent) => {
|
||||||
if (event.key === 'Backspace' && currentInput === '') {
|
if (event.key === 'Backspace' && currentInput === '') {
|
||||||
if (appliedFilters.length > 0) {
|
event.preventDefault()
|
||||||
event.preventDefault()
|
|
||||||
removeBadge(appliedFilters.length - 1)
|
if (highlightedBadgeIndex !== null) {
|
||||||
|
removeBadge(highlightedBadgeIndex)
|
||||||
|
setHighlightedBadgeIndex(null)
|
||||||
|
} else if (appliedFilters.length > 0) {
|
||||||
|
setHighlightedBadgeIndex(appliedFilters.length - 1)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
highlightedBadgeIndex !== null &&
|
||||||
|
!['ArrowDown', 'ArrowUp', 'Enter'].includes(event.key)
|
||||||
|
) {
|
||||||
|
setHighlightedBadgeIndex(null)
|
||||||
|
}
|
||||||
|
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
@@ -180,6 +194,7 @@ export function useSearchState({
|
|||||||
[
|
[
|
||||||
currentInput,
|
currentInput,
|
||||||
appliedFilters,
|
appliedFilters,
|
||||||
|
highlightedBadgeIndex,
|
||||||
isOpen,
|
isOpen,
|
||||||
highlightedIndex,
|
highlightedIndex,
|
||||||
suggestions,
|
suggestions,
|
||||||
@@ -226,6 +241,7 @@ export function useSearchState({
|
|||||||
suggestions,
|
suggestions,
|
||||||
sections,
|
sections,
|
||||||
highlightedIndex,
|
highlightedIndex,
|
||||||
|
highlightedBadgeIndex,
|
||||||
|
|
||||||
inputRef,
|
inputRef,
|
||||||
dropdownRef,
|
dropdownRef,
|
||||||
@@ -238,7 +254,7 @@ export function useSearchState({
|
|||||||
removeBadge,
|
removeBadge,
|
||||||
clearAll,
|
clearAll,
|
||||||
initializeFromQuery,
|
initializeFromQuery,
|
||||||
|
|
||||||
setHighlightedIndex,
|
setHighlightedIndex,
|
||||||
|
setHighlightedBadgeIndex,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
/**
|
|
||||||
* Logs layout - applies sidebar padding for all logs routes.
|
|
||||||
*/
|
|
||||||
export default function LogsLayout({ children }: { children: React.ReactNode }) {
|
export default function LogsLayout({ children }: { children: React.ReactNode }) {
|
||||||
return <div className='flex h-full flex-1 flex-col overflow-hidden pl-60'>{children}</div>
|
return <div className='flex h-full flex-1 flex-col overflow-hidden pl-60'>{children}</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { AlertCircle, ArrowUpRight, Loader2 } from 'lucide-react'
|
import { AlertCircle, Loader2 } from 'lucide-react'
|
||||||
import Link from 'next/link'
|
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import { Badge, buttonVariants } from '@/components/emcn'
|
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
|
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
|
||||||
import { useFolders } from '@/hooks/queries/folders'
|
import { useFolders } from '@/hooks/queries/folders'
|
||||||
import { useLogDetail, useLogsList } from '@/hooks/queries/logs'
|
import { useDashboardLogs, useLogDetail, useLogsList } from '@/hooks/queries/logs'
|
||||||
import { useDebounce } from '@/hooks/use-debounce'
|
import { useDebounce } from '@/hooks/use-debounce'
|
||||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
import { useFilterStore } from '@/stores/logs/filters/store'
|
||||||
import type { WorkflowLog } from '@/stores/logs/filters/types'
|
import type { WorkflowLog } from '@/stores/logs/filters/types'
|
||||||
import { useUserPermissionsContext } from '../providers/workspace-permissions-provider'
|
import { useUserPermissionsContext } from '../providers/workspace-permissions-provider'
|
||||||
import { Dashboard, LogDetails, LogsToolbar, NotificationSettings } from './components'
|
import { Dashboard, LogDetails, LogsList, LogsToolbar, NotificationSettings } from './components'
|
||||||
import { formatDate, formatDuration, StatusBadge, TriggerBadge } from './utils'
|
|
||||||
|
|
||||||
const LOGS_PER_PAGE = 50 as const
|
const LOGS_PER_PAGE = 50 as const
|
||||||
const REFRESH_SPINNER_DURATION_MS = 1000 as const
|
const REFRESH_SPINNER_DURATION_MS = 1000 as const
|
||||||
@@ -56,20 +53,17 @@ export default function Logs() {
|
|||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const debouncedSearchQuery = useDebounce(searchQuery, 300)
|
const debouncedSearchQuery = useDebounce(searchQuery, 300)
|
||||||
|
|
||||||
// Sync search query from URL on mount (client-side only)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const urlSearch = new URLSearchParams(window.location.search).get('search') || ''
|
const urlSearch = new URLSearchParams(window.location.search).get('search') || ''
|
||||||
if (urlSearch && urlSearch !== searchQuery) {
|
if (urlSearch && urlSearch !== searchQuery) {
|
||||||
setSearchQuery(urlSearch)
|
setSearchQuery(urlSearch)
|
||||||
}
|
}
|
||||||
// Only run on mount
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const [isLive, setIsLive] = useState(false)
|
const [isLive, setIsLive] = useState(false)
|
||||||
const [isVisuallyRefreshing, setIsVisuallyRefreshing] = useState(false)
|
const [isVisuallyRefreshing, setIsVisuallyRefreshing] = useState(false)
|
||||||
const [isExporting, setIsExporting] = useState(false)
|
const [isExporting, setIsExporting] = useState(false)
|
||||||
const [dashboardRefreshTrigger, setDashboardRefreshTrigger] = useState(0)
|
|
||||||
const isSearchOpenRef = useRef<boolean>(false)
|
const isSearchOpenRef = useRef<boolean>(false)
|
||||||
const [isNotificationSettingsOpen, setIsNotificationSettingsOpen] = useState(false)
|
const [isNotificationSettingsOpen, setIsNotificationSettingsOpen] = useState(false)
|
||||||
const userPermissions = useUserPermissionsContext()
|
const userPermissions = useUserPermissionsContext()
|
||||||
@@ -92,8 +86,31 @@ export default function Logs() {
|
|||||||
refetchInterval: isLive ? 5000 : false,
|
refetchInterval: isLive ? 5000 : false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const dashboardFilters = useMemo(
|
||||||
|
() => ({
|
||||||
|
timeRange,
|
||||||
|
level,
|
||||||
|
workflowIds,
|
||||||
|
folderIds,
|
||||||
|
triggers,
|
||||||
|
searchQuery: debouncedSearchQuery,
|
||||||
|
}),
|
||||||
|
[timeRange, level, workflowIds, folderIds, triggers, debouncedSearchQuery]
|
||||||
|
)
|
||||||
|
|
||||||
|
const dashboardLogsQuery = useDashboardLogs(workspaceId, dashboardFilters, {
|
||||||
|
enabled: Boolean(workspaceId) && isInitialized.current,
|
||||||
|
refetchInterval: isLive ? 5000 : false,
|
||||||
|
})
|
||||||
|
|
||||||
const logDetailQuery = useLogDetail(selectedLog?.id)
|
const logDetailQuery = useLogDetail(selectedLog?.id)
|
||||||
|
|
||||||
|
const mergedSelectedLog = useMemo(() => {
|
||||||
|
if (!selectedLog) return null
|
||||||
|
if (!logDetailQuery.data) return selectedLog
|
||||||
|
return { ...selectedLog, ...logDetailQuery.data }
|
||||||
|
}, [selectedLog, logDetailQuery.data])
|
||||||
|
|
||||||
const logs = useMemo(() => {
|
const logs = useMemo(() => {
|
||||||
if (!logsQuery.data?.pages) return []
|
if (!logsQuery.data?.pages) return []
|
||||||
return logsQuery.data.pages.flatMap((page) => page.logs)
|
return logsQuery.data.pages.flatMap((page) => page.logs)
|
||||||
@@ -107,10 +124,8 @@ export default function Logs() {
|
|||||||
}
|
}
|
||||||
}, [debouncedSearchQuery, setStoreSearchQuery])
|
}, [debouncedSearchQuery, setStoreSearchQuery])
|
||||||
|
|
||||||
// Track previous log state for efficient change detection
|
|
||||||
const prevSelectedLogRef = useRef<WorkflowLog | null>(null)
|
const prevSelectedLogRef = useRef<WorkflowLog | null>(null)
|
||||||
|
|
||||||
// Sync selected log with refreshed data from logs list
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedLog?.id || logs.length === 0) return
|
if (!selectedLog?.id || logs.length === 0) return
|
||||||
|
|
||||||
@@ -119,32 +134,27 @@ export default function Logs() {
|
|||||||
|
|
||||||
const prevLog = prevSelectedLogRef.current
|
const prevLog = prevSelectedLogRef.current
|
||||||
|
|
||||||
// Check if status-related fields have changed (e.g., running -> done)
|
|
||||||
const hasStatusChange =
|
const hasStatusChange =
|
||||||
prevLog?.id === updatedLog.id &&
|
prevLog?.id === updatedLog.id &&
|
||||||
(updatedLog.duration !== prevLog.duration ||
|
(updatedLog.duration !== prevLog.duration ||
|
||||||
updatedLog.level !== prevLog.level ||
|
updatedLog.level !== prevLog.level ||
|
||||||
updatedLog.hasPendingPause !== prevLog.hasPendingPause)
|
updatedLog.hasPendingPause !== prevLog.hasPendingPause)
|
||||||
|
|
||||||
// Only update if the log data actually changed
|
|
||||||
if (updatedLog !== selectedLog) {
|
if (updatedLog !== selectedLog) {
|
||||||
setSelectedLog(updatedLog)
|
setSelectedLog(updatedLog)
|
||||||
prevSelectedLogRef.current = updatedLog
|
prevSelectedLogRef.current = updatedLog
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update index in case position changed
|
|
||||||
const newIndex = logs.findIndex((l) => l.id === selectedLog.id)
|
const newIndex = logs.findIndex((l) => l.id === selectedLog.id)
|
||||||
if (newIndex !== selectedLogIndex) {
|
if (newIndex !== selectedLogIndex) {
|
||||||
setSelectedLogIndex(newIndex)
|
setSelectedLogIndex(newIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refetch log details if status changed to keep details panel in sync
|
|
||||||
if (hasStatusChange) {
|
if (hasStatusChange) {
|
||||||
logDetailQuery.refetch()
|
logDetailQuery.refetch()
|
||||||
}
|
}
|
||||||
}, [logs, selectedLog?.id, selectedLogIndex, logDetailQuery.refetch])
|
}, [logs, selectedLog?.id, selectedLogIndex, logDetailQuery])
|
||||||
|
|
||||||
// Refetch log details during live mode
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLive || !selectedLog?.id) return
|
if (!isLive || !selectedLog?.id) return
|
||||||
|
|
||||||
@@ -155,20 +165,24 @@ export default function Logs() {
|
|||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [isLive, selectedLog?.id, logDetailQuery])
|
}, [isLive, selectedLog?.id, logDetailQuery])
|
||||||
|
|
||||||
const handleLogClick = (log: WorkflowLog) => {
|
const handleLogClick = useCallback(
|
||||||
// If clicking on the same log that's already selected and sidebar is open, close it
|
(log: WorkflowLog) => {
|
||||||
if (selectedLog?.id === log.id && isSidebarOpen) {
|
if (selectedLog?.id === log.id && isSidebarOpen) {
|
||||||
handleCloseSidebar()
|
setIsSidebarOpen(false)
|
||||||
return
|
setSelectedLog(null)
|
||||||
}
|
setSelectedLogIndex(-1)
|
||||||
|
prevSelectedLogRef.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Otherwise, select the log and open the sidebar
|
setSelectedLog(log)
|
||||||
setSelectedLog(log)
|
prevSelectedLogRef.current = log
|
||||||
prevSelectedLogRef.current = log
|
const index = logs.findIndex((l) => l.id === log.id)
|
||||||
const index = logs.findIndex((l) => l.id === log.id)
|
setSelectedLogIndex(index)
|
||||||
setSelectedLogIndex(index)
|
setIsSidebarOpen(true)
|
||||||
setIsSidebarOpen(true)
|
},
|
||||||
}
|
[selectedLog?.id, isSidebarOpen, logs]
|
||||||
|
)
|
||||||
|
|
||||||
const handleNavigateNext = useCallback(() => {
|
const handleNavigateNext = useCallback(() => {
|
||||||
if (selectedLogIndex < logs.length - 1) {
|
if (selectedLogIndex < logs.length - 1) {
|
||||||
@@ -190,12 +204,12 @@ export default function Logs() {
|
|||||||
}
|
}
|
||||||
}, [selectedLogIndex, logs])
|
}, [selectedLogIndex, logs])
|
||||||
|
|
||||||
const handleCloseSidebar = () => {
|
const handleCloseSidebar = useCallback(() => {
|
||||||
setIsSidebarOpen(false)
|
setIsSidebarOpen(false)
|
||||||
setSelectedLog(null)
|
setSelectedLog(null)
|
||||||
setSelectedLogIndex(-1)
|
setSelectedLogIndex(-1)
|
||||||
prevSelectedLogRef.current = null
|
prevSelectedLogRef.current = null
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedRowRef.current) {
|
if (selectedRowRef.current) {
|
||||||
@@ -213,8 +227,6 @@ export default function Logs() {
|
|||||||
if (selectedLog?.id) {
|
if (selectedLog?.id) {
|
||||||
logDetailQuery.refetch()
|
logDetailQuery.refetch()
|
||||||
}
|
}
|
||||||
// Also trigger dashboard refresh
|
|
||||||
setDashboardRefreshTrigger((prev) => prev + 1)
|
|
||||||
}, [logsQuery, logDetailQuery, selectedLog?.id])
|
}, [logsQuery, logDetailQuery, selectedLog?.id])
|
||||||
|
|
||||||
const handleToggleLive = useCallback(() => {
|
const handleToggleLive = useCallback(() => {
|
||||||
@@ -225,8 +237,6 @@ export default function Logs() {
|
|||||||
setIsVisuallyRefreshing(true)
|
setIsVisuallyRefreshing(true)
|
||||||
setTimeout(() => setIsVisuallyRefreshing(false), REFRESH_SPINNER_DURATION_MS)
|
setTimeout(() => setIsVisuallyRefreshing(false), REFRESH_SPINNER_DURATION_MS)
|
||||||
logsQuery.refetch()
|
logsQuery.refetch()
|
||||||
// Also trigger dashboard refresh
|
|
||||||
setDashboardRefreshTrigger((prev) => prev + 1)
|
|
||||||
}
|
}
|
||||||
}, [isLive, logsQuery])
|
}, [isLive, logsQuery])
|
||||||
|
|
||||||
@@ -292,62 +302,6 @@ export default function Logs() {
|
|||||||
}
|
}
|
||||||
}, [logsQuery])
|
}, [logsQuery])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (logsQuery.isLoading || !logsQuery.hasNextPage) return
|
|
||||||
|
|
||||||
const scrollContainer = scrollContainerRef.current
|
|
||||||
if (!scrollContainer) return
|
|
||||||
|
|
||||||
const handleScroll = () => {
|
|
||||||
if (!scrollContainer) return
|
|
||||||
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainer
|
|
||||||
|
|
||||||
const scrollPercentage = (scrollTop / (scrollHeight - clientHeight)) * 100
|
|
||||||
|
|
||||||
if (scrollPercentage > 60 && !logsQuery.isFetchingNextPage && logsQuery.hasNextPage) {
|
|
||||||
loadMoreLogs()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollContainer.addEventListener('scroll', handleScroll)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
scrollContainer.removeEventListener('scroll', handleScroll)
|
|
||||||
}
|
|
||||||
}, [logsQuery.isLoading, logsQuery.hasNextPage, logsQuery.isFetchingNextPage, loadMoreLogs])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const currentLoaderRef = loaderRef.current
|
|
||||||
const scrollContainer = scrollContainerRef.current
|
|
||||||
|
|
||||||
if (!currentLoaderRef || !scrollContainer || logsQuery.isLoading || !logsQuery.hasNextPage)
|
|
||||||
return
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
const e = entries[0]
|
|
||||||
if (!e?.isIntersecting) return
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainer
|
|
||||||
const pct = (scrollTop / (scrollHeight - clientHeight)) * 100
|
|
||||||
if (pct > 70 && !logsQuery.isFetchingNextPage) {
|
|
||||||
loadMoreLogs()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
root: scrollContainer,
|
|
||||||
threshold: 0.1,
|
|
||||||
rootMargin: '200px 0px 0px 0px',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
observer.observe(currentLoaderRef)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
observer.unobserve(currentLoaderRef)
|
|
||||||
}
|
|
||||||
}, [logsQuery.isLoading, logsQuery.hasNextPage, logsQuery.isFetchingNextPage, loadMoreLogs])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (isSearchOpenRef.current) return
|
if (isSearchOpenRef.current) return
|
||||||
@@ -408,11 +362,15 @@ export default function Logs() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dashboard view - always mounted to preserve state and query cache */}
|
{/* Dashboard view - uses all logs (non-paginated) for accurate metrics */}
|
||||||
<div
|
<div
|
||||||
className={cn('flex min-h-0 flex-1 flex-col pr-[24px]', !isDashboardView && 'hidden')}
|
className={cn('flex min-h-0 flex-1 flex-col pr-[24px]', !isDashboardView && 'hidden')}
|
||||||
>
|
>
|
||||||
<Dashboard isLive={isLive} refreshTrigger={dashboardRefreshTrigger} />
|
<Dashboard
|
||||||
|
logs={dashboardLogsQuery.data ?? []}
|
||||||
|
isLoading={!dashboardLogsQuery.data}
|
||||||
|
error={dashboardLogsQuery.error}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main content area with table - only show in logs view */}
|
{/* Main content area with table - only show in logs view */}
|
||||||
@@ -451,11 +409,8 @@ export default function Logs() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table body - scrollable */}
|
{/* Table body - virtualized */}
|
||||||
<div
|
<div className='min-h-0 flex-1 overflow-hidden' ref={scrollContainerRef}>
|
||||||
className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden'
|
|
||||||
ref={scrollContainerRef}
|
|
||||||
>
|
|
||||||
{logsQuery.isLoading && !logsQuery.data ? (
|
{logsQuery.isLoading && !logsQuery.data ? (
|
||||||
<div className='flex h-full items-center justify-center'>
|
<div className='flex h-full items-center justify-center'>
|
||||||
<div className='flex items-center gap-[8px] text-[var(--text-secondary)]'>
|
<div className='flex items-center gap-[8px] text-[var(--text-secondary)]'>
|
||||||
@@ -479,137 +434,23 @@ export default function Logs() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<LogsList
|
||||||
{logs.map((log) => {
|
logs={logs}
|
||||||
const formattedDate = formatDate(log.createdAt)
|
selectedLogId={selectedLog?.id ?? null}
|
||||||
const isSelected = selectedLog?.id === log.id
|
onLogClick={handleLogClick}
|
||||||
const baseLevel = (log.level || 'info').toLowerCase()
|
selectedRowRef={selectedRowRef}
|
||||||
const isError = baseLevel === 'error'
|
hasNextPage={logsQuery.hasNextPage ?? false}
|
||||||
const isPending = !isError && log.hasPendingPause === true
|
isFetchingNextPage={logsQuery.isFetchingNextPage}
|
||||||
const isRunning = !isError && !isPending && log.duration === null
|
onLoadMore={loadMoreLogs}
|
||||||
|
loaderRef={loaderRef}
|
||||||
return (
|
/>
|
||||||
<div
|
|
||||||
key={log.id}
|
|
||||||
ref={isSelected ? selectedRowRef : null}
|
|
||||||
className={cn(
|
|
||||||
'relative flex h-[44px] cursor-pointer items-center px-[24px] hover:bg-[var(--c-2A2A2A)]',
|
|
||||||
isSelected && 'bg-[var(--c-2A2A2A)]'
|
|
||||||
)}
|
|
||||||
onClick={() => handleLogClick(log)}
|
|
||||||
>
|
|
||||||
<div className='flex flex-1 items-center'>
|
|
||||||
{/* Date */}
|
|
||||||
<span className='w-[8%] min-w-[70px] font-medium text-[12px] text-[var(--text-primary)]'>
|
|
||||||
{formattedDate.compactDate}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Time */}
|
|
||||||
<span className='w-[12%] min-w-[90px] font-medium text-[12px] text-[var(--text-primary)]'>
|
|
||||||
{formattedDate.compactTime}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Status */}
|
|
||||||
<div className='w-[12%] min-w-[100px]'>
|
|
||||||
<StatusBadge
|
|
||||||
status={
|
|
||||||
isError
|
|
||||||
? 'error'
|
|
||||||
: isPending
|
|
||||||
? 'pending'
|
|
||||||
: isRunning
|
|
||||||
? 'running'
|
|
||||||
: 'info'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Workflow */}
|
|
||||||
<div className='flex w-[22%] min-w-[140px] items-center gap-[8px] pr-[8px]'>
|
|
||||||
<div
|
|
||||||
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
|
|
||||||
style={{ backgroundColor: log.workflow?.color }}
|
|
||||||
/>
|
|
||||||
<span className='min-w-0 truncate font-medium text-[12px] text-[var(--text-primary)]'>
|
|
||||||
{log.workflow?.name || 'Unknown'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Cost */}
|
|
||||||
<span className='w-[12%] min-w-[90px] font-medium text-[12px] text-[var(--text-primary)]'>
|
|
||||||
{typeof log.cost?.total === 'number'
|
|
||||||
? `$${log.cost.total.toFixed(4)}`
|
|
||||||
: '—'}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Trigger */}
|
|
||||||
<div className='w-[14%] min-w-[110px]'>
|
|
||||||
{log.trigger ? (
|
|
||||||
<TriggerBadge trigger={log.trigger} />
|
|
||||||
) : (
|
|
||||||
<span className='font-medium text-[12px] text-[var(--text-primary)]'>
|
|
||||||
—
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Duration */}
|
|
||||||
<div className='w-[20%] min-w-[100px]'>
|
|
||||||
<Badge
|
|
||||||
variant='default'
|
|
||||||
className='rounded-[6px] px-[9px] py-[2px] text-[12px]'
|
|
||||||
>
|
|
||||||
{formatDuration(log.duration) || '—'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Resume Link */}
|
|
||||||
{isPending && log.executionId && (log.workflow?.id || log.workflowId) && (
|
|
||||||
<Link
|
|
||||||
href={`/resume/${log.workflow?.id || log.workflowId}/${log.executionId}`}
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className={cn(
|
|
||||||
buttonVariants({ variant: 'active' }),
|
|
||||||
'absolute right-[24px] h-[26px] w-[26px] rounded-[6px] p-0'
|
|
||||||
)}
|
|
||||||
aria-label='Open resume console'
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<ArrowUpRight className='h-[14px] w-[14px]' />
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Infinite scroll loader */}
|
|
||||||
{logsQuery.hasNextPage && (
|
|
||||||
<div className='flex items-center justify-center py-[16px]'>
|
|
||||||
<div
|
|
||||||
ref={loaderRef}
|
|
||||||
className='flex items-center gap-[8px] text-[var(--text-secondary)]'
|
|
||||||
>
|
|
||||||
{logsQuery.isFetchingNextPage ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className='h-[16px] w-[16px] animate-spin' />
|
|
||||||
<span className='text-[13px]'>Loading more...</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className='text-[13px]'>Scroll to load more</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Log Details - rendered inside table container */}
|
{/* Log Details - rendered inside table container */}
|
||||||
<LogDetails
|
<LogDetails
|
||||||
log={logDetailQuery.data ? { ...selectedLog, ...logDetailQuery.data } : selectedLog}
|
log={mergedSelectedLog}
|
||||||
isOpen={isSidebarOpen}
|
isOpen={isSidebarOpen}
|
||||||
onClose={handleCloseSidebar}
|
onClose={handleCloseSidebar}
|
||||||
onNavigateNext={handleNavigateNext}
|
onNavigateNext={handleNavigateNext}
|
||||||
|
|||||||
@@ -448,7 +448,7 @@ export const formatDate = (dateString: string) => {
|
|||||||
formatted: format(date, 'HH:mm:ss'),
|
formatted: format(date, 'HH:mm:ss'),
|
||||||
compact: format(date, 'MMM d HH:mm:ss'),
|
compact: format(date, 'MMM d HH:mm:ss'),
|
||||||
compactDate: format(date, 'MMM d').toUpperCase(),
|
compactDate: format(date, 'MMM d').toUpperCase(),
|
||||||
compactTime: format(date, 'h:mm:ss a'),
|
compactTime: format(date, 'h:mm a'),
|
||||||
relative: (() => {
|
relative: (() => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const diffMs = now.getTime() - date.getTime()
|
const diffMs = now.getTime() - date.getTime()
|
||||||
|
|||||||
@@ -2265,7 +2265,14 @@ const WorkflowContent = React.memo(() => {
|
|||||||
className={`absolute inset-0 z-[5] flex items-center justify-center bg-[var(--bg)] transition-opacity duration-150 ${isWorkflowReady ? 'pointer-events-none opacity-0' : 'opacity-100'}`}
|
className={`absolute inset-0 z-[5] flex items-center justify-center bg-[var(--bg)] transition-opacity duration-150 ${isWorkflowReady ? 'pointer-events-none opacity-0' : 'opacity-100'}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`h-[18px] w-[18px] rounded-full border-[1.5px] border-muted-foreground border-t-transparent ${isWorkflowReady ? '' : 'animate-spin'}`}
|
className={`h-[18px] w-[18px] rounded-full ${isWorkflowReady ? '' : 'animate-spin'}`}
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'conic-gradient(from 0deg, hsl(var(--muted-foreground)) 0deg 120deg, transparent 120deg 180deg, hsl(var(--muted-foreground)) 180deg 300deg, transparent 300deg 360deg)',
|
||||||
|
mask: 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
|
||||||
|
WebkitMask:
|
||||||
|
'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,16 @@ export default function WorkflowsPage() {
|
|||||||
<div className='flex h-full w-full flex-col overflow-hidden bg-[var(--bg)]'>
|
<div className='flex h-full w-full flex-col overflow-hidden bg-[var(--bg)]'>
|
||||||
<div className='relative h-full w-full flex-1 bg-[var(--bg)]'>
|
<div className='relative h-full w-full flex-1 bg-[var(--bg)]'>
|
||||||
<div className='workflow-container flex h-full items-center justify-center bg-[var(--bg)]'>
|
<div className='workflow-container flex h-full items-center justify-center bg-[var(--bg)]'>
|
||||||
<div className='h-[18px] w-[18px] animate-spin rounded-full border-[1.5px] border-muted-foreground border-t-transparent' />
|
<div
|
||||||
|
className='h-[18px] w-[18px] animate-spin rounded-full'
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'conic-gradient(from 0deg, hsl(var(--muted-foreground)) 0deg 120deg, transparent 120deg 180deg, hsl(var(--muted-foreground)) 180deg 300deg, transparent 300deg 360deg)',
|
||||||
|
mask: 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
|
||||||
|
WebkitMask:
|
||||||
|
'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Panel />
|
<Panel />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -117,7 +117,16 @@ export default function WorkspacePage() {
|
|||||||
if (isPending) {
|
if (isPending) {
|
||||||
return (
|
return (
|
||||||
<div className='flex h-screen w-full items-center justify-center'>
|
<div className='flex h-screen w-full items-center justify-center'>
|
||||||
<div className='h-[18px] w-[18px] animate-spin rounded-full border-[1.5px] border-muted-foreground border-t-transparent' />
|
<div
|
||||||
|
className='h-[18px] w-[18px] animate-spin rounded-full'
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'conic-gradient(from 0deg, hsl(var(--muted-foreground)) 0deg 120deg, transparent 120deg 180deg, hsl(var(--muted-foreground)) 180deg 300deg, transparent 300deg 360deg)',
|
||||||
|
mask: 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
|
||||||
|
WebkitMask:
|
||||||
|
'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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 { Key } from './key'
|
||||||
export { Layout } from './layout'
|
export { Layout } from './layout'
|
||||||
export { Library } from './library'
|
export { Library } from './library'
|
||||||
|
export { Loader } from './loader'
|
||||||
export { MoreHorizontal } from './more-horizontal'
|
export { MoreHorizontal } from './more-horizontal'
|
||||||
export { NoWrap } from './no-wrap'
|
export { NoWrap } from './no-wrap'
|
||||||
export { PanelLeft } from './panel-left'
|
export { PanelLeft } from './panel-left'
|
||||||
|
|||||||
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,
|
[...logKeys.lists(), workspaceId ?? '', filters] as const,
|
||||||
details: () => [...logKeys.all, 'detail'] as const,
|
details: () => [...logKeys.all, 'detail'] as const,
|
||||||
detail: (logId: string | undefined) => [...logKeys.details(), logId ?? ''] as const,
|
detail: (logId: string | undefined) => [...logKeys.details(), logId ?? ''] as const,
|
||||||
metrics: () => [...logKeys.all, 'metrics'] as const,
|
dashboard: (workspaceId: string | undefined, filters: Record<string, unknown>) =>
|
||||||
executions: (workspaceId: string | undefined, filters: Record<string, any>) =>
|
[...logKeys.all, 'dashboard', workspaceId ?? '', filters] as const,
|
||||||
[...logKeys.metrics(), 'executions', workspaceId ?? '', filters] as const,
|
|
||||||
workflowLogs: (
|
|
||||||
workspaceId: string | undefined,
|
|
||||||
workflowId: string | undefined,
|
|
||||||
filters: Record<string, any>
|
|
||||||
) => [...logKeys.all, 'workflow-logs', workspaceId ?? '', workflowId ?? '', filters] as const,
|
|
||||||
globalLogs: (workspaceId: string | undefined, filters: Record<string, any>) =>
|
|
||||||
[...logKeys.all, 'global-logs', workspaceId ?? '', filters] as const,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LogFilters {
|
interface LogFilters {
|
||||||
@@ -31,6 +23,87 @@ interface LogFilters {
|
|||||||
limit: number
|
limit: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates start date from a time range string.
|
||||||
|
* Returns null for 'All time' to indicate no date filtering.
|
||||||
|
*/
|
||||||
|
function getStartDateFromTimeRange(timeRange: string): Date | null {
|
||||||
|
if (timeRange === 'All time') return null
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
switch (timeRange) {
|
||||||
|
case 'Past 30 minutes':
|
||||||
|
return new Date(now.getTime() - 30 * 60 * 1000)
|
||||||
|
case 'Past hour':
|
||||||
|
return new Date(now.getTime() - 60 * 60 * 1000)
|
||||||
|
case 'Past 6 hours':
|
||||||
|
return new Date(now.getTime() - 6 * 60 * 60 * 1000)
|
||||||
|
case 'Past 12 hours':
|
||||||
|
return new Date(now.getTime() - 12 * 60 * 60 * 1000)
|
||||||
|
case 'Past 24 hours':
|
||||||
|
return new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||||
|
case 'Past 3 days':
|
||||||
|
return new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000)
|
||||||
|
case 'Past 7 days':
|
||||||
|
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||||
|
case 'Past 14 days':
|
||||||
|
return new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000)
|
||||||
|
case 'Past 30 days':
|
||||||
|
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
|
||||||
|
default:
|
||||||
|
return new Date(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies common filter parameters to a URLSearchParams object.
|
||||||
|
* Shared between paginated and non-paginated log fetches.
|
||||||
|
*/
|
||||||
|
function applyFilterParams(params: URLSearchParams, filters: Omit<LogFilters, 'limit'>): void {
|
||||||
|
if (filters.level !== 'all') {
|
||||||
|
params.set('level', filters.level)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.triggers.length > 0) {
|
||||||
|
params.set('triggers', filters.triggers.join(','))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.workflowIds.length > 0) {
|
||||||
|
params.set('workflowIds', filters.workflowIds.join(','))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.folderIds.length > 0) {
|
||||||
|
params.set('folderIds', filters.folderIds.join(','))
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = getStartDateFromTimeRange(filters.timeRange)
|
||||||
|
if (startDate) {
|
||||||
|
params.set('startDate', startDate.toISOString())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.searchQuery.trim()) {
|
||||||
|
const parsedQuery = parseQuery(filters.searchQuery.trim())
|
||||||
|
const searchParams = queryToApiParams(parsedQuery)
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(searchParams)) {
|
||||||
|
params.set(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQueryParams(workspaceId: string, filters: LogFilters, page: number): string {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
|
||||||
|
params.set('workspaceId', workspaceId)
|
||||||
|
params.set('limit', filters.limit.toString())
|
||||||
|
params.set('offset', ((page - 1) * filters.limit).toString())
|
||||||
|
|
||||||
|
applyFilterParams(params, filters)
|
||||||
|
|
||||||
|
return params.toString()
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchLogsPage(
|
async function fetchLogsPage(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
filters: LogFilters,
|
filters: LogFilters,
|
||||||
@@ -64,80 +137,6 @@ async function fetchLogDetail(logId: string): Promise<WorkflowLog> {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildQueryParams(workspaceId: string, filters: LogFilters, page: number): string {
|
|
||||||
const params = new URLSearchParams()
|
|
||||||
|
|
||||||
params.set('workspaceId', workspaceId)
|
|
||||||
params.set('limit', filters.limit.toString())
|
|
||||||
params.set('offset', ((page - 1) * filters.limit).toString())
|
|
||||||
|
|
||||||
if (filters.level !== 'all') {
|
|
||||||
params.set('level', filters.level)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.triggers.length > 0) {
|
|
||||||
params.set('triggers', filters.triggers.join(','))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.workflowIds.length > 0) {
|
|
||||||
params.set('workflowIds', filters.workflowIds.join(','))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.folderIds.length > 0) {
|
|
||||||
params.set('folderIds', filters.folderIds.join(','))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.timeRange !== 'All time') {
|
|
||||||
const now = new Date()
|
|
||||||
let startDate: Date
|
|
||||||
|
|
||||||
switch (filters.timeRange) {
|
|
||||||
case 'Past 30 minutes':
|
|
||||||
startDate = new Date(now.getTime() - 30 * 60 * 1000)
|
|
||||||
break
|
|
||||||
case 'Past hour':
|
|
||||||
startDate = new Date(now.getTime() - 60 * 60 * 1000)
|
|
||||||
break
|
|
||||||
case 'Past 6 hours':
|
|
||||||
startDate = new Date(now.getTime() - 6 * 60 * 60 * 1000)
|
|
||||||
break
|
|
||||||
case 'Past 12 hours':
|
|
||||||
startDate = new Date(now.getTime() - 12 * 60 * 60 * 1000)
|
|
||||||
break
|
|
||||||
case 'Past 24 hours':
|
|
||||||
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
|
||||||
break
|
|
||||||
case 'Past 3 days':
|
|
||||||
startDate = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000)
|
|
||||||
break
|
|
||||||
case 'Past 7 days':
|
|
||||||
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
|
||||||
break
|
|
||||||
case 'Past 14 days':
|
|
||||||
startDate = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000)
|
|
||||||
break
|
|
||||||
case 'Past 30 days':
|
|
||||||
startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
startDate = new Date(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
params.set('startDate', startDate.toISOString())
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.searchQuery.trim()) {
|
|
||||||
const parsedQuery = parseQuery(filters.searchQuery.trim())
|
|
||||||
const searchParams = queryToApiParams(parsedQuery)
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(searchParams)) {
|
|
||||||
params.set(key, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return params.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseLogsListOptions {
|
interface UseLogsListOptions {
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
refetchInterval?: number | false
|
refetchInterval?: number | false
|
||||||
@@ -153,8 +152,8 @@ export function useLogsList(
|
|||||||
queryFn: ({ pageParam }) => fetchLogsPage(workspaceId as string, filters, pageParam),
|
queryFn: ({ pageParam }) => fetchLogsPage(workspaceId as string, filters, pageParam),
|
||||||
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
|
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
|
||||||
refetchInterval: options?.refetchInterval ?? false,
|
refetchInterval: options?.refetchInterval ?? false,
|
||||||
staleTime: 0, // Always consider stale for real-time logs
|
staleTime: 0,
|
||||||
placeholderData: keepPreviousData, // Keep showing old data while fetching new data
|
placeholderData: keepPreviousData,
|
||||||
initialPageParam: 1,
|
initialPageParam: 1,
|
||||||
getNextPageParam: (lastPage) => lastPage.nextPage,
|
getNextPageParam: (lastPage) => lastPage.nextPage,
|
||||||
})
|
})
|
||||||
@@ -165,303 +164,60 @@ export function useLogDetail(logId: string | undefined) {
|
|||||||
queryKey: logKeys.detail(logId),
|
queryKey: logKeys.detail(logId),
|
||||||
queryFn: () => fetchLogDetail(logId as string),
|
queryFn: () => fetchLogDetail(logId as string),
|
||||||
enabled: Boolean(logId),
|
enabled: Boolean(logId),
|
||||||
staleTime: 30 * 1000, // Details can be slightly stale (30 seconds)
|
staleTime: 30 * 1000,
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WorkflowSegment {
|
const DASHBOARD_LOGS_LIMIT = 10000
|
||||||
timestamp: string
|
|
||||||
hasExecutions: boolean
|
|
||||||
totalExecutions: number
|
|
||||||
successfulExecutions: number
|
|
||||||
successRate: number
|
|
||||||
avgDurationMs?: number
|
|
||||||
p50Ms?: number
|
|
||||||
p90Ms?: number
|
|
||||||
p99Ms?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WorkflowExecution {
|
/**
|
||||||
workflowId: string
|
* Fetches all logs for dashboard metrics (non-paginated).
|
||||||
workflowName: string
|
* Uses same filters as the logs list but with a high limit to get all data.
|
||||||
segments: WorkflowSegment[]
|
*/
|
||||||
overallSuccessRate: number
|
async function fetchAllLogs(
|
||||||
}
|
workspaceId: string,
|
||||||
|
filters: Omit<LogFilters, 'limit'>
|
||||||
|
): Promise<WorkflowLog[]> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
|
||||||
interface AggregateSegment {
|
params.set('workspaceId', workspaceId)
|
||||||
timestamp: string
|
params.set('limit', DASHBOARD_LOGS_LIMIT.toString())
|
||||||
totalExecutions: number
|
params.set('offset', '0')
|
||||||
successfulExecutions: number
|
|
||||||
avgDurationMs?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExecutionsMetricsResponse {
|
applyFilterParams(params, filters)
|
||||||
workflows: WorkflowExecution[]
|
|
||||||
aggregateSegments: AggregateSegment[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DashboardMetricsFilters {
|
|
||||||
workspaceId: string
|
|
||||||
segments: number
|
|
||||||
startTime: string
|
|
||||||
endTime: string
|
|
||||||
workflowIds?: string[]
|
|
||||||
folderIds?: string[]
|
|
||||||
triggers?: string[]
|
|
||||||
level?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchExecutionsMetrics(
|
|
||||||
filters: DashboardMetricsFilters
|
|
||||||
): Promise<ExecutionsMetricsResponse> {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
segments: String(filters.segments),
|
|
||||||
startTime: filters.startTime,
|
|
||||||
endTime: filters.endTime,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (filters.workflowIds && filters.workflowIds.length > 0) {
|
|
||||||
params.set('workflowIds', filters.workflowIds.join(','))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.folderIds && filters.folderIds.length > 0) {
|
|
||||||
params.set('folderIds', filters.folderIds.join(','))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.triggers && filters.triggers.length > 0) {
|
|
||||||
params.set('triggers', filters.triggers.join(','))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.level && filters.level !== 'all') {
|
|
||||||
params.set('level', filters.level)
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/workspaces/${filters.workspaceId}/metrics/executions?${params.toString()}`
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to fetch execution metrics')
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
const workflows: WorkflowExecution[] = (data.workflows || []).map((wf: any) => {
|
|
||||||
const segments = (wf.segments || []).map((s: any) => {
|
|
||||||
const total = s.totalExecutions || 0
|
|
||||||
const success = s.successfulExecutions || 0
|
|
||||||
const hasExecutions = total > 0
|
|
||||||
const successRate = hasExecutions ? (success / total) * 100 : 100
|
|
||||||
return {
|
|
||||||
timestamp: s.timestamp,
|
|
||||||
hasExecutions,
|
|
||||||
totalExecutions: total,
|
|
||||||
successfulExecutions: success,
|
|
||||||
successRate,
|
|
||||||
avgDurationMs: typeof s.avgDurationMs === 'number' ? s.avgDurationMs : 0,
|
|
||||||
p50Ms: typeof s.p50Ms === 'number' ? s.p50Ms : 0,
|
|
||||||
p90Ms: typeof s.p90Ms === 'number' ? s.p90Ms : 0,
|
|
||||||
p99Ms: typeof s.p99Ms === 'number' ? s.p99Ms : 0,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const totals = segments.reduce(
|
|
||||||
(acc: { total: number; success: number }, seg: WorkflowSegment) => {
|
|
||||||
acc.total += seg.totalExecutions
|
|
||||||
acc.success += seg.successfulExecutions
|
|
||||||
return acc
|
|
||||||
},
|
|
||||||
{ total: 0, success: 0 }
|
|
||||||
)
|
|
||||||
|
|
||||||
const overallSuccessRate = totals.total > 0 ? (totals.success / totals.total) * 100 : 100
|
|
||||||
|
|
||||||
return {
|
|
||||||
workflowId: wf.workflowId,
|
|
||||||
workflowName: wf.workflowName,
|
|
||||||
segments,
|
|
||||||
overallSuccessRate,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const sortedWorkflows = workflows.sort((a, b) => {
|
|
||||||
const errA = a.overallSuccessRate < 100 ? 1 - a.overallSuccessRate / 100 : 0
|
|
||||||
const errB = b.overallSuccessRate < 100 ? 1 - b.overallSuccessRate / 100 : 0
|
|
||||||
return errB - errA
|
|
||||||
})
|
|
||||||
|
|
||||||
const segmentCount = filters.segments
|
|
||||||
const startTime = new Date(filters.startTime)
|
|
||||||
const endTime = new Date(filters.endTime)
|
|
||||||
|
|
||||||
const aggregateSegments: AggregateSegment[] = Array.from({ length: segmentCount }, (_, i) => {
|
|
||||||
const base = startTime.getTime()
|
|
||||||
const ts = new Date(base + Math.floor((i * (endTime.getTime() - base)) / segmentCount))
|
|
||||||
return {
|
|
||||||
timestamp: ts.toISOString(),
|
|
||||||
totalExecutions: 0,
|
|
||||||
successfulExecutions: 0,
|
|
||||||
avgDurationMs: 0,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const weightedDurationSums: number[] = Array(segmentCount).fill(0)
|
|
||||||
const executionCounts: number[] = Array(segmentCount).fill(0)
|
|
||||||
|
|
||||||
for (const wf of data.workflows as any[]) {
|
|
||||||
wf.segments.forEach((s: any, i: number) => {
|
|
||||||
const index = Math.min(i, segmentCount - 1)
|
|
||||||
const execCount = s.totalExecutions || 0
|
|
||||||
|
|
||||||
aggregateSegments[index].totalExecutions += execCount
|
|
||||||
aggregateSegments[index].successfulExecutions += s.successfulExecutions || 0
|
|
||||||
|
|
||||||
if (typeof s.avgDurationMs === 'number' && s.avgDurationMs > 0 && execCount > 0) {
|
|
||||||
weightedDurationSums[index] += s.avgDurationMs * execCount
|
|
||||||
executionCounts[index] += execCount
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
aggregateSegments.forEach((seg, i) => {
|
|
||||||
if (executionCounts[i] > 0) {
|
|
||||||
seg.avgDurationMs = weightedDurationSums[i] / executionCounts[i]
|
|
||||||
} else {
|
|
||||||
seg.avgDurationMs = 0
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
workflows: sortedWorkflows,
|
|
||||||
aggregateSegments,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseExecutionsMetricsOptions {
|
|
||||||
enabled?: boolean
|
|
||||||
refetchInterval?: number | false
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useExecutionsMetrics(
|
|
||||||
filters: DashboardMetricsFilters,
|
|
||||||
options?: UseExecutionsMetricsOptions
|
|
||||||
) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: logKeys.executions(filters.workspaceId, filters),
|
|
||||||
queryFn: () => fetchExecutionsMetrics(filters),
|
|
||||||
enabled: Boolean(filters.workspaceId) && (options?.enabled ?? true),
|
|
||||||
refetchInterval: options?.refetchInterval ?? false,
|
|
||||||
staleTime: 10 * 1000, // Metrics can be slightly stale (10 seconds)
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DashboardLogsFilters {
|
|
||||||
workspaceId: string
|
|
||||||
startDate: string
|
|
||||||
endDate: string
|
|
||||||
workflowIds?: string[]
|
|
||||||
folderIds?: string[]
|
|
||||||
triggers?: string[]
|
|
||||||
level?: string
|
|
||||||
searchQuery?: string
|
|
||||||
limit: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DashboardLogsPage {
|
|
||||||
logs: any[] // Will be mapped by the consumer
|
|
||||||
hasMore: boolean
|
|
||||||
nextPage: number | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchDashboardLogsPage(
|
|
||||||
filters: DashboardLogsFilters,
|
|
||||||
page: number,
|
|
||||||
workflowId?: string
|
|
||||||
): Promise<DashboardLogsPage> {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
limit: filters.limit.toString(),
|
|
||||||
offset: ((page - 1) * filters.limit).toString(),
|
|
||||||
workspaceId: filters.workspaceId,
|
|
||||||
startDate: filters.startDate,
|
|
||||||
endDate: filters.endDate,
|
|
||||||
order: 'desc',
|
|
||||||
details: 'full',
|
|
||||||
})
|
|
||||||
|
|
||||||
if (workflowId) {
|
|
||||||
params.set('workflowIds', workflowId)
|
|
||||||
} else if (filters.workflowIds && filters.workflowIds.length > 0) {
|
|
||||||
params.set('workflowIds', filters.workflowIds.join(','))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.folderIds && filters.folderIds.length > 0) {
|
|
||||||
params.set('folderIds', filters.folderIds.join(','))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.triggers && filters.triggers.length > 0) {
|
|
||||||
params.set('triggers', filters.triggers.join(','))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.level && filters.level !== 'all') {
|
|
||||||
params.set('level', filters.level)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.searchQuery?.trim()) {
|
|
||||||
const parsed = parseQuery(filters.searchQuery)
|
|
||||||
const extraParams = queryToApiParams(parsed)
|
|
||||||
Object.entries(extraParams).forEach(([key, value]) => {
|
|
||||||
params.set(key, value)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`/api/logs?${params.toString()}`)
|
const response = await fetch(`/api/logs?${params.toString()}`)
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch dashboard logs')
|
throw new Error('Failed to fetch logs for dashboard')
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const apiData: LogsResponse = await response.json()
|
||||||
const logs = data.data || []
|
return apiData.data || []
|
||||||
const hasMore = logs.length === filters.limit
|
|
||||||
|
|
||||||
return {
|
|
||||||
logs,
|
|
||||||
hasMore,
|
|
||||||
nextPage: hasMore ? page + 1 : undefined,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseDashboardLogsOptions {
|
interface UseDashboardLogsOptions {
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
|
refetchInterval?: number | false
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGlobalDashboardLogs(
|
/**
|
||||||
filters: DashboardLogsFilters,
|
* Hook for fetching all logs for dashboard metrics.
|
||||||
|
* Unlike useLogsList, this fetches all logs in a single request
|
||||||
|
* to ensure dashboard metrics are computed from complete data.
|
||||||
|
*/
|
||||||
|
export function useDashboardLogs(
|
||||||
|
workspaceId: string | undefined,
|
||||||
|
filters: Omit<LogFilters, 'limit'>,
|
||||||
options?: UseDashboardLogsOptions
|
options?: UseDashboardLogsOptions
|
||||||
) {
|
) {
|
||||||
return useInfiniteQuery({
|
return useQuery({
|
||||||
queryKey: logKeys.globalLogs(filters.workspaceId, filters),
|
queryKey: logKeys.dashboard(workspaceId, filters),
|
||||||
queryFn: ({ pageParam }) => fetchDashboardLogsPage(filters, pageParam),
|
queryFn: () => fetchAllLogs(workspaceId as string, filters),
|
||||||
enabled: Boolean(filters.workspaceId) && (options?.enabled ?? true),
|
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
|
||||||
staleTime: 10 * 1000, // Slightly stale (10 seconds)
|
refetchInterval: options?.refetchInterval ?? false,
|
||||||
initialPageParam: 1,
|
staleTime: 0,
|
||||||
getNextPageParam: (lastPage) => lastPage.nextPage,
|
placeholderData: keepPreviousData,
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorkflowDashboardLogs(
|
|
||||||
workflowId: string | undefined,
|
|
||||||
filters: DashboardLogsFilters,
|
|
||||||
options?: UseDashboardLogsOptions
|
|
||||||
) {
|
|
||||||
return useInfiniteQuery({
|
|
||||||
queryKey: logKeys.workflowLogs(filters.workspaceId, workflowId, filters),
|
|
||||||
queryFn: ({ pageParam }) => fetchDashboardLogsPage(filters, pageParam, workflowId),
|
|
||||||
enabled: Boolean(filters.workspaceId) && Boolean(workflowId) && (options?.enabled ?? true),
|
|
||||||
staleTime: 10 * 1000, // Slightly stale (10 seconds)
|
|
||||||
initialPageParam: 1,
|
|
||||||
getNextPageParam: (lastPage) => lastPage.nextPage,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ const getSearchParams = () => {
|
|||||||
|
|
||||||
const updateURL = (params: URLSearchParams) => {
|
const updateURL = (params: URLSearchParams) => {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href)
|
||||||
url.search = params.toString()
|
url.search = params.toString()
|
||||||
window.history.replaceState({}, '', url)
|
window.history.replaceState({}, '', url)
|
||||||
@@ -45,14 +44,12 @@ const parseTimeRangeFromURL = (value: string | null): TimeRange => {
|
|||||||
|
|
||||||
const parseLogLevelFromURL = (value: string | null): LogLevel => {
|
const parseLogLevelFromURL = (value: string | null): LogLevel => {
|
||||||
if (!value) return 'all'
|
if (!value) return 'all'
|
||||||
// Support comma-separated values for multiple selections
|
|
||||||
const levels = value.split(',').filter(Boolean)
|
const levels = value.split(',').filter(Boolean)
|
||||||
const validLevels = levels.filter(
|
const validLevels = levels.filter(
|
||||||
(l) => l === 'error' || l === 'info' || l === 'running' || l === 'pending'
|
(l) => l === 'error' || l === 'info' || l === 'running' || l === 'pending'
|
||||||
)
|
)
|
||||||
if (validLevels.length === 0) return 'all'
|
if (validLevels.length === 0) return 'all'
|
||||||
if (validLevels.length === 1) return validLevels[0] as LogLevel
|
if (validLevels.length === 1) return validLevels[0] as LogLevel
|
||||||
// Return comma-separated string for multiple selections
|
|
||||||
return validLevels.join(',') as LogLevel
|
return validLevels.join(',') as LogLevel
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +99,7 @@ export const useFilterStore = create<FilterState>((set, get) => ({
|
|||||||
folderIds: [],
|
folderIds: [],
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
triggers: [],
|
triggers: [],
|
||||||
_isInitializing: false, // Internal flag to prevent URL sync during initialization
|
isInitializing: false,
|
||||||
|
|
||||||
setWorkspaceId: (workspaceId) => set({ workspaceId }),
|
setWorkspaceId: (workspaceId) => set({ workspaceId }),
|
||||||
|
|
||||||
@@ -110,21 +107,21 @@ export const useFilterStore = create<FilterState>((set, get) => ({
|
|||||||
|
|
||||||
setTimeRange: (timeRange) => {
|
setTimeRange: (timeRange) => {
|
||||||
set({ timeRange })
|
set({ timeRange })
|
||||||
if (!get()._isInitializing) {
|
if (!get().isInitializing) {
|
||||||
get().syncWithURL()
|
get().syncWithURL()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setLevel: (level) => {
|
setLevel: (level) => {
|
||||||
set({ level })
|
set({ level })
|
||||||
if (!get()._isInitializing) {
|
if (!get().isInitializing) {
|
||||||
get().syncWithURL()
|
get().syncWithURL()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setWorkflowIds: (workflowIds) => {
|
setWorkflowIds: (workflowIds) => {
|
||||||
set({ workflowIds })
|
set({ workflowIds })
|
||||||
if (!get()._isInitializing) {
|
if (!get().isInitializing) {
|
||||||
get().syncWithURL()
|
get().syncWithURL()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -140,14 +137,14 @@ export const useFilterStore = create<FilterState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
set({ workflowIds: currentWorkflowIds })
|
set({ workflowIds: currentWorkflowIds })
|
||||||
if (!get()._isInitializing) {
|
if (!get().isInitializing) {
|
||||||
get().syncWithURL()
|
get().syncWithURL()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setFolderIds: (folderIds) => {
|
setFolderIds: (folderIds) => {
|
||||||
set({ folderIds })
|
set({ folderIds })
|
||||||
if (!get()._isInitializing) {
|
if (!get().isInitializing) {
|
||||||
get().syncWithURL()
|
get().syncWithURL()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -163,21 +160,21 @@ export const useFilterStore = create<FilterState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
set({ folderIds: currentFolderIds })
|
set({ folderIds: currentFolderIds })
|
||||||
if (!get()._isInitializing) {
|
if (!get().isInitializing) {
|
||||||
get().syncWithURL()
|
get().syncWithURL()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setSearchQuery: (searchQuery) => {
|
setSearchQuery: (searchQuery) => {
|
||||||
set({ searchQuery })
|
set({ searchQuery })
|
||||||
if (!get()._isInitializing) {
|
if (!get().isInitializing) {
|
||||||
get().syncWithURL()
|
get().syncWithURL()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setTriggers: (triggers: TriggerType[]) => {
|
setTriggers: (triggers: TriggerType[]) => {
|
||||||
set({ triggers })
|
set({ triggers })
|
||||||
if (!get()._isInitializing) {
|
if (!get().isInitializing) {
|
||||||
get().syncWithURL()
|
get().syncWithURL()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -193,16 +190,15 @@ export const useFilterStore = create<FilterState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
set({ triggers: currentTriggers })
|
set({ triggers: currentTriggers })
|
||||||
if (!get()._isInitializing) {
|
if (!get().isInitializing) {
|
||||||
get().syncWithURL()
|
get().syncWithURL()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
initializeFromURL: () => {
|
initializeFromURL: () => {
|
||||||
set({ _isInitializing: true })
|
set({ isInitializing: true })
|
||||||
|
|
||||||
const params = getSearchParams()
|
const params = getSearchParams()
|
||||||
|
|
||||||
const timeRange = parseTimeRangeFromURL(params.get('timeRange'))
|
const timeRange = parseTimeRangeFromURL(params.get('timeRange'))
|
||||||
const level = parseLogLevelFromURL(params.get('level'))
|
const level = parseLogLevelFromURL(params.get('level'))
|
||||||
const workflowIds = parseStringArrayFromURL(params.get('workflowIds'))
|
const workflowIds = parseStringArrayFromURL(params.get('workflowIds'))
|
||||||
@@ -217,7 +213,7 @@ export const useFilterStore = create<FilterState>((set, get) => ({
|
|||||||
folderIds,
|
folderIds,
|
||||||
triggers,
|
triggers,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
_isInitializing: false,
|
isInitializing: false,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -165,28 +165,22 @@ export type TimeRange =
|
|||||||
| 'Past 14 days'
|
| 'Past 14 days'
|
||||||
| 'Past 30 days'
|
| 'Past 30 days'
|
||||||
| 'All time'
|
| 'All time'
|
||||||
export type LogLevel = 'error' | 'info' | 'running' | 'pending' | 'all'
|
|
||||||
|
export type LogLevel = 'error' | 'info' | 'running' | 'pending' | 'all' | (string & {})
|
||||||
export type TriggerType = 'chat' | 'api' | 'webhook' | 'manual' | 'schedule' | 'all' | string
|
export type TriggerType = 'chat' | 'api' | 'webhook' | 'manual' | 'schedule' | 'all' | string
|
||||||
|
|
||||||
|
/** Filter state for logs and dashboard views */
|
||||||
export interface FilterState {
|
export interface FilterState {
|
||||||
// Workspace context
|
|
||||||
workspaceId: string
|
workspaceId: string
|
||||||
|
|
||||||
// View mode
|
|
||||||
viewMode: 'logs' | 'dashboard'
|
viewMode: 'logs' | 'dashboard'
|
||||||
|
|
||||||
// Filter states
|
|
||||||
timeRange: TimeRange
|
timeRange: TimeRange
|
||||||
level: LogLevel
|
level: LogLevel
|
||||||
workflowIds: string[]
|
workflowIds: string[]
|
||||||
folderIds: string[]
|
folderIds: string[]
|
||||||
searchQuery: string
|
searchQuery: string
|
||||||
triggers: TriggerType[]
|
triggers: TriggerType[]
|
||||||
|
isInitializing: boolean
|
||||||
|
|
||||||
// Internal state
|
|
||||||
_isInitializing: boolean
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
setWorkspaceId: (workspaceId: string) => void
|
setWorkspaceId: (workspaceId: string) => void
|
||||||
setViewMode: (viewMode: 'logs' | 'dashboard') => void
|
setViewMode: (viewMode: 'logs' | 'dashboard') => void
|
||||||
setTimeRange: (timeRange: TimeRange) => void
|
setTimeRange: (timeRange: TimeRange) => void
|
||||||
@@ -198,8 +192,6 @@ export interface FilterState {
|
|||||||
setSearchQuery: (query: string) => void
|
setSearchQuery: (query: string) => void
|
||||||
setTriggers: (triggers: TriggerType[]) => void
|
setTriggers: (triggers: TriggerType[]) => void
|
||||||
toggleTrigger: (trigger: TriggerType) => void
|
toggleTrigger: (trigger: TriggerType) => void
|
||||||
|
|
||||||
// URL synchronization methods
|
|
||||||
initializeFromURL: () => void
|
initializeFromURL: () => void
|
||||||
syncWithURL: () => void
|
syncWithURL: () => void
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user