mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(logs): split summary/detail contracts to make trace tab gate type-safe (#4431)
* fix(logs): split summary/detail contracts to make trace tab gate type-safe The Trace tab was silently missing from the Log Details sidepanel because list and detail rows shared one WorkflowLog type with executionData: z.unknown(). The UI couldn't distinguish a summary row (no spans) from a detail row (with spans), so the tab gate read undefined and hid itself. Splits into WorkflowLogSummary (list) and WorkflowLogDetail (typed executionData with optional traceSpans). Detail and by-execution routes both write through to the same logKeys.detail(id) cache, eliminating the two-key fragmentation that caused the merge memo workaround. List route moves to cursor pagination on (sortValue, id) with proper NULLS LAST handling and SQL-side sort across workflow + job execution tables. Detail route now requires and asserts workspaceId. Deep-link path uses useLogByExecutionId instead of auto-paginating the entire workspace. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(logs): audit follow-ups — render side-effect, stats invalidation, enhanced spread order - Move onActiveTabChange call from render into useEffect to avoid side-effects during render (StrictMode safety). - Re-add logKeys.stats() invalidation to cancel/retry mutations so the dashboard reflects status flips immediately. - Reorder enhanced: true after ...execData spread in detail and by-execution routes so the literal discriminator is never overwritten by stale execData. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(logs): mirror SQL NULLS LAST in JS merge for cursor consistency The in-memory merge of workflow + job pages negated the comparator for DESC, which placed null sort values at the start. SQL orders both ASC and DESC with NULLS LAST, so DESC pages emitted a cursor {v: <last non-null>, id: ...} while null rows still satisfied the cursor predicate (OR sort_expr IS NULL) on the next page — producing duplicate null rows across pages on cost/duration sorts. Handle nulls explicitly in the JS comparator so they always sort last regardless of direction, matching the SQL ordering. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(logs): final-audit follow-ups — stable tab callback, byExecution invalidation, optimistic detail patch, trace loading state - Wrap LogDetails -> LogDetailsContent onActiveTabChange in useCallback so the child useEffect doesn't refire on every parent render. - Add logKeys.byExecutionAll() to cancel + retry invalidation so the table-embedded sidebar picks up status changes immediately. - Optimistic write-through to logKeys.detail in useCancelExecution so the open sidebar reflects 'cancelling' instantly; rolls back on error. - Distinguish trace loading from trace-empty: when log.executionData is not yet fetched, render "Loading trace…" instead of the empty state. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(logs): migrate stores/components to contract types Replace the legacy `WorkflowLog` / `LogsResponse` / `WorkflowData` / `CostMetadata` / `ToolCallMetadata` shapes in `stores/logs/filters/types.ts` with direct use of the contract types `WorkflowLogSummary`, `WorkflowLogDetail`, and a new `WorkflowLogRow` alias for surfaces that render either form. Removes the `summaryToWorkflowLog` / `detailToWorkflowLog` bridge in the React Query layer along with their double-cast annotations. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(logs): address PR review feedback - Whitelist sort columns against logSortBy enum to prevent client crash when non-sortable headers (workflow, trigger) reach the contract parser. - Extract fetchLogDetail helper shared by /api/logs/[id] and /api/logs/by-execution/[executionId] — collapses ~360 duplicated lines to a single source of truth keyed on lookup column. * fix(logs): exclude job logs when level filter is workflow-only When level=running or level=pending (workflow-only states involving endedAt/pausedExecutions semantics), jobLevelConditions stayed empty so no level constraint reached jobConditions — every job log in the workspace leaked into the result. Skip the job side entirely when the level filter has no job-applicable values (error/info). * chore(logs): drop dead utils — mapToExecutionLog and friends Remove ExecutionLog/RawLogResponse/ExecutionCost/LogWithExecutionData/ TraceSpan/BlockExecution interfaces and the mapToExecutionLog, mapToExecutionLogAlt, extractOutput functions — all unreferenced after the contract split. -212 lines. * chore(logs): drop unused LOG_COLUMN_ORDER and LogColumnKey * fix(logs): hydrate filters from URL synchronously on mount The previous useEffect-based initializeFromURL caused useLogsList and useDashboardStats to fire once with default store filters, then refetch after the effect updated filters from the URL. Move the initial hydrate into a useState lazy initializer so the first render already reads URL-derived filters; the popstate handler keeps the existing effect for back/forward navigation. * chore(logs): trim verbose comments added during PR Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(logs): guard navigation arrows when selected log is off-page Deep-linked logs resolved via useLogByExecutionId may not be in the current page list, leaving selectedLogIndex at -1. The hasNext prop was evaluating -1 < logs.length - 1 (true for any non-empty list), which enabled the next arrow and jumped to the first item on click. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(logs): sync active-tab callback before paint to keep keyboard guards aligned Run the resolvedTab → onActiveTabChange propagation in useLayoutEffect so the parent's activeTabRef updates synchronously before the next paint. This closes the brief window where window keydown handlers in the logs page would still see activeTabRef.current === 'trace' and short-circuit arrow-key navigation immediately after switching to a log without a Trace tab. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
6d044a924c
commit
029ac9fb05
@@ -1,183 +1,36 @@
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
jobExecutionLogs,
|
||||
permissions,
|
||||
workflow,
|
||||
workflowDeploymentVersion,
|
||||
workflowExecutionLogs,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { logIdParamsSchema } from '@/lib/api/contracts/logs'
|
||||
import { getLogDetailContract } from '@/lib/api/contracts/logs'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { fetchLogDetail } from '@/lib/logs/fetch-log-detail'
|
||||
|
||||
const logger = createLogger('LogDetailsByIdAPI')
|
||||
|
||||
export const revalidate = 0
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized log details access attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const { id } = logIdParamsSchema.parse(await params)
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: workflowExecutionLogs.id,
|
||||
workflowId: workflowExecutionLogs.workflowId,
|
||||
executionId: workflowExecutionLogs.executionId,
|
||||
stateSnapshotId: workflowExecutionLogs.stateSnapshotId,
|
||||
deploymentVersionId: workflowExecutionLogs.deploymentVersionId,
|
||||
level: workflowExecutionLogs.level,
|
||||
status: workflowExecutionLogs.status,
|
||||
trigger: workflowExecutionLogs.trigger,
|
||||
startedAt: workflowExecutionLogs.startedAt,
|
||||
endedAt: workflowExecutionLogs.endedAt,
|
||||
totalDurationMs: workflowExecutionLogs.totalDurationMs,
|
||||
executionData: workflowExecutionLogs.executionData,
|
||||
cost: workflowExecutionLogs.cost,
|
||||
files: workflowExecutionLogs.files,
|
||||
createdAt: workflowExecutionLogs.createdAt,
|
||||
workflowName: workflow.name,
|
||||
workflowDescription: workflow.description,
|
||||
workflowColor: workflow.color,
|
||||
workflowFolderId: workflow.folderId,
|
||||
workflowUserId: workflow.userId,
|
||||
workflowWorkspaceId: workflow.workspaceId,
|
||||
workflowCreatedAt: workflow.createdAt,
|
||||
workflowUpdatedAt: workflow.updatedAt,
|
||||
deploymentVersion: workflowDeploymentVersion.version,
|
||||
deploymentVersionName: workflowDeploymentVersion.name,
|
||||
})
|
||||
.from(workflowExecutionLogs)
|
||||
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
|
||||
.leftJoin(
|
||||
workflowDeploymentVersion,
|
||||
eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId)
|
||||
)
|
||||
.innerJoin(
|
||||
permissions,
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workflowExecutionLogs.workspaceId),
|
||||
eq(permissions.userId, userId)
|
||||
)
|
||||
)
|
||||
.where(eq(workflowExecutionLogs.id, id))
|
||||
.limit(1)
|
||||
|
||||
const log = rows[0]
|
||||
|
||||
// Fallback: check job_execution_logs
|
||||
if (!log) {
|
||||
const jobRows = await db
|
||||
.select({
|
||||
id: jobExecutionLogs.id,
|
||||
executionId: jobExecutionLogs.executionId,
|
||||
level: jobExecutionLogs.level,
|
||||
status: jobExecutionLogs.status,
|
||||
trigger: jobExecutionLogs.trigger,
|
||||
startedAt: jobExecutionLogs.startedAt,
|
||||
endedAt: jobExecutionLogs.endedAt,
|
||||
totalDurationMs: jobExecutionLogs.totalDurationMs,
|
||||
executionData: jobExecutionLogs.executionData,
|
||||
cost: jobExecutionLogs.cost,
|
||||
createdAt: jobExecutionLogs.createdAt,
|
||||
})
|
||||
.from(jobExecutionLogs)
|
||||
.innerJoin(
|
||||
permissions,
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, jobExecutionLogs.workspaceId),
|
||||
eq(permissions.userId, userId)
|
||||
)
|
||||
)
|
||||
.where(eq(jobExecutionLogs.id, id))
|
||||
.limit(1)
|
||||
|
||||
const jobLog = jobRows[0]
|
||||
if (!jobLog) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const execData = jobLog.executionData as Record<string, any> | null
|
||||
const response = {
|
||||
id: jobLog.id,
|
||||
workflowId: null,
|
||||
executionId: jobLog.executionId,
|
||||
deploymentVersionId: null,
|
||||
deploymentVersion: null,
|
||||
deploymentVersionName: null,
|
||||
level: jobLog.level,
|
||||
status: jobLog.status,
|
||||
duration: jobLog.totalDurationMs ? `${jobLog.totalDurationMs}ms` : null,
|
||||
trigger: jobLog.trigger,
|
||||
createdAt: jobLog.startedAt.toISOString(),
|
||||
workflow: null,
|
||||
jobTitle: (execData?.trigger?.source as string) || null,
|
||||
executionData: {
|
||||
totalDuration: jobLog.totalDurationMs,
|
||||
...execData,
|
||||
enhanced: true,
|
||||
},
|
||||
cost: jobLog.cost as any,
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: response })
|
||||
}
|
||||
|
||||
const workflowSummary = log.workflowId
|
||||
? {
|
||||
id: log.workflowId,
|
||||
name: log.workflowName,
|
||||
description: log.workflowDescription,
|
||||
color: log.workflowColor,
|
||||
folderId: log.workflowFolderId,
|
||||
userId: log.workflowUserId,
|
||||
workspaceId: log.workflowWorkspaceId,
|
||||
createdAt: log.workflowCreatedAt,
|
||||
updatedAt: log.workflowUpdatedAt,
|
||||
}
|
||||
: null
|
||||
|
||||
const response = {
|
||||
id: log.id,
|
||||
workflowId: log.workflowId,
|
||||
executionId: log.executionId,
|
||||
deploymentVersionId: log.deploymentVersionId,
|
||||
deploymentVersion: log.deploymentVersion ?? null,
|
||||
deploymentVersionName: log.deploymentVersionName ?? null,
|
||||
level: log.level,
|
||||
status: log.status,
|
||||
duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null,
|
||||
trigger: log.trigger,
|
||||
createdAt: log.startedAt.toISOString(),
|
||||
files: log.files || undefined,
|
||||
workflow: workflowSummary,
|
||||
executionData: {
|
||||
totalDuration: log.totalDurationMs,
|
||||
...(log.executionData as any),
|
||||
enhanced: true,
|
||||
},
|
||||
cost: log.cost as any,
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: response })
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] log details fetch error`, error)
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(getLogDetailContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id } = parsed.data.params
|
||||
const { workspaceId } = parsed.data.query
|
||||
|
||||
const data = await fetchLogDetail({
|
||||
userId: session.user.id,
|
||||
workspaceId,
|
||||
lookupColumn: 'id',
|
||||
lookupValue: id,
|
||||
})
|
||||
|
||||
if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
|
||||
logger.debug('Fetched log detail', { id, workspaceId })
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getLogByExecutionIdContract } from '@/lib/api/contracts/logs'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { fetchLogDetail } from '@/lib/logs/fetch-log-detail'
|
||||
|
||||
const logger = createLogger('LogDetailsByExecutionAPI')
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ executionId: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(getLogByExecutionIdContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { executionId } = parsed.data.params
|
||||
const { workspaceId } = parsed.data.query
|
||||
|
||||
const data = await fetchLogDetail({
|
||||
userId: session.user.id,
|
||||
workspaceId,
|
||||
lookupColumn: 'executionId',
|
||||
lookupValue: executionId,
|
||||
})
|
||||
|
||||
if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
|
||||
logger.debug('Fetched log by execution id', { executionId, workspaceId })
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
)
|
||||
+428
-569
File diff suppressed because it is too large
Load Diff
+2
@@ -59,6 +59,8 @@ const LOG_DROPDOWN_FILTERS = {
|
||||
triggers: [] as string[],
|
||||
searchQuery: '',
|
||||
limit: LOG_DROPDOWN_LIMIT,
|
||||
sortBy: 'date' as const,
|
||||
sortOrder: 'desc' as const,
|
||||
}
|
||||
|
||||
export function useAvailableResources(
|
||||
|
||||
+5
-3
@@ -185,6 +185,7 @@ export const ResourceContent = memo(function ResourceContent({
|
||||
return (
|
||||
<EmbeddedLog
|
||||
key={resource.id}
|
||||
workspaceId={workspaceId}
|
||||
logId={resource.id}
|
||||
onNotFound={onNotFound ? () => onNotFound(resource.id) : undefined}
|
||||
/>
|
||||
@@ -626,12 +627,13 @@ function EmbeddedFolder({ workspaceId, folderId }: EmbeddedFolderProps) {
|
||||
}
|
||||
|
||||
interface EmbeddedLogProps {
|
||||
workspaceId: string
|
||||
logId: string
|
||||
onNotFound?: () => void
|
||||
}
|
||||
|
||||
function EmbeddedLog({ logId, onNotFound }: EmbeddedLogProps) {
|
||||
const { data: log, isLoading, error } = useLogDetail(logId)
|
||||
function EmbeddedLog({ workspaceId, logId, onNotFound }: EmbeddedLogProps) {
|
||||
const { data: log, isLoading, error } = useLogDetail(logId, workspaceId)
|
||||
|
||||
const onNotFoundRef = useRef(onNotFound)
|
||||
onNotFoundRef.current = onNotFound
|
||||
@@ -672,7 +674,7 @@ interface EmbeddedLogActionsProps {
|
||||
|
||||
export function EmbeddedLogActions({ workspaceId, logId }: EmbeddedLogActionsProps) {
|
||||
const router = useRouter()
|
||||
const { data: log } = useLogDetail(logId)
|
||||
const { data: log } = useLogDetail(logId, workspaceId)
|
||||
|
||||
const handleOpenInLogs = () => {
|
||||
const param = log?.executionId ? `?executionId=${log.executionId}` : ''
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { formatDuration } from '@sim/utils/formatting'
|
||||
import { ArrowDown, ArrowUp, Check, ChevronUp, Clipboard, Eye, Search, X } from 'lucide-react'
|
||||
import { createPortal } from 'react-dom'
|
||||
@@ -22,9 +22,11 @@ import {
|
||||
SModalTabsTrigger,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import type { WorkflowLogRow } from '@/lib/api/contracts/logs'
|
||||
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans'
|
||||
import type { TraceSpan } from '@/lib/logs/types'
|
||||
import { workflowBorderColor } from '@/lib/workspaces/colors'
|
||||
import {
|
||||
ExecutionSnapshot,
|
||||
@@ -43,13 +45,9 @@ import {
|
||||
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { formatCost } from '@/providers/utils'
|
||||
import type { WorkflowLog } from '@/stores/logs/filters/types'
|
||||
import { useLogDetailsUIStore } from '@/stores/logs/store'
|
||||
import { MAX_LOG_DETAILS_WIDTH_RATIO, MIN_LOG_DETAILS_WIDTH } from '@/stores/logs/utils'
|
||||
|
||||
/**
|
||||
* Workflow Output section with code viewer, copy, search, and context menu functionality
|
||||
*/
|
||||
export const WorkflowOutputSection = memo(
|
||||
function WorkflowOutputSection({ output }: { output: Record<string, unknown> }) {
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
@@ -257,18 +255,10 @@ export const WorkflowOutputSection = memo(
|
||||
export type LogDetailsTab = 'overview' | 'trace'
|
||||
|
||||
interface LogDetailsContentProps {
|
||||
/** The log to display */
|
||||
log: WorkflowLog
|
||||
/** Fires when the active tab changes, so embedders can gate their own keyboard handlers */
|
||||
log: WorkflowLogRow
|
||||
onActiveTabChange?: (tab: LogDetailsTab) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Tabbed body for a single log: overview details and trace spans, plus the
|
||||
* execution snapshot modal. Used as the body of the `LogDetails` sidebar and
|
||||
* embedded directly inside the Mothership resource panel — keep the two in
|
||||
* sync by editing this component, not by re-implementing it elsewhere.
|
||||
*/
|
||||
export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentProps) {
|
||||
const [isExecutionSnapshotOpen, setIsExecutionSnapshotOpen] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<LogDetailsTab>('overview')
|
||||
@@ -297,9 +287,9 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
|
||||
}
|
||||
}, [log.id])
|
||||
|
||||
const isLikelyExecution = !!log.executionId && log.trigger !== 'mothership'
|
||||
const isWorkflowExecutionLog =
|
||||
(log.trigger === 'manual' && !!log.duration) ||
|
||||
!!(log.executionData?.enhanced && log.executionData?.traceSpans)
|
||||
(log.trigger === 'manual' && !!log.duration) || !!log.executionData?.traceSpans
|
||||
|
||||
const hasCostInfo = !!(isWorkflowExecutionLog && log.cost)
|
||||
const showWorkflowState =
|
||||
@@ -307,16 +297,16 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
|
||||
!!log.executionId &&
|
||||
log.trigger !== 'mothership' &&
|
||||
!permissionConfig.hideTraceSpans
|
||||
const showTraceTab =
|
||||
isWorkflowExecutionLog && !!log.executionData?.traceSpans && !permissionConfig.hideTraceSpans
|
||||
|
||||
const showTraceTab = !permissionConfig.hideTraceSpans && isLikelyExecution
|
||||
// double-cast-allowed: contract schema makes duration/startTime optional for legacy persisted JSON; runtime data always supplies them.
|
||||
const traceSpans = log.executionData?.traceSpans as unknown as TraceSpan[] | undefined
|
||||
|
||||
const resolvedTab: LogDetailsTab = activeTab === 'trace' && !showTraceTab ? 'overview' : activeTab
|
||||
|
||||
const prevResolvedTabRef = useRef<LogDetailsTab>(resolvedTab)
|
||||
if (prevResolvedTabRef.current !== resolvedTab) {
|
||||
prevResolvedTabRef.current = resolvedTab
|
||||
useLayoutEffect(() => {
|
||||
onActiveTabChange?.(resolvedTab)
|
||||
}
|
||||
}, [resolvedTab, onActiveTabChange])
|
||||
|
||||
const workflowOutput = useMemo(() => {
|
||||
const executionData = log.executionData as { finalOutput?: Record<string, unknown> } | undefined
|
||||
@@ -594,12 +584,26 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
|
||||
</SModalTabsContent>
|
||||
|
||||
{/* Trace Tab */}
|
||||
{showTraceTab && log.executionData?.traceSpans && (
|
||||
{showTraceTab && (
|
||||
<SModalTabsContent
|
||||
value='trace'
|
||||
className='mt-3 min-h-0 flex-1 overflow-hidden focus-visible:outline-none'
|
||||
>
|
||||
<TraceView traceSpans={log.executionData.traceSpans} />
|
||||
{traceSpans?.length ? (
|
||||
<TraceView traceSpans={traceSpans} />
|
||||
) : log.executionData ? (
|
||||
<div className='flex h-full items-center justify-center px-4 text-center'>
|
||||
<span className='font-medium text-[var(--text-tertiary)] text-sm'>
|
||||
No trace data available for this run
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex h-full items-center justify-center px-4 text-center'>
|
||||
<span className='font-medium text-[var(--text-tertiary)] text-sm'>
|
||||
Loading trace…
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</SModalTabsContent>
|
||||
)}
|
||||
</SModalTabs>
|
||||
@@ -608,7 +612,7 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
|
||||
{log.executionId && (
|
||||
<ExecutionSnapshot
|
||||
executionId={log.executionId}
|
||||
traceSpans={log.executionData?.traceSpans}
|
||||
traceSpans={traceSpans}
|
||||
isModal
|
||||
isOpen={isExecutionSnapshotOpen}
|
||||
onClose={() => setIsExecutionSnapshotOpen(false)}
|
||||
@@ -619,33 +623,18 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
|
||||
}
|
||||
|
||||
interface LogDetailsProps {
|
||||
/** The log to display details for */
|
||||
log: WorkflowLog | null
|
||||
/** Whether the sidebar is open */
|
||||
log: WorkflowLogRow | null
|
||||
isOpen: boolean
|
||||
/** Callback when closing the sidebar */
|
||||
onClose: () => void
|
||||
/** Callback to navigate to next log */
|
||||
onNavigateNext?: () => void
|
||||
/** Callback to navigate to previous log */
|
||||
onNavigatePrev?: () => void
|
||||
/** Whether there is a next log available */
|
||||
hasNext?: boolean
|
||||
/** Whether there is a previous log available */
|
||||
hasPrev?: boolean
|
||||
/** Callback to retry a failed execution */
|
||||
onRetryExecution?: () => void
|
||||
/** Whether a retry is currently in progress */
|
||||
isRetryPending?: boolean
|
||||
/** Fires when the active tab changes, so the parent can gate its own keyboard handlers */
|
||||
onActiveTabChange?: (tab: LogDetailsTab) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar panel displaying detailed information about a selected log.
|
||||
* Wraps `LogDetailsContent` with sidebar chrome — resize handle, header, and
|
||||
* keyboard navigation between logs.
|
||||
*/
|
||||
export const LogDetails = memo(function LogDetails({
|
||||
log,
|
||||
isOpen,
|
||||
@@ -660,13 +649,18 @@ export const LogDetails = memo(function LogDetails({
|
||||
}: LogDetailsProps) {
|
||||
const activeTabRef = useRef<LogDetailsTab>('overview')
|
||||
|
||||
const handleActiveTabChange = useCallback(
|
||||
(tab: LogDetailsTab) => {
|
||||
activeTabRef.current = tab
|
||||
onActiveTabChange?.(tab)
|
||||
},
|
||||
[onActiveTabChange]
|
||||
)
|
||||
|
||||
const panelWidth = useLogDetailsUIStore((state) => state.panelWidth)
|
||||
const { handleMouseDown } = useLogDetailsResize()
|
||||
|
||||
const maxVw = `${MAX_LOG_DETAILS_WIDTH_RATIO * 100}vw`
|
||||
// CSS-side clamp matching `clampPanelWidth` in stores/logs/utils.ts: the
|
||||
// floor is itself capped at the max-vw ratio so a narrow viewport doesn't
|
||||
// let the min outpace the cap and cover the table behind the panel.
|
||||
const effectiveWidth = `clamp(min(${MIN_LOG_DETAILS_WIDTH}px, ${maxVw}), ${panelWidth}px, ${maxVw})`
|
||||
|
||||
useEffect(() => {
|
||||
@@ -677,8 +671,7 @@ export const LogDetails = memo(function LogDetails({
|
||||
|
||||
if (!isOpen) return
|
||||
|
||||
// When the Trace tab is active, arrow keys belong to TraceView's own
|
||||
// span-navigation handler. Log-to-log navigation should not hijack them.
|
||||
// Trace tab owns arrow keys for span navigation.
|
||||
if (activeTabRef.current === 'trace') return
|
||||
|
||||
if (e.key === 'ArrowUp' && hasPrev && onNavigatePrev) {
|
||||
@@ -766,13 +759,7 @@ export const LogDetails = memo(function LogDetails({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LogDetailsContent
|
||||
log={log}
|
||||
onActiveTabChange={(tab) => {
|
||||
activeTabRef.current = tab
|
||||
onActiveTabChange?.(tab)
|
||||
}}
|
||||
/>
|
||||
<LogDetailsContent log={log} onActiveTabChange={handleActiveTabChange} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -15,13 +15,13 @@ import {
|
||||
SquareArrowUpRight,
|
||||
X,
|
||||
} from '@/components/emcn'
|
||||
import type { WorkflowLog } from '@/stores/logs/filters/types'
|
||||
import type { WorkflowLogSummary } from '@/lib/api/contracts/logs'
|
||||
|
||||
interface LogRowContextMenuProps {
|
||||
isOpen: boolean
|
||||
position: { x: number; y: number }
|
||||
onClose: () => void
|
||||
log: WorkflowLog | null
|
||||
log: WorkflowLogSummary | null
|
||||
onCopyExecutionId: () => void
|
||||
onCopyLink: () => void
|
||||
onOpenWorkflow: () => void
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ArrowUpRight } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { List, type RowComponentProps, useListRef } from 'react-window'
|
||||
import { Badge, buttonVariants, Loader } from '@/components/emcn'
|
||||
import type { WorkflowLogSummary } from '@/lib/api/contracts/logs'
|
||||
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { workflowBorderColor } from '@/lib/workspaces/colors'
|
||||
@@ -18,16 +19,15 @@ import {
|
||||
StatusBadge,
|
||||
TriggerBadge,
|
||||
} from '@/app/workspace/[workspaceId]/logs/utils'
|
||||
import type { WorkflowLog } from '@/stores/logs/filters/types'
|
||||
|
||||
const LOG_ROW_HEIGHT = 44 as const
|
||||
|
||||
interface LogRowProps {
|
||||
log: WorkflowLog
|
||||
log: WorkflowLogSummary
|
||||
isSelected: boolean
|
||||
onClick: (log: WorkflowLog) => void
|
||||
onHover?: (log: WorkflowLog) => void
|
||||
onContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void
|
||||
onClick: (log: WorkflowLogSummary) => void
|
||||
onHover?: (log: WorkflowLogSummary) => void
|
||||
onContextMenu?: (e: React.MouseEvent, log: WorkflowLogSummary) => void
|
||||
selectedRowRef: React.RefObject<HTMLTableRowElement | null> | null
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ const LogRow = memo(
|
||||
? '#ec4899'
|
||||
: isDeletedWorkflow
|
||||
? DELETED_WORKFLOW_COLOR
|
||||
: log.workflow?.color
|
||||
: (log.workflow?.color ?? undefined)
|
||||
|
||||
const handleClick = () => onClick(log)
|
||||
const handleMouseEnter = () => onHover?.(log)
|
||||
@@ -164,11 +164,11 @@ const LogRow = memo(
|
||||
)
|
||||
|
||||
interface RowProps {
|
||||
logs: WorkflowLog[]
|
||||
logs: WorkflowLogSummary[]
|
||||
selectedLogId: string | null
|
||||
onLogClick: (log: WorkflowLog) => void
|
||||
onLogHover?: (log: WorkflowLog) => void
|
||||
onLogContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void
|
||||
onLogClick: (log: WorkflowLogSummary) => void
|
||||
onLogHover?: (log: WorkflowLogSummary) => void
|
||||
onLogContextMenu?: (e: React.MouseEvent, log: WorkflowLogSummary) => void
|
||||
selectedRowRef: React.RefObject<HTMLTableRowElement | null>
|
||||
isFetchingNextPage: boolean
|
||||
loaderRef: React.RefObject<HTMLDivElement | null>
|
||||
@@ -225,11 +225,11 @@ function Row({
|
||||
}
|
||||
|
||||
export interface LogsListProps {
|
||||
logs: WorkflowLog[]
|
||||
logs: WorkflowLogSummary[]
|
||||
selectedLogId: string | null
|
||||
onLogClick: (log: WorkflowLog) => void
|
||||
onLogHover?: (log: WorkflowLog) => void
|
||||
onLogContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void
|
||||
onLogClick: (log: WorkflowLogSummary) => void
|
||||
onLogHover?: (log: WorkflowLogSummary) => void
|
||||
onLogContextMenu?: (e: React.MouseEvent, log: WorkflowLogSummary) => void
|
||||
selectedRowRef: React.RefObject<HTMLTableRowElement | null>
|
||||
hasNextPage: boolean
|
||||
isFetchingNextPage: boolean
|
||||
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
RefreshCw,
|
||||
toast,
|
||||
} from '@/components/emcn'
|
||||
import type {
|
||||
WorkflowLogDetail,
|
||||
WorkflowLogRow,
|
||||
WorkflowLogSummary,
|
||||
} from '@/lib/api/contracts/logs'
|
||||
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
@@ -50,12 +55,14 @@ import type { Suggestion } from '@/app/workspace/[workspaceId]/logs/types'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { useFolderMap, useFolders } from '@/hooks/queries/folders'
|
||||
import type { LogSortBy, LogSortOrder } from '@/hooks/queries/logs'
|
||||
import {
|
||||
fetchLogDetail,
|
||||
logKeys,
|
||||
prefetchLogDetail,
|
||||
useCancelExecution,
|
||||
useDashboardStats,
|
||||
useLogByExecutionId,
|
||||
useLogDetail,
|
||||
useLogsList,
|
||||
useRetryExecution,
|
||||
@@ -63,7 +70,6 @@ import {
|
||||
import { useWorkflowMap, useWorkflows } from '@/hooks/queries/workflows'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
||||
import type { WorkflowLog } from '@/stores/logs/filters/types'
|
||||
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
|
||||
import {
|
||||
Dashboard,
|
||||
@@ -86,6 +92,7 @@ import {
|
||||
} from './utils'
|
||||
|
||||
const LOGS_PER_PAGE = 50 as const
|
||||
const SORTABLE_COLUMNS: readonly LogSortBy[] = ['date', 'duration', 'cost', 'status'] as const
|
||||
const REFRESH_SPINNER_DURATION_MS = 1000 as const
|
||||
|
||||
const LOG_COLUMNS: ResourceColumn[] = [
|
||||
@@ -214,6 +221,11 @@ export default function Logs() {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
useState(() => {
|
||||
useFilterStore.getState().initializeFromURL()
|
||||
return null
|
||||
})
|
||||
|
||||
const {
|
||||
setWorkspaceId,
|
||||
initializeFromURL,
|
||||
@@ -268,14 +280,11 @@ export default function Logs() {
|
||||
selectedLogId: null,
|
||||
isSidebarOpen: false,
|
||||
})
|
||||
const isInitialized = useRef<boolean>(false)
|
||||
const pendingExecutionIdRef = useRef<string | null | undefined>(undefined)
|
||||
if (pendingExecutionIdRef.current === undefined) {
|
||||
pendingExecutionIdRef.current =
|
||||
typeof window !== 'undefined'
|
||||
? new URLSearchParams(window.location.search).get('executionId')
|
||||
: null
|
||||
}
|
||||
const [pendingExecutionId, setPendingExecutionId] = useState<string | null>(() =>
|
||||
typeof window !== 'undefined'
|
||||
? new URLSearchParams(window.location.search).get('executionId')
|
||||
: null
|
||||
)
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState(() => {
|
||||
if (typeof window === 'undefined') return ''
|
||||
@@ -287,7 +296,7 @@ export default function Logs() {
|
||||
const [isVisuallyRefreshing, setIsVisuallyRefreshing] = useState(false)
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const refreshTimersRef = useRef(new Set<number>())
|
||||
const logsRef = useRef<WorkflowLog[]>([])
|
||||
const logsRef = useRef<WorkflowLogSummary[]>([])
|
||||
const selectedLogIndexRef = useRef(-1)
|
||||
const selectedLogIdRef = useRef<string | null>(null)
|
||||
const shouldScrollIntoViewRef = useRef(false)
|
||||
@@ -304,21 +313,35 @@ export default function Logs() {
|
||||
|
||||
const [contextMenuOpen, setContextMenuOpen] = useState(false)
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
|
||||
const [contextMenuLog, setContextMenuLog] = useState<WorkflowLog | null>(null)
|
||||
const [contextMenuLog, setContextMenuLog] = useState<WorkflowLogSummary | null>(null)
|
||||
|
||||
const [previewLogId, setPreviewLogId] = useState<string | null>(null)
|
||||
|
||||
const activeLogId = previewLogId ?? selectedLogId
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const activeLogQuery = useLogDetail(activeLogId ?? undefined, {
|
||||
refetchInterval: (query: { state: { data?: WorkflowLog } }) => {
|
||||
const refetchInterval = useCallback(
|
||||
(query: { state: { data?: WorkflowLogDetail } }) => {
|
||||
if (!isLive) return false
|
||||
const status = query.state.data?.status
|
||||
return status === 'running' || status === 'pending' ? 3000 : false
|
||||
},
|
||||
[isLive]
|
||||
)
|
||||
|
||||
const selectedDetailQuery = useLogDetail(selectedLogId ?? undefined, workspaceId, {
|
||||
refetchInterval,
|
||||
})
|
||||
|
||||
const previewDetailQuery = useLogDetail(previewLogId ?? undefined, workspaceId, {
|
||||
refetchInterval,
|
||||
})
|
||||
|
||||
const sortBy: LogSortBy =
|
||||
activeSort && SORTABLE_COLUMNS.includes(activeSort.column as LogSortBy)
|
||||
? (activeSort.column as LogSortBy)
|
||||
: 'date'
|
||||
const sortOrder: LogSortOrder = activeSort?.direction ?? 'desc'
|
||||
|
||||
const logFilters = useMemo(
|
||||
() => ({
|
||||
timeRange,
|
||||
@@ -330,12 +353,24 @@ export default function Logs() {
|
||||
triggers,
|
||||
searchQuery: debouncedSearchQuery,
|
||||
limit: LOGS_PER_PAGE,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
}),
|
||||
[timeRange, startDate, endDate, level, workflowIds, folderIds, triggers, debouncedSearchQuery]
|
||||
[
|
||||
timeRange,
|
||||
startDate,
|
||||
endDate,
|
||||
level,
|
||||
workflowIds,
|
||||
folderIds,
|
||||
triggers,
|
||||
debouncedSearchQuery,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]
|
||||
)
|
||||
|
||||
const logsQuery = useLogsList(workspaceId, logFilters, {
|
||||
enabled: Boolean(workspaceId) && isInitialized.current,
|
||||
refetchInterval: isLive ? 3000 : false,
|
||||
})
|
||||
|
||||
@@ -354,7 +389,6 @@ export default function Logs() {
|
||||
)
|
||||
|
||||
const dashboardStatsQuery = useDashboardStats(workspaceId, dashboardFilters, {
|
||||
enabled: Boolean(workspaceId) && isInitialized.current,
|
||||
refetchInterval: isLive ? 3000 : false,
|
||||
})
|
||||
|
||||
@@ -362,80 +396,42 @@ export default function Logs() {
|
||||
return logsQuery.data?.pages?.flatMap((page) => page.logs) ?? []
|
||||
}, [logsQuery.data?.pages])
|
||||
|
||||
const sortedLogs = useMemo(() => {
|
||||
if (!activeSort) return logs
|
||||
|
||||
const { column, direction } = activeSort
|
||||
return [...logs].sort((a, b) => {
|
||||
let cmp = 0
|
||||
switch (column) {
|
||||
case 'date':
|
||||
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
break
|
||||
case 'duration': {
|
||||
const aDuration = parseDuration({ duration: a.duration ?? undefined }) ?? -1
|
||||
const bDuration = parseDuration({ duration: b.duration ?? undefined }) ?? -1
|
||||
cmp = aDuration - bDuration
|
||||
break
|
||||
}
|
||||
case 'cost': {
|
||||
const aCost = typeof a.cost?.total === 'number' ? a.cost.total : -1
|
||||
const bCost = typeof b.cost?.total === 'number' ? b.cost.total : -1
|
||||
cmp = aCost - bCost
|
||||
break
|
||||
}
|
||||
case 'status':
|
||||
cmp = (a.status ?? '').localeCompare(b.status ?? '')
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
return direction === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}, [logs, activeSort])
|
||||
|
||||
const selectedLogIndex = selectedLogId ? sortedLogs.findIndex((l) => l.id === selectedLogId) : -1
|
||||
const selectedLogFromList = selectedLogIndex >= 0 ? sortedLogs[selectedLogIndex] : null
|
||||
|
||||
const selectedLog = useMemo(() => {
|
||||
if (!selectedLogFromList) return null
|
||||
if (!activeLogQuery.data || previewLogId !== null) return selectedLogFromList
|
||||
return { ...selectedLogFromList, ...activeLogQuery.data }
|
||||
}, [selectedLogFromList, activeLogQuery.data, previewLogId])
|
||||
const selectedLogIndex = selectedLogId ? logs.findIndex((l) => l.id === selectedLogId) : -1
|
||||
const selectedLogFromList = selectedLogIndex >= 0 ? logs[selectedLogIndex] : null
|
||||
const selectedLog = selectedDetailQuery.data ?? selectedLogFromList ?? null
|
||||
|
||||
const handleLogHover = useCallback(
|
||||
(rowId: string) => {
|
||||
prefetchLogDetail(queryClient, rowId)
|
||||
prefetchLogDetail(queryClient, rowId, workspaceId)
|
||||
},
|
||||
[queryClient]
|
||||
[queryClient, workspaceId]
|
||||
)
|
||||
|
||||
useFolders(workspaceId)
|
||||
|
||||
logsRef.current = sortedLogs
|
||||
logsRef.current = logs
|
||||
selectedLogIndexRef.current = selectedLogIndex
|
||||
selectedLogIdRef.current = selectedLogId
|
||||
logsRefetchRef.current = logsQuery.refetch
|
||||
activeLogRefetchRef.current = activeLogQuery.refetch
|
||||
activeLogRefetchRef.current = selectedDetailQuery.refetch
|
||||
logsQueryRef.current = {
|
||||
isFetching: logsQuery.isFetching,
|
||||
hasNextPage: logsQuery.hasNextPage ?? false,
|
||||
fetchNextPage: logsQuery.fetchNextPage,
|
||||
}
|
||||
|
||||
const deepLinkQuery = useLogByExecutionId(workspaceId, pendingExecutionId)
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingExecutionIdRef.current) return
|
||||
const targetExecutionId = pendingExecutionIdRef.current
|
||||
const found = sortedLogs.find((l) => l.executionId === targetExecutionId)
|
||||
if (found) {
|
||||
pendingExecutionIdRef.current = null
|
||||
dispatch({ type: 'TOGGLE_LOG', logId: found.id })
|
||||
} else if (!logsQuery.hasNextPage && logsQuery.status === 'success') {
|
||||
pendingExecutionIdRef.current = null
|
||||
} else if (!logsQuery.isFetching && logsQuery.status === 'success') {
|
||||
logsQueryRef.current.fetchNextPage()
|
||||
if (!pendingExecutionId) return
|
||||
const resolvedId = deepLinkQuery.data?.id
|
||||
if (resolvedId) {
|
||||
dispatch({ type: 'TOGGLE_LOG', logId: resolvedId })
|
||||
setPendingExecutionId(null)
|
||||
} else if (deepLinkQuery.isError) {
|
||||
setPendingExecutionId(null)
|
||||
}
|
||||
}, [sortedLogs, logsQuery.hasNextPage, logsQuery.isFetching, logsQuery.status])
|
||||
}, [pendingExecutionId, deepLinkQuery.data, deepLinkQuery.isError])
|
||||
|
||||
useEffect(() => {
|
||||
const timers = refreshTimersRef.current
|
||||
@@ -446,9 +442,7 @@ export default function Logs() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized.current) {
|
||||
setStoreSearchQuery(debouncedSearchQuery)
|
||||
}
|
||||
setStoreSearchQuery(debouncedSearchQuery)
|
||||
}, [debouncedSearchQuery, setStoreSearchQuery])
|
||||
|
||||
const handleLogClick = useCallback((rowId: string) => {
|
||||
@@ -458,7 +452,7 @@ export default function Logs() {
|
||||
const handleNavigateNext = useCallback(() => {
|
||||
const idx = selectedLogIndexRef.current
|
||||
const currentLogs = logsRef.current
|
||||
if (idx < currentLogs.length - 1) {
|
||||
if (idx >= 0 && idx < currentLogs.length - 1) {
|
||||
shouldScrollIntoViewRef.current = true
|
||||
dispatch({ type: 'SELECT_LOG', logId: currentLogs[idx + 1].id })
|
||||
}
|
||||
@@ -484,12 +478,12 @@ export default function Logs() {
|
||||
const handleLogContextMenu = useCallback(
|
||||
(e: React.MouseEvent, rowId: string) => {
|
||||
e.preventDefault()
|
||||
const log = sortedLogs.find((l) => l.id === rowId) ?? null
|
||||
const log = logs.find((l) => l.id === rowId) ?? null
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY })
|
||||
setContextMenuLog(log)
|
||||
setContextMenuOpen(true)
|
||||
},
|
||||
[sortedLogs]
|
||||
[logs]
|
||||
)
|
||||
|
||||
const handleCopyExecutionId = useCallback(() => {
|
||||
@@ -547,7 +541,7 @@ export default function Logs() {
|
||||
}, [contextMenuLog])
|
||||
|
||||
const retryLog = useCallback(
|
||||
async (log: WorkflowLog | null) => {
|
||||
async (log: WorkflowLogRow | null) => {
|
||||
const workflowId = log?.workflow?.id || log?.workflowId
|
||||
const logId = log?.id
|
||||
if (!workflowId || !logId) return
|
||||
@@ -555,7 +549,7 @@ export default function Logs() {
|
||||
try {
|
||||
const detailLog = await queryClient.fetchQuery({
|
||||
queryKey: logKeys.detail(logId),
|
||||
queryFn: ({ signal }) => fetchLogDetail(logId, signal),
|
||||
queryFn: ({ signal }) => fetchLogDetail(logId, workspaceId, signal),
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
const input = extractRetryInput(detailLog)
|
||||
@@ -600,7 +594,8 @@ export default function Logs() {
|
||||
}
|
||||
}, [selectedLogId, selectedLogIndex])
|
||||
|
||||
const effectiveSidebarOpen = isSidebarOpen && selectedLogIndex !== -1
|
||||
const effectiveSidebarOpen =
|
||||
isSidebarOpen && (selectedLogIndex !== -1 || !!selectedDetailQuery.data)
|
||||
|
||||
const triggerVisualRefresh = useCallback(() => {
|
||||
setIsVisuallyRefreshing(true)
|
||||
@@ -676,13 +671,6 @@ export default function Logs() {
|
||||
debouncedSearchQuery,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialized.current) {
|
||||
isInitialized.current = true
|
||||
initializeFromURL()
|
||||
}
|
||||
}, [initializeFromURL])
|
||||
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
initializeFromURL()
|
||||
@@ -695,12 +683,11 @@ export default function Logs() {
|
||||
}, [initializeFromURL])
|
||||
|
||||
const loadMoreLogs = useCallback(() => {
|
||||
if (activeSort) return
|
||||
const { isFetching, hasNextPage, fetchNextPage } = logsQueryRef.current
|
||||
if (!isFetching && hasNextPage) {
|
||||
fetchNextPage()
|
||||
}
|
||||
}, [activeSort])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -753,7 +740,7 @@ export default function Logs() {
|
||||
|
||||
const rows: ResourceRow[] = useMemo(
|
||||
() =>
|
||||
sortedLogs.map((log) => {
|
||||
logs.map((log) => {
|
||||
const formattedDate = formatDate(log.createdAt)
|
||||
const displayStatus = getDisplayStatus(log.status)
|
||||
const isMothershipJob = log.trigger === 'mothership'
|
||||
@@ -804,7 +791,7 @@ export default function Logs() {
|
||||
},
|
||||
}
|
||||
}),
|
||||
[sortedLogs]
|
||||
[logs]
|
||||
)
|
||||
|
||||
const sidebarOverlay = (
|
||||
@@ -814,7 +801,7 @@ export default function Logs() {
|
||||
onClose={handleCloseSidebar}
|
||||
onNavigateNext={handleNavigateNext}
|
||||
onNavigatePrev={handleNavigatePrev}
|
||||
hasNext={selectedLogIndex < sortedLogs.length - 1}
|
||||
hasNext={selectedLogIndex >= 0 && selectedLogIndex < logs.length - 1}
|
||||
hasPrev={selectedLogIndex > 0}
|
||||
onRetryExecution={handleRetrySidebarExecution}
|
||||
isRetryPending={retryExecution.isPending}
|
||||
@@ -1121,7 +1108,7 @@ export default function Logs() {
|
||||
label: 'Export',
|
||||
icon: Download,
|
||||
onClick: handleExport,
|
||||
disabled: !userPermissions.canEdit || isExporting || sortedLogs.length === 0,
|
||||
disabled: !userPermissions.canEdit || isExporting || logs.length === 0,
|
||||
},
|
||||
{
|
||||
label: 'Notifications',
|
||||
@@ -1154,7 +1141,7 @@ export default function Logs() {
|
||||
handleExport,
|
||||
userPermissions.canEdit,
|
||||
isExporting,
|
||||
sortedLogs.length,
|
||||
logs.length,
|
||||
handleOpenNotificationSettings,
|
||||
]
|
||||
)
|
||||
@@ -1192,7 +1179,7 @@ export default function Logs() {
|
||||
onRowContextMenu={handleLogContextMenu}
|
||||
isLoading={!logsQuery.data}
|
||||
onLoadMore={loadMoreLogs}
|
||||
hasMore={!activeSort && (logsQuery.hasNextPage ?? false)}
|
||||
hasMore={logsQuery.hasNextPage ?? false}
|
||||
isLoadingMore={logsQuery.isFetchingNextPage}
|
||||
emptyMessage='No logs found'
|
||||
overlay={sidebarOverlay}
|
||||
@@ -1224,10 +1211,10 @@ export default function Logs() {
|
||||
hasActiveFilters={filtersActive}
|
||||
/>
|
||||
|
||||
{previewLogId !== null && activeLogQuery.data?.executionId && (
|
||||
{previewLogId !== null && previewDetailQuery.data?.executionId && (
|
||||
<ExecutionSnapshot
|
||||
executionId={activeLogQuery.data.executionId}
|
||||
traceSpans={activeLogQuery.data.executionData?.traceSpans}
|
||||
executionId={previewDetailQuery.data.executionId}
|
||||
traceSpans={previewDetailQuery.data.executionData?.traceSpans}
|
||||
isModal
|
||||
isOpen={previewLogId !== null}
|
||||
onClose={handleClosePreview}
|
||||
|
||||
@@ -2,9 +2,9 @@ import React from 'react'
|
||||
import { formatDuration } from '@sim/utils/formatting'
|
||||
import { format } from 'date-fns'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import type { WorkflowLogDetail } from '@/lib/api/contracts/logs'
|
||||
import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import type { WorkflowLog } from '@/stores/logs/filters/types'
|
||||
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
|
||||
|
||||
export const LOG_COLUMNS = {
|
||||
@@ -16,17 +16,6 @@ export const LOG_COLUMNS = {
|
||||
duration: { width: 'w-[20%]', minWidth: 'min-w-[100px]', label: 'Duration' },
|
||||
} as const
|
||||
|
||||
export type LogColumnKey = keyof typeof LOG_COLUMNS
|
||||
|
||||
export const LOG_COLUMN_ORDER: readonly LogColumnKey[] = [
|
||||
'workflow',
|
||||
'date',
|
||||
'status',
|
||||
'cost',
|
||||
'trigger',
|
||||
'duration',
|
||||
] as const
|
||||
|
||||
export const DELETED_WORKFLOW_LABEL = 'Deleted Workflow'
|
||||
export const DELETED_WORKFLOW_COLOR = 'var(--text-tertiary)'
|
||||
|
||||
@@ -180,218 +169,6 @@ export function parseDuration(log: LogWithDuration): number | null {
|
||||
return Number.isFinite(durationCandidate) ? durationCandidate : null
|
||||
}
|
||||
|
||||
interface TraceSpan {
|
||||
output?: Record<string, unknown>
|
||||
status?: string
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
interface BlockExecution {
|
||||
outputData?: unknown
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
interface LogWithExecutionData {
|
||||
executionData?: {
|
||||
finalOutput?: unknown
|
||||
traceSpans?: TraceSpan[]
|
||||
blockExecutions?: BlockExecution[]
|
||||
output?: unknown
|
||||
}
|
||||
output?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract output from various sources in execution data.
|
||||
* Checks multiple locations in priority order:
|
||||
* 1. executionData.finalOutput
|
||||
* 2. output (as string)
|
||||
* 3. executionData.traceSpans (iterates through spans)
|
||||
* 4. executionData.blockExecutions (last block)
|
||||
* 5. message (fallback)
|
||||
* @param log - Log object containing execution data
|
||||
* @returns Extracted output value or null
|
||||
*/
|
||||
export function extractOutput(log: LogWithExecutionData): unknown {
|
||||
let output: unknown = null
|
||||
|
||||
// Check finalOutput first
|
||||
if (log.executionData?.finalOutput !== undefined) {
|
||||
output = log.executionData.finalOutput
|
||||
}
|
||||
|
||||
// Check direct output field
|
||||
if (typeof log.output === 'string') {
|
||||
output = log.output
|
||||
} else if (log.executionData?.traceSpans && Array.isArray(log.executionData.traceSpans)) {
|
||||
// Search through trace spans
|
||||
const spans = log.executionData.traceSpans
|
||||
for (let i = spans.length - 1; i >= 0; i--) {
|
||||
const s = spans[i]
|
||||
if (s?.output && Object.keys(s.output).length > 0) {
|
||||
output = s.output
|
||||
break
|
||||
}
|
||||
const outputWithError = s?.output as Record<string, unknown> | undefined
|
||||
if (s?.status === 'error' && (outputWithError?.error || s?.error)) {
|
||||
output = outputWithError?.error || s.error
|
||||
break
|
||||
}
|
||||
}
|
||||
// Fallback to executionData.output
|
||||
if (!output && log.executionData?.output) {
|
||||
output = log.executionData.output
|
||||
}
|
||||
}
|
||||
|
||||
// Check block executions
|
||||
if (!output) {
|
||||
const blockExecutions = log.executionData?.blockExecutions
|
||||
if (Array.isArray(blockExecutions) && blockExecutions.length > 0) {
|
||||
const lastBlock = blockExecutions[blockExecutions.length - 1]
|
||||
output = lastBlock?.outputData || lastBlock?.errorMessage || null
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback to message
|
||||
if (!output) {
|
||||
output = log.message || null
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
/** Execution log cost breakdown */
|
||||
interface ExecutionCost {
|
||||
input: number
|
||||
output: number
|
||||
total: number
|
||||
}
|
||||
|
||||
/** Mapped execution log format for UI consumption */
|
||||
export interface ExecutionLog {
|
||||
id: string
|
||||
executionId: string
|
||||
startedAt: string
|
||||
level: string
|
||||
status: string
|
||||
trigger: string
|
||||
triggerUserId: string | null
|
||||
triggerInputs?: unknown
|
||||
outputs?: unknown
|
||||
errorMessage: string | null
|
||||
duration: number | null
|
||||
cost: ExecutionCost | null
|
||||
workflowName?: string
|
||||
workflowColor?: string
|
||||
hasPendingPause?: boolean
|
||||
}
|
||||
|
||||
/** Raw API log response structure */
|
||||
interface RawLogResponse extends LogWithDuration, LogWithExecutionData {
|
||||
id: string
|
||||
executionId: string
|
||||
startedAt?: string
|
||||
endedAt?: string
|
||||
createdAt?: string
|
||||
level?: string
|
||||
status?: string
|
||||
trigger?: string
|
||||
triggerUserId?: string | null
|
||||
error?: string
|
||||
cost?: {
|
||||
input?: number
|
||||
output?: number
|
||||
total?: number
|
||||
}
|
||||
workflowName?: string
|
||||
workflowColor?: string
|
||||
workflow?: {
|
||||
name?: string
|
||||
color?: string
|
||||
}
|
||||
hasPendingPause?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert raw API log response to ExecutionLog format.
|
||||
* @param log - Raw log response from API
|
||||
* @returns Formatted execution log
|
||||
*/
|
||||
export function mapToExecutionLog(log: RawLogResponse): ExecutionLog {
|
||||
const started = log.startedAt
|
||||
? new Date(log.startedAt)
|
||||
: log.endedAt
|
||||
? new Date(log.endedAt)
|
||||
: null
|
||||
|
||||
const startedAt =
|
||||
started && !Number.isNaN(started.getTime()) ? started.toISOString() : new Date().toISOString()
|
||||
|
||||
const duration = parseDuration(log)
|
||||
const output = extractOutput(log)
|
||||
|
||||
return {
|
||||
id: log.id,
|
||||
executionId: log.executionId,
|
||||
startedAt,
|
||||
level: log.level || 'info',
|
||||
status: log.status || 'completed',
|
||||
trigger: log.trigger || 'manual',
|
||||
triggerUserId: log.triggerUserId || null,
|
||||
triggerInputs: undefined,
|
||||
outputs: output || undefined,
|
||||
errorMessage: log.error || null,
|
||||
duration,
|
||||
cost: log.cost
|
||||
? {
|
||||
input: log.cost.input || 0,
|
||||
output: log.cost.output || 0,
|
||||
total: log.cost.total || 0,
|
||||
}
|
||||
: null,
|
||||
workflowName: log.workflowName || log.workflow?.name,
|
||||
workflowColor: log.workflowColor || log.workflow?.color,
|
||||
hasPendingPause: log.hasPendingPause === true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alternative version that uses createdAt as fallback for startedAt.
|
||||
* Used in some API responses.
|
||||
* @param log - Raw log response from API
|
||||
* @returns Formatted execution log
|
||||
*/
|
||||
export function mapToExecutionLogAlt(log: RawLogResponse): ExecutionLog {
|
||||
const duration = parseDuration(log)
|
||||
const output = extractOutput(log)
|
||||
|
||||
return {
|
||||
id: log.id,
|
||||
executionId: log.executionId,
|
||||
startedAt: log.createdAt || log.startedAt || new Date().toISOString(),
|
||||
level: log.level || 'info',
|
||||
status: log.status || 'completed',
|
||||
trigger: log.trigger || 'manual',
|
||||
triggerUserId: log.triggerUserId || null,
|
||||
triggerInputs: undefined,
|
||||
outputs: output || undefined,
|
||||
errorMessage: log.error || null,
|
||||
duration,
|
||||
cost: log.cost
|
||||
? {
|
||||
input: log.cost.input || 0,
|
||||
output: log.cost.output || 0,
|
||||
total: log.cost.total || 0,
|
||||
}
|
||||
: null,
|
||||
workflowName: log.workflow?.name,
|
||||
workflowColor: log.workflow?.color,
|
||||
hasPendingPause: log.hasPendingPause === true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format latency value for display in dashboard UI
|
||||
* @param ms - Latency in milliseconds (number)
|
||||
@@ -449,15 +226,15 @@ export const formatDate = (dateString: string) => {
|
||||
* Prefers the persisted `workflowInput` field (new logs), falls back to
|
||||
* reconstructing from `executionState.blockStates` (old logs).
|
||||
*/
|
||||
export function extractRetryInput(log: WorkflowLog): unknown | undefined {
|
||||
const execData = log.executionData as Record<string, unknown> | undefined
|
||||
export function extractRetryInput(log: WorkflowLogDetail): unknown | undefined {
|
||||
const execData = log.executionData
|
||||
if (!execData) return undefined
|
||||
|
||||
if (execData.workflowInput !== undefined) {
|
||||
return execData.workflowInput
|
||||
}
|
||||
|
||||
const executionState = execData.executionState as
|
||||
const executionState = (execData as Record<string, unknown>).executionState as
|
||||
| {
|
||||
blockStates?: Record<
|
||||
string,
|
||||
|
||||
+1
-1
@@ -346,7 +346,7 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
|
||||
try {
|
||||
setIsLoadingLogs(true)
|
||||
const data = await requestJson(listLogsContract, {
|
||||
query: { workspaceId, limit: 50, details: 'full' },
|
||||
query: { workspaceId, limit: 50 },
|
||||
})
|
||||
const items = data.data
|
||||
const mapped = items.map((l) => ({
|
||||
|
||||
@@ -15,22 +15,27 @@ import {
|
||||
type ExecutionSnapshotData,
|
||||
getDashboardStatsContract,
|
||||
getExecutionSnapshotContract,
|
||||
getLogByExecutionIdContract,
|
||||
getLogDetailContract,
|
||||
listLogsContract,
|
||||
type SegmentStats,
|
||||
type WorkflowLogData,
|
||||
type WorkflowLogDetail,
|
||||
type WorkflowLogSummary,
|
||||
type WorkflowStats,
|
||||
} from '@/lib/api/contracts/logs'
|
||||
import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters'
|
||||
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
|
||||
import type { TimeRange, WorkflowLog } from '@/stores/logs/filters/types'
|
||||
import type { TimeRange } from '@/stores/logs/filters/types'
|
||||
|
||||
export type { DashboardStatsResponse, SegmentStats, WorkflowStats }
|
||||
|
||||
export type LogSortBy = 'date' | 'duration' | 'cost' | 'status'
|
||||
export type LogSortOrder = 'asc' | 'desc'
|
||||
|
||||
export const logKeys = {
|
||||
all: ['logs'] as const,
|
||||
lists: () => [...logKeys.all, 'list'] as const,
|
||||
list: (workspaceId: string | undefined, filters: Omit<LogFilters, 'page'>) =>
|
||||
list: (workspaceId: string | undefined, filters: LogFilters) =>
|
||||
[...logKeys.lists(), workspaceId ?? '', filters] as const,
|
||||
details: () => [...logKeys.all, 'detail'] as const,
|
||||
detail: (logId: string | undefined) => [...logKeys.details(), logId ?? ''] as const,
|
||||
@@ -45,7 +50,7 @@ export const logKeys = {
|
||||
[...logKeys.executionSnapshots(), executionId ?? ''] as const,
|
||||
}
|
||||
|
||||
interface LogFilters {
|
||||
export interface LogFilters {
|
||||
timeRange: TimeRange
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
@@ -55,15 +60,14 @@ interface LogFilters {
|
||||
triggers: string[]
|
||||
searchQuery: string
|
||||
limit: number
|
||||
sortBy: LogSortBy
|
||||
sortOrder: LogSortOrder
|
||||
}
|
||||
|
||||
const toWorkflowLog = (log: WorkflowLogData): WorkflowLog => log as WorkflowLog
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
function applyFilterParams(
|
||||
params: URLSearchParams,
|
||||
filters: Omit<LogFilters, 'limit' | 'sortBy' | 'sortOrder'>
|
||||
): void {
|
||||
if (filters.level !== 'all') {
|
||||
params.set('level', filters.level)
|
||||
}
|
||||
@@ -100,61 +104,53 @@ function applyFilterParams(params: URLSearchParams, filters: Omit<LogFilters, 'l
|
||||
}
|
||||
}
|
||||
|
||||
function buildQueryParams(workspaceId: string, filters: LogFilters, page: number) {
|
||||
function buildListQuery(workspaceId: string, filters: LogFilters, cursor: string | null) {
|
||||
const params = new URLSearchParams()
|
||||
|
||||
applyFilterParams(params, filters)
|
||||
|
||||
return {
|
||||
workspaceId,
|
||||
limit: filters.limit,
|
||||
offset: (page - 1) * filters.limit,
|
||||
sortBy: filters.sortBy,
|
||||
sortOrder: filters.sortOrder,
|
||||
...(cursor ? { cursor } : {}),
|
||||
...Object.fromEntries(params.entries()),
|
||||
}
|
||||
}
|
||||
|
||||
interface LogsPage {
|
||||
logs: WorkflowLogSummary[]
|
||||
nextCursor: string | null
|
||||
}
|
||||
|
||||
async function fetchLogsPage(
|
||||
workspaceId: string,
|
||||
filters: LogFilters,
|
||||
page: number,
|
||||
cursor: string | null,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ logs: WorkflowLog[]; hasMore: boolean; nextPage: number | undefined }> {
|
||||
): Promise<LogsPage> {
|
||||
const apiData = await requestJson(listLogsContract, {
|
||||
query: buildQueryParams(workspaceId, filters, page),
|
||||
query: buildListQuery(workspaceId, filters, cursor),
|
||||
signal,
|
||||
})
|
||||
const hasMore = apiData.data.length === filters.limit && apiData.page < apiData.totalPages
|
||||
|
||||
return {
|
||||
logs: apiData.data.map(toWorkflowLog),
|
||||
hasMore,
|
||||
nextPage: hasMore ? page + 1 : undefined,
|
||||
logs: apiData.data,
|
||||
nextCursor: apiData.nextCursor,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchLogDetail(logId: string, signal?: AbortSignal): Promise<WorkflowLog> {
|
||||
export async function fetchLogDetail(
|
||||
logId: string,
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkflowLogDetail> {
|
||||
const { data } = await requestJson(getLogDetailContract, {
|
||||
params: { id: logId },
|
||||
query: { workspaceId },
|
||||
signal,
|
||||
})
|
||||
return toWorkflowLog(data)
|
||||
}
|
||||
|
||||
async function fetchLogByExecutionId(
|
||||
workspaceId: string,
|
||||
executionId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkflowLog | null> {
|
||||
const apiData = await requestJson(listLogsContract, {
|
||||
query: {
|
||||
workspaceId,
|
||||
executionId,
|
||||
details: 'full',
|
||||
limit: 1,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
return apiData.data?.[0] ? toWorkflowLog(apiData.data[0]) : null
|
||||
return data
|
||||
}
|
||||
|
||||
interface UseLogsListOptions {
|
||||
@@ -173,10 +169,10 @@ export function useLogsList(
|
||||
fetchLogsPage(workspaceId as string, filters, pageParam, signal),
|
||||
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
|
||||
refetchInterval: options?.refetchInterval ?? false,
|
||||
staleTime: 0,
|
||||
staleTime: 30 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) => lastPage.nextPage,
|
||||
initialPageParam: null as string | null,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -185,14 +181,18 @@ interface UseLogDetailOptions {
|
||||
refetchInterval?:
|
||||
| number
|
||||
| false
|
||||
| ((query: { state: { data?: WorkflowLog } }) => number | false | undefined)
|
||||
| ((query: { state: { data?: WorkflowLogDetail } }) => number | false | undefined)
|
||||
}
|
||||
|
||||
export function useLogDetail(logId: string | undefined, options?: UseLogDetailOptions) {
|
||||
export function useLogDetail(
|
||||
logId: string | undefined,
|
||||
workspaceId: string | undefined,
|
||||
options?: UseLogDetailOptions
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: logKeys.detail(logId),
|
||||
queryFn: ({ signal }) => fetchLogDetail(logId as string, signal),
|
||||
enabled: Boolean(logId) && (options?.enabled ?? true),
|
||||
queryFn: ({ signal }) => fetchLogDetail(logId as string, workspaceId as string, signal),
|
||||
enabled: Boolean(logId) && Boolean(workspaceId) && (options?.enabled ?? true),
|
||||
refetchInterval: options?.refetchInterval ?? false,
|
||||
staleTime: 30 * 1000,
|
||||
retry: (failureCount, err) =>
|
||||
@@ -200,42 +200,38 @@ export function useLogDetail(logId: string | undefined, options?: UseLogDetailOp
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up a workflow log by its `executionId` (the id stored on table workflow cells).
|
||||
* Returns the full log shape so the LogDetails sidebar can render directly without
|
||||
* an extra detail fetch.
|
||||
*/
|
||||
export function useLogByExecutionId(
|
||||
workspaceId: string | undefined,
|
||||
executionId: string | null | undefined
|
||||
) {
|
||||
const queryClient = useQueryClient()
|
||||
return useQuery({
|
||||
queryKey: logKeys.byExecution(workspaceId, executionId ?? undefined),
|
||||
queryFn: ({ signal }) =>
|
||||
fetchLogByExecutionId(workspaceId as string, executionId as string, signal),
|
||||
queryFn: async ({ signal }) => {
|
||||
const { data } = await requestJson(getLogByExecutionIdContract, {
|
||||
params: { executionId: executionId as string },
|
||||
query: { workspaceId: workspaceId as string },
|
||||
signal,
|
||||
})
|
||||
queryClient.setQueryData(logKeys.detail(data.id), data)
|
||||
return data
|
||||
},
|
||||
enabled: Boolean(workspaceId) && Boolean(executionId),
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetches log detail data on hover for instant panel rendering on click.
|
||||
*/
|
||||
export function prefetchLogDetail(queryClient: QueryClient, logId: string) {
|
||||
export function prefetchLogDetail(queryClient: QueryClient, logId: string, workspaceId: string) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: logKeys.detail(logId),
|
||||
queryFn: ({ signal }) => fetchLogDetail(logId, signal),
|
||||
queryFn: ({ signal }) => fetchLogDetail(logId, workspaceId, signal),
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches dashboard stats from the server-side aggregation endpoint.
|
||||
* Uses SQL aggregation for efficient computation without arbitrary limits.
|
||||
*/
|
||||
async function fetchDashboardStats(
|
||||
workspaceId: string,
|
||||
filters: Omit<LogFilters, 'limit'>,
|
||||
filters: Omit<LogFilters, 'limit' | 'sortBy' | 'sortOrder'>,
|
||||
signal?: AbortSignal
|
||||
): Promise<DashboardStatsResponse> {
|
||||
const params = new URLSearchParams()
|
||||
@@ -255,13 +251,9 @@ interface UseDashboardStatsOptions {
|
||||
refetchInterval?: number | false
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching dashboard stats using server-side aggregation.
|
||||
* No arbitrary limits - uses SQL aggregation for accurate metrics.
|
||||
*/
|
||||
export function useDashboardStats(
|
||||
workspaceId: string | undefined,
|
||||
filters: Omit<LogFilters, 'limit'>,
|
||||
filters: Omit<LogFilters, 'limit' | 'sortBy' | 'sortOrder'>,
|
||||
options?: UseDashboardStatsOptions
|
||||
) {
|
||||
return useQuery({
|
||||
@@ -269,7 +261,7 @@ export function useDashboardStats(
|
||||
queryFn: ({ signal }) => fetchDashboardStats(workspaceId as string, filters, signal),
|
||||
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
|
||||
refetchInterval: options?.refetchInterval ?? false,
|
||||
staleTime: 0,
|
||||
staleTime: 30 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
@@ -296,12 +288,10 @@ export function useExecutionSnapshot(executionId: string | undefined) {
|
||||
queryKey: logKeys.executionSnapshot(executionId),
|
||||
queryFn: ({ signal }) => fetchExecutionSnapshot(executionId as string, signal),
|
||||
enabled: Boolean(executionId),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - execution snapshots don't change
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
type LogsPage = { logs: WorkflowLog[]; hasMore: boolean; nextPage: number | undefined }
|
||||
|
||||
export function useCancelExecution() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
@@ -325,29 +315,47 @@ export function useCancelExecution() {
|
||||
queryKey: logKeys.lists(),
|
||||
})
|
||||
|
||||
let affectedLogId: string | null = null
|
||||
queryClient.setQueriesData<InfiniteData<LogsPage>>({ queryKey: logKeys.lists() }, (old) => {
|
||||
if (!old) return old
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
logs: page.logs.map((log) =>
|
||||
log.executionId === executionId ? { ...log, status: 'cancelling' } : log
|
||||
),
|
||||
logs: page.logs.map((log) => {
|
||||
if (log.executionId !== executionId) return log
|
||||
affectedLogId = log.id
|
||||
return { ...log, status: 'cancelling' }
|
||||
}),
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
return { previousQueries }
|
||||
let previousDetail: WorkflowLogDetail | undefined
|
||||
if (affectedLogId) {
|
||||
previousDetail = queryClient.getQueryData<WorkflowLogDetail>(logKeys.detail(affectedLogId))
|
||||
if (previousDetail) {
|
||||
queryClient.setQueryData<WorkflowLogDetail>(logKeys.detail(affectedLogId), {
|
||||
...previousDetail,
|
||||
status: 'cancelling',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { previousQueries, affectedLogId, previousDetail }
|
||||
},
|
||||
onError: (_err, _variables, context) => {
|
||||
for (const [queryKey, data] of context?.previousQueries ?? []) {
|
||||
queryClient.setQueryData(queryKey, data)
|
||||
}
|
||||
if (context?.affectedLogId && context.previousDetail !== undefined) {
|
||||
queryClient.setQueryData(logKeys.detail(context.affectedLogId), context.previousDetail)
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: logKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: logKeys.details() })
|
||||
queryClient.invalidateQueries({ queryKey: logKeys.byExecutionAll() })
|
||||
queryClient.invalidateQueries({ queryKey: logKeys.stats() })
|
||||
},
|
||||
})
|
||||
@@ -367,9 +375,6 @@ export function useRetryExecution() {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data.error || 'Failed to retry execution')
|
||||
}
|
||||
// The ReadableStream is lazy — start() only runs when read.
|
||||
// Read one chunk to trigger execution, then cancel. Execution continues
|
||||
// server-side after client disconnect.
|
||||
const reader = res.body?.getReader()
|
||||
if (reader) {
|
||||
await reader.read()
|
||||
@@ -380,6 +385,7 @@ export function useRetryExecution() {
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: logKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: logKeys.details() })
|
||||
queryClient.invalidateQueries({ queryKey: logKeys.byExecutionAll() })
|
||||
queryClient.invalidateQueries({ queryKey: logKeys.stats() })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -34,10 +34,18 @@ const logFilterQuerySchema = z.object({
|
||||
durationValue: z.coerce.number().optional(),
|
||||
})
|
||||
|
||||
export const logSortBySchema = z.enum(['date', 'duration', 'cost', 'status']).default('date')
|
||||
export const logSortOrderSchema = z.enum(['asc', 'desc']).default('desc')
|
||||
|
||||
export const listLogsQuerySchema = logFilterQuerySchema.extend({
|
||||
details: z.enum(['basic', 'full']).optional().default('basic'),
|
||||
limit: z.coerce.number().optional().default(100),
|
||||
offset: z.coerce.number().optional().default(0),
|
||||
cursor: z.string().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).optional().default(100),
|
||||
sortBy: logSortBySchema,
|
||||
sortOrder: logSortOrderSchema,
|
||||
})
|
||||
|
||||
export const logDetailQuerySchema = z.object({
|
||||
workspaceId: z.string().min(1),
|
||||
})
|
||||
|
||||
export const statsQueryParamsSchema = logFilterQuerySchema.extend({
|
||||
@@ -58,55 +66,196 @@ const workflowSummarySchema = z
|
||||
})
|
||||
.partial()
|
||||
|
||||
const fileSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
size: z.number(),
|
||||
type: z.string(),
|
||||
url: z.string(),
|
||||
key: z.string(),
|
||||
uploadedAt: z.string(),
|
||||
expiresAt: z.string(),
|
||||
storageProvider: z.enum(['s3', 'blob', 'local']).optional(),
|
||||
bucketName: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
export const workflowLogSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
workflowId: z.string().nullable(),
|
||||
executionId: z.string().nullable().optional(),
|
||||
deploymentVersionId: z.string().nullable().optional(),
|
||||
deploymentVersion: z.number().nullable().optional(),
|
||||
deploymentVersionName: z.string().nullable().optional(),
|
||||
level: z.string(),
|
||||
status: z.string().nullable().optional(),
|
||||
duration: z.string().nullable(),
|
||||
trigger: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
workflow: workflowSummarySchema.nullable().optional(),
|
||||
jobTitle: z.string().nullable().optional(),
|
||||
files: z.array(fileSchema).optional(),
|
||||
cost: z.unknown().optional(),
|
||||
hasPendingPause: z.boolean().nullable().optional(),
|
||||
pauseSummary: z.unknown().optional(),
|
||||
executionData: z.unknown().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
export type WorkflowLogData = z.output<typeof workflowLogSchema>
|
||||
|
||||
export const logsResponseSchema = z.object({
|
||||
data: z.array(workflowLogSchema),
|
||||
total: z.number(),
|
||||
page: z.number(),
|
||||
pageSize: z.number(),
|
||||
totalPages: z.number(),
|
||||
const fileSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
size: z.number(),
|
||||
type: z.string(),
|
||||
url: z.string(),
|
||||
key: z.string(),
|
||||
uploadedAt: z.string(),
|
||||
expiresAt: z.string(),
|
||||
storageProvider: z.enum(['s3', 'blob', 'local']).optional(),
|
||||
bucketName: z.string().optional(),
|
||||
})
|
||||
|
||||
export type LogsResponse = z.output<typeof logsResponseSchema>
|
||||
const tokenBreakdownSchema = z
|
||||
.object({
|
||||
total: z.number().optional(),
|
||||
input: z.number().optional(),
|
||||
output: z.number().optional(),
|
||||
prompt: z.number().optional(),
|
||||
completion: z.number().optional(),
|
||||
})
|
||||
.partial()
|
||||
|
||||
const modelCostSchema = z
|
||||
.object({
|
||||
input: z.number().optional(),
|
||||
output: z.number().optional(),
|
||||
total: z.number().optional(),
|
||||
tokens: tokenBreakdownSchema.optional(),
|
||||
})
|
||||
.partial()
|
||||
|
||||
const costSummarySchema = z
|
||||
.object({
|
||||
total: z.number().optional(),
|
||||
input: z.number().optional(),
|
||||
output: z.number().optional(),
|
||||
tokens: tokenBreakdownSchema.optional(),
|
||||
models: z.record(z.string(), modelCostSchema).optional(),
|
||||
pricing: z
|
||||
.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
cachedInput: z.number().optional(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.partial()
|
||||
|
||||
const pauseSummarySchema = z.object({
|
||||
status: z.string().nullable(),
|
||||
total: z.number(),
|
||||
resumed: z.number(),
|
||||
})
|
||||
|
||||
const blockExecutionSchema = z.object({
|
||||
id: z.string(),
|
||||
blockId: z.string(),
|
||||
blockName: z.string(),
|
||||
blockType: z.string(),
|
||||
startedAt: z.string(),
|
||||
endedAt: z.string(),
|
||||
durationMs: z.number(),
|
||||
status: z.enum(['success', 'error', 'skipped']),
|
||||
errorMessage: z.string().optional(),
|
||||
errorStackTrace: z.string().optional(),
|
||||
inputData: z.unknown(),
|
||||
outputData: z.unknown(),
|
||||
cost: costSummarySchema.optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
|
||||
const toolCallSchema = z
|
||||
.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
arguments: z.unknown().optional(),
|
||||
result: z.unknown().optional(),
|
||||
error: z.string().optional(),
|
||||
startTime: z.string().optional(),
|
||||
endTime: z.string().optional(),
|
||||
duration: z.number().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
type TraceSpan = {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
duration?: number
|
||||
durationMs?: number
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
status?: string
|
||||
blockId?: string
|
||||
input?: unknown
|
||||
output?: unknown
|
||||
tokens?: number | { total?: number; input?: number; output?: number }
|
||||
relativeStartMs?: number
|
||||
toolCalls?: Array<z.output<typeof toolCallSchema>>
|
||||
children?: TraceSpan[]
|
||||
}
|
||||
|
||||
const traceSpanSchema: z.ZodType<TraceSpan> = z.lazy(() =>
|
||||
z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
duration: z.number().optional(),
|
||||
durationMs: z.number().optional(),
|
||||
startTime: z.string().optional(),
|
||||
endTime: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
blockId: z.string().optional(),
|
||||
input: z.unknown().optional(),
|
||||
output: z.unknown().optional(),
|
||||
tokens: z
|
||||
.union([
|
||||
z.number(),
|
||||
z
|
||||
.object({
|
||||
total: z.number().optional(),
|
||||
input: z.number().optional(),
|
||||
output: z.number().optional(),
|
||||
})
|
||||
.partial(),
|
||||
])
|
||||
.optional(),
|
||||
relativeStartMs: z.number().optional(),
|
||||
toolCalls: z.array(toolCallSchema).optional(),
|
||||
children: z.array(traceSpanSchema).optional(),
|
||||
})
|
||||
.passthrough()
|
||||
)
|
||||
|
||||
const executionDataDetailSchema = z
|
||||
.object({
|
||||
totalDuration: z.number().nullable().optional(),
|
||||
enhanced: z.literal(true).optional(),
|
||||
traceSpans: z.array(traceSpanSchema).optional(),
|
||||
blockExecutions: z.array(blockExecutionSchema).optional(),
|
||||
finalOutput: z.unknown().optional(),
|
||||
workflowInput: z.unknown().optional(),
|
||||
blockInput: z.record(z.string(), z.unknown()).optional(),
|
||||
trigger: z.unknown().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
export const workflowLogSummarySchema = z.object({
|
||||
id: z.string(),
|
||||
workflowId: z.string().nullable(),
|
||||
executionId: z.string().nullable(),
|
||||
deploymentVersionId: z.string().nullable(),
|
||||
deploymentVersion: z.number().nullable(),
|
||||
deploymentVersionName: z.string().nullable(),
|
||||
level: z.string(),
|
||||
status: z.string().nullable(),
|
||||
duration: z.string().nullable(),
|
||||
trigger: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
workflow: workflowSummarySchema.nullable(),
|
||||
jobTitle: z.string().nullable(),
|
||||
cost: costSummarySchema.nullable(),
|
||||
pauseSummary: pauseSummarySchema,
|
||||
hasPendingPause: z.boolean(),
|
||||
})
|
||||
|
||||
export const workflowLogDetailSchema = workflowLogSummarySchema.extend({
|
||||
executionData: executionDataDetailSchema,
|
||||
files: z.array(fileSchema).nullable(),
|
||||
})
|
||||
|
||||
export type WorkflowLogSummary = z.output<typeof workflowLogSummarySchema>
|
||||
export type WorkflowLogDetail = z.output<typeof workflowLogDetailSchema>
|
||||
|
||||
/**
|
||||
* A row that may be either a list-view summary or a fully loaded detail. Used by
|
||||
* UI surfaces that render the same log before and after its detail query resolves.
|
||||
*/
|
||||
export type WorkflowLogRow = WorkflowLogSummary &
|
||||
Partial<Pick<WorkflowLogDetail, 'executionData' | 'files'>>
|
||||
|
||||
export const listLogsResponseSchema = z.object({
|
||||
data: z.array(workflowLogSummarySchema),
|
||||
nextCursor: z.string().nullable(),
|
||||
})
|
||||
|
||||
export type ListLogsResponse = z.output<typeof listLogsResponseSchema>
|
||||
|
||||
export const segmentStatsSchema = z.object({
|
||||
timestamp: z.string(),
|
||||
@@ -179,7 +328,7 @@ export const listLogsContract = defineRouteContract({
|
||||
query: listLogsQuerySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: logsResponseSchema,
|
||||
schema: listLogsResponseSchema,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -187,10 +336,24 @@ export const getLogDetailContract = defineRouteContract({
|
||||
method: 'GET',
|
||||
path: '/api/logs/[id]',
|
||||
params: logIdParamsSchema,
|
||||
query: logDetailQuerySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: z.object({
|
||||
data: workflowLogSchema,
|
||||
data: workflowLogDetailSchema,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
export const getLogByExecutionIdContract = defineRouteContract({
|
||||
method: 'GET',
|
||||
path: '/api/logs/by-execution/[executionId]',
|
||||
params: executionIdParamsSchema,
|
||||
query: logDetailQuerySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: z.object({
|
||||
data: workflowLogDetailSchema,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
jobExecutionLogs,
|
||||
pausedExecutions,
|
||||
permissions,
|
||||
workflow,
|
||||
workflowDeploymentVersion,
|
||||
workflowExecutionLogs,
|
||||
} from '@sim/db/schema'
|
||||
import { and, eq, type SQL } from 'drizzle-orm'
|
||||
|
||||
type LookupColumn = 'id' | 'executionId'
|
||||
|
||||
interface FetchLogDetailArgs {
|
||||
userId: string
|
||||
workspaceId: string
|
||||
lookupColumn: LookupColumn
|
||||
lookupValue: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared loader for the workflow-log detail shape returned by the by-id and
|
||||
* by-execution routes. Returns `null` when no matching row exists in either
|
||||
* the workflow-execution or job-execution tables for this user + workspace.
|
||||
*/
|
||||
export async function fetchLogDetail({
|
||||
userId,
|
||||
workspaceId,
|
||||
lookupColumn,
|
||||
lookupValue,
|
||||
}: FetchLogDetailArgs) {
|
||||
const workflowMatch: SQL =
|
||||
lookupColumn === 'id'
|
||||
? eq(workflowExecutionLogs.id, lookupValue)
|
||||
: eq(workflowExecutionLogs.executionId, lookupValue)
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: workflowExecutionLogs.id,
|
||||
workflowId: workflowExecutionLogs.workflowId,
|
||||
executionId: workflowExecutionLogs.executionId,
|
||||
deploymentVersionId: workflowExecutionLogs.deploymentVersionId,
|
||||
level: workflowExecutionLogs.level,
|
||||
status: workflowExecutionLogs.status,
|
||||
trigger: workflowExecutionLogs.trigger,
|
||||
startedAt: workflowExecutionLogs.startedAt,
|
||||
endedAt: workflowExecutionLogs.endedAt,
|
||||
totalDurationMs: workflowExecutionLogs.totalDurationMs,
|
||||
executionData: workflowExecutionLogs.executionData,
|
||||
cost: workflowExecutionLogs.cost,
|
||||
files: workflowExecutionLogs.files,
|
||||
createdAt: workflowExecutionLogs.createdAt,
|
||||
workflowName: workflow.name,
|
||||
workflowDescription: workflow.description,
|
||||
workflowColor: workflow.color,
|
||||
workflowFolderId: workflow.folderId,
|
||||
workflowUserId: workflow.userId,
|
||||
workflowWorkspaceId: workflow.workspaceId,
|
||||
workflowCreatedAt: workflow.createdAt,
|
||||
workflowUpdatedAt: workflow.updatedAt,
|
||||
deploymentVersion: workflowDeploymentVersion.version,
|
||||
deploymentVersionName: workflowDeploymentVersion.name,
|
||||
pausedStatus: pausedExecutions.status,
|
||||
pausedTotalPauseCount: pausedExecutions.totalPauseCount,
|
||||
pausedResumedCount: pausedExecutions.resumedCount,
|
||||
})
|
||||
.from(workflowExecutionLogs)
|
||||
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
|
||||
.leftJoin(
|
||||
workflowDeploymentVersion,
|
||||
eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId)
|
||||
)
|
||||
.leftJoin(pausedExecutions, eq(pausedExecutions.executionId, workflowExecutionLogs.executionId))
|
||||
.innerJoin(
|
||||
permissions,
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workflowExecutionLogs.workspaceId),
|
||||
eq(permissions.userId, userId)
|
||||
)
|
||||
)
|
||||
.where(and(workflowMatch, eq(workflowExecutionLogs.workspaceId, workspaceId)))
|
||||
.limit(1)
|
||||
|
||||
const log = rows[0]
|
||||
|
||||
if (log) {
|
||||
const workflowSummary = log.workflowId
|
||||
? {
|
||||
id: log.workflowId,
|
||||
name: log.workflowName,
|
||||
description: log.workflowDescription,
|
||||
color: log.workflowColor,
|
||||
folderId: log.workflowFolderId,
|
||||
userId: log.workflowUserId,
|
||||
workspaceId: log.workflowWorkspaceId,
|
||||
createdAt: log.workflowCreatedAt?.toISOString() ?? null,
|
||||
updatedAt: log.workflowUpdatedAt?.toISOString() ?? null,
|
||||
}
|
||||
: null
|
||||
|
||||
const totalPauseCount = Number(log.pausedTotalPauseCount ?? 0)
|
||||
const resumedCount = Number(log.pausedResumedCount ?? 0)
|
||||
const hasPendingPause =
|
||||
(totalPauseCount > 0 && resumedCount < totalPauseCount) ||
|
||||
(log.pausedStatus !== null && log.pausedStatus !== 'fully_resumed')
|
||||
|
||||
return {
|
||||
id: log.id,
|
||||
workflowId: log.workflowId,
|
||||
executionId: log.executionId,
|
||||
deploymentVersionId: log.deploymentVersionId,
|
||||
deploymentVersion: log.deploymentVersion ?? null,
|
||||
deploymentVersionName: log.deploymentVersionName ?? null,
|
||||
level: log.level,
|
||||
status: log.status,
|
||||
duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null,
|
||||
trigger: log.trigger,
|
||||
createdAt: log.startedAt.toISOString(),
|
||||
workflow: workflowSummary,
|
||||
jobTitle: null,
|
||||
cost: log.cost ?? null,
|
||||
pauseSummary: {
|
||||
status: log.pausedStatus ?? null,
|
||||
total: totalPauseCount,
|
||||
resumed: resumedCount,
|
||||
},
|
||||
hasPendingPause,
|
||||
executionData: {
|
||||
totalDuration: log.totalDurationMs,
|
||||
...((log.executionData as Record<string, unknown> | null) ?? {}),
|
||||
enhanced: true as const,
|
||||
},
|
||||
files: log.files ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
const jobMatch: SQL =
|
||||
lookupColumn === 'id'
|
||||
? eq(jobExecutionLogs.id, lookupValue)
|
||||
: eq(jobExecutionLogs.executionId, lookupValue)
|
||||
|
||||
const jobRows = await db
|
||||
.select({
|
||||
id: jobExecutionLogs.id,
|
||||
executionId: jobExecutionLogs.executionId,
|
||||
level: jobExecutionLogs.level,
|
||||
status: jobExecutionLogs.status,
|
||||
trigger: jobExecutionLogs.trigger,
|
||||
startedAt: jobExecutionLogs.startedAt,
|
||||
endedAt: jobExecutionLogs.endedAt,
|
||||
totalDurationMs: jobExecutionLogs.totalDurationMs,
|
||||
executionData: jobExecutionLogs.executionData,
|
||||
cost: jobExecutionLogs.cost,
|
||||
createdAt: jobExecutionLogs.createdAt,
|
||||
})
|
||||
.from(jobExecutionLogs)
|
||||
.innerJoin(
|
||||
permissions,
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, jobExecutionLogs.workspaceId),
|
||||
eq(permissions.userId, userId)
|
||||
)
|
||||
)
|
||||
.where(and(jobMatch, eq(jobExecutionLogs.workspaceId, workspaceId)))
|
||||
.limit(1)
|
||||
|
||||
const jobLog = jobRows[0]
|
||||
if (!jobLog) return null
|
||||
|
||||
const execData = (jobLog.executionData as Record<string, unknown> | null) ?? {}
|
||||
return {
|
||||
id: jobLog.id,
|
||||
workflowId: null,
|
||||
executionId: jobLog.executionId,
|
||||
deploymentVersionId: null,
|
||||
deploymentVersion: null,
|
||||
deploymentVersionName: null,
|
||||
level: jobLog.level,
|
||||
status: jobLog.status,
|
||||
duration: jobLog.totalDurationMs ? `${jobLog.totalDurationMs}ms` : null,
|
||||
trigger: jobLog.trigger,
|
||||
createdAt: jobLog.startedAt.toISOString(),
|
||||
workflow: null,
|
||||
jobTitle: ((execData.trigger as Record<string, unknown> | undefined)?.source as string) ?? null,
|
||||
cost: jobLog.cost ?? null,
|
||||
pauseSummary: { status: null, total: 0, resumed: 0 },
|
||||
hasPendingPause: false,
|
||||
executionData: {
|
||||
totalDuration: jobLog.totalDurationMs,
|
||||
...execData,
|
||||
enhanced: true as const,
|
||||
},
|
||||
files: null,
|
||||
}
|
||||
}
|
||||
@@ -1,113 +1,3 @@
|
||||
import type { ProviderTiming, TokenInfo, ToolCall, TraceSpan } from '@/lib/logs/types'
|
||||
|
||||
export type { ProviderTiming, TokenInfo, ToolCall, TraceSpan }
|
||||
|
||||
export interface WorkflowData {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
color: string
|
||||
state: any
|
||||
}
|
||||
|
||||
export interface ToolCallMetadata {
|
||||
toolCalls?: ToolCall[]
|
||||
}
|
||||
|
||||
export interface CostMetadata {
|
||||
models?: Record<
|
||||
string,
|
||||
{
|
||||
input: number
|
||||
output: number
|
||||
total: number
|
||||
tokens?: {
|
||||
input?: number
|
||||
output?: number
|
||||
prompt?: number
|
||||
completion?: number
|
||||
total?: number
|
||||
}
|
||||
}
|
||||
>
|
||||
input?: number
|
||||
output?: number
|
||||
total?: number
|
||||
tokens?: {
|
||||
input?: number
|
||||
output?: number
|
||||
prompt?: number
|
||||
completion?: number
|
||||
total?: number
|
||||
}
|
||||
pricing?: {
|
||||
input: number
|
||||
output: number
|
||||
cachedInput?: number
|
||||
updatedAt: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface WorkflowLog {
|
||||
id: string
|
||||
workflowId: string | null
|
||||
executionId?: string | null
|
||||
deploymentVersion?: number | null
|
||||
deploymentVersionName?: string | null
|
||||
level: string
|
||||
status?: string | null
|
||||
duration: string | null
|
||||
trigger: string | null
|
||||
createdAt: string
|
||||
workflow?: WorkflowData | null
|
||||
jobTitle?: string | null
|
||||
files?: Array<{
|
||||
id: string
|
||||
name: string
|
||||
size: number
|
||||
type: string
|
||||
url: string
|
||||
key: string
|
||||
uploadedAt: string
|
||||
expiresAt: string
|
||||
storageProvider?: 's3' | 'blob' | 'local'
|
||||
bucketName?: string
|
||||
}>
|
||||
cost?: CostMetadata
|
||||
hasPendingPause?: boolean
|
||||
executionData?: ToolCallMetadata & {
|
||||
traceSpans?: TraceSpan[]
|
||||
totalDuration?: number
|
||||
blockInput?: Record<string, unknown>
|
||||
enhanced?: boolean
|
||||
|
||||
blockExecutions?: Array<{
|
||||
id: string
|
||||
blockId: string
|
||||
blockName: string
|
||||
blockType: string
|
||||
startedAt: string
|
||||
endedAt: string
|
||||
durationMs: number
|
||||
status: 'success' | 'error' | 'skipped'
|
||||
errorMessage?: string
|
||||
errorStackTrace?: string
|
||||
inputData: unknown
|
||||
outputData: unknown
|
||||
cost?: CostMetadata
|
||||
metadata: Record<string, unknown>
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
export interface LogsResponse {
|
||||
data: WorkflowLog[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export type TimeRange =
|
||||
| 'Past 30 minutes'
|
||||
| 'Past hour'
|
||||
@@ -129,6 +19,7 @@ export type LogLevel =
|
||||
| 'cancelled'
|
||||
| 'all'
|
||||
| (string & {})
|
||||
|
||||
/** Core trigger types for workflow execution */
|
||||
export const CORE_TRIGGER_TYPES = [
|
||||
'manual',
|
||||
|
||||
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
|
||||
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
|
||||
|
||||
const BASELINE = {
|
||||
totalRoutes: 725,
|
||||
zodRoutes: 725,
|
||||
totalRoutes: 726,
|
||||
zodRoutes: 726,
|
||||
nonZodRoutes: 0,
|
||||
} as const
|
||||
|
||||
|
||||
Reference in New Issue
Block a user