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:
Waleed
2026-05-04 16:18:44 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 6d044a924c
commit 029ac9fb05
16 changed files with 1144 additions and 1384 deletions
+25 -172
View File
@@ -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 })
}
)
File diff suppressed because it is too large Load Diff
@@ -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(
@@ -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>
@@ -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,
@@ -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) => ({
+86 -80
View File
@@ -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() })
},
})
+215 -52
View File
@@ -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,
}),
},
})
+197
View File
@@ -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 -110
View File
@@ -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',
+2 -2
View File
@@ -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