diff --git a/apps/sim/app/api/logs/[id]/route.ts b/apps/sim/app/api/logs/[id]/route.ts index 575b0867b1a..75f7378db20 100644 --- a/apps/sim/app/api/logs/[id]/route.ts +++ b/apps/sim/app/api/logs/[id]/route.ts @@ -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 }) - } + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const execData = jobLog.executionData as Record | 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, - } + const parsed = await parseRequest(getLogDetailContract, request, context) + if (!parsed.success) return parsed.response - return NextResponse.json({ data: response }) - } + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query - 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 data = await fetchLogDetail({ + userId: session.user.id, + workspaceId, + lookupColumn: 'id', + lookupValue: id, + }) - 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, - } + if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 }) - return NextResponse.json({ data: response }) - } catch (error: any) { - logger.error(`[${requestId}] log details fetch error`, error) - return NextResponse.json({ error: error.message }, { status: 500 }) - } + logger.debug('Fetched log detail', { id, workspaceId }) + return NextResponse.json({ data }) } ) diff --git a/apps/sim/app/api/logs/by-execution/[executionId]/route.ts b/apps/sim/app/api/logs/by-execution/[executionId]/route.ts new file mode 100644 index 00000000000..172a77506cc --- /dev/null +++ b/apps/sim/app/api/logs/by-execution/[executionId]/route.ts @@ -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 }) + } +) diff --git a/apps/sim/app/api/logs/route.ts b/apps/sim/app/api/logs/route.ts index 27b071be0f3..73dcd600a24 100644 --- a/apps/sim/app/api/logs/route.ts +++ b/apps/sim/app/api/logs/route.ts @@ -10,6 +10,7 @@ import { import { createLogger } from '@sim/logger' import { and, + asc, desc, eq, gt, @@ -24,582 +25,440 @@ import { type SQL, sql, } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { listLogsQuerySchema } from '@/lib/api/contracts/logs' -import { isZodError } from '@/lib/api/server' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { listLogsContract, type WorkflowLogSummary } 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 { buildFilterConditions } from '@/lib/logs/filters' const logger = createLogger('LogsAPI') -export const revalidate = 0 +type SortBy = 'date' | 'duration' | 'cost' | 'status' +type SortOrder = 'asc' | 'desc' -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() +interface CursorData { + v: string | number | null + id: string +} + +function encodeCursor(data: CursorData): string { + return Buffer.from(JSON.stringify(data)).toString('base64') +} +function decodeCursor(cursor: string): CursorData | null { try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized logs access attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const parsed = JSON.parse(Buffer.from(cursor, 'base64').toString()) + if (typeof parsed?.id !== 'string') return null + return parsed as CursorData + } catch { + return null + } +} + +export const GET = withRouteHandler(async (request: NextRequest) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userId = session.user.id + + const parsed = await parseRequest(listLogsContract, request, {}) + if (!parsed.success) return parsed.response + + const params = parsed.data.query + const sortBy = params.sortBy as SortBy + const sortOrder = params.sortOrder as SortOrder + const cursor = params.cursor ? decodeCursor(params.cursor) : null + + const workflowSortExpr: SQL = (() => { + switch (sortBy) { + case 'duration': + return sql`${workflowExecutionLogs.totalDurationMs}` + case 'cost': + return sql`(${workflowExecutionLogs.cost}->>'total')::numeric` + case 'status': + return sql`${workflowExecutionLogs.status}` + default: + return sql`${workflowExecutionLogs.startedAt}` } + })() + + const jobSortExpr: SQL = (() => { + switch (sortBy) { + case 'duration': + return sql`${jobExecutionLogs.totalDurationMs}` + case 'cost': + return sql`(${jobExecutionLogs.cost}->>'total')::numeric` + case 'status': + return sql`${jobExecutionLogs.status}` + default: + return sql`${jobExecutionLogs.startedAt}` + } + })() + + const dir = sortOrder === 'asc' ? asc : desc + const nullsLast = sql`NULLS LAST` + const orderByClause = (expr: SQL): SQL => sql`${dir(expr)} ${nullsLast}` + + const buildCursorCondition = (sortExpr: unknown, idCol: unknown): SQL | undefined => { + if (!cursor) return undefined + const v = cursor.v + const id = cursor.id + const cmp = sortOrder === 'asc' ? sql`>` : sql`<` + if (v === null) { + return sql`(${sortExpr} IS NULL AND ${idCol} ${cmp} ${id})` + } + return sql`((${sortExpr} IS NOT NULL AND ${sortExpr} ${cmp} ${v}) OR (${sortExpr} = ${v} AND ${idCol} ${cmp} ${id}) OR ${sortExpr} IS NULL)` + } - const userId = session.user.id - - try { - const { searchParams } = new URL(request.url) - const params = listLogsQuerySchema.parse(Object.fromEntries(searchParams.entries())) - - const selectColumns = - params.details === 'full' - ? { - 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, - pausedStatus: pausedExecutions.status, - pausedTotalPauseCount: pausedExecutions.totalPauseCount, - pausedResumedCount: pausedExecutions.resumedCount, - deploymentVersion: workflowDeploymentVersion.version, - deploymentVersionName: workflowDeploymentVersion.name, - } - : { - 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: sql`NULL`, - cost: workflowExecutionLogs.cost, - files: sql`NULL`, - 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, - pausedStatus: pausedExecutions.status, - pausedTotalPauseCount: pausedExecutions.totalPauseCount, - pausedResumedCount: pausedExecutions.resumedCount, - deploymentVersion: workflowDeploymentVersion.version, - deploymentVersionName: sql`NULL`, - } - - const workspaceFilter = eq(workflowExecutionLogs.workspaceId, params.workspaceId) - - const baseQuery = db - .select(selectColumns) - .from(workflowExecutionLogs) - .leftJoin( - pausedExecutions, - eq(pausedExecutions.executionId, workflowExecutionLogs.executionId) - ) - .leftJoin( - workflowDeploymentVersion, - eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId) - ) - .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflowExecutionLogs.workspaceId), - eq(permissions.userId, userId) - ) - ) + const fetchSize = params.limit + 1 - let conditions: SQL | undefined + // Build workflow log conditions + const workflowConditions: SQL[] = [eq(workflowExecutionLogs.workspaceId, params.workspaceId)] - if (params.level && params.level !== 'all') { - const levels = params.level.split(',').filter(Boolean) - const levelConditions: SQL[] = [] + if (params.level && params.level !== 'all') { + const levels = params.level.split(',').filter(Boolean) + const levelConditions: SQL[] = [] - for (const level of levels) { - if (level === 'error') { - levelConditions.push(eq(workflowExecutionLogs.level, 'error')) - } else if (level === 'info') { - const condition = and( - eq(workflowExecutionLogs.level, 'info'), - isNotNull(workflowExecutionLogs.endedAt) - ) - if (condition) levelConditions.push(condition) - } else if (level === 'running') { - const condition = and( - eq(workflowExecutionLogs.level, 'info'), - isNull(workflowExecutionLogs.endedAt) - ) - if (condition) levelConditions.push(condition) - } else if (level === 'pending') { - const condition = and( - eq(workflowExecutionLogs.level, 'info'), - or( - sql`(${pausedExecutions.totalPauseCount} > 0 AND ${pausedExecutions.resumedCount} < ${pausedExecutions.totalPauseCount})`, - and( - isNotNull(pausedExecutions.status), - sql`${pausedExecutions.status} != 'fully_resumed'` - ) - ) + for (const level of levels) { + if (level === 'error') { + levelConditions.push(eq(workflowExecutionLogs.level, 'error')) + } else if (level === 'info') { + const c = and( + eq(workflowExecutionLogs.level, 'info'), + isNotNull(workflowExecutionLogs.endedAt) + ) + if (c) levelConditions.push(c) + } else if (level === 'running') { + const c = and( + eq(workflowExecutionLogs.level, 'info'), + isNull(workflowExecutionLogs.endedAt) + ) + if (c) levelConditions.push(c) + } else if (level === 'pending') { + const c = and( + eq(workflowExecutionLogs.level, 'info'), + or( + sql`(${pausedExecutions.totalPauseCount} > 0 AND ${pausedExecutions.resumedCount} < ${pausedExecutions.totalPauseCount})`, + and( + isNotNull(pausedExecutions.status), + sql`${pausedExecutions.status} != 'fully_resumed'` ) - if (condition) levelConditions.push(condition) - } - } - - if (levelConditions.length > 0) { - conditions = and( - conditions, - levelConditions.length === 1 ? levelConditions[0] : or(...levelConditions) ) - } - } - - // Apply common filters (workflowIds, folderIds, triggers, dates, search, cost, duration) - // Level filtering is handled above with advanced running/pending state logic - const commonFilters = buildFilterConditions(params, { useSimpleLevelFilter: false }) - if (commonFilters) { - conditions = and(conditions, commonFilters) + ) + if (c) levelConditions.push(c) } + } - // Workflow-specific filters exclude job logs entirely - const hasWorkflowSpecificFilters = !!( - params.workflowIds || - params.folderIds || - params.workflowName || - params.folderName + if (levelConditions.length > 0) { + workflowConditions.push( + levelConditions.length === 1 ? levelConditions[0] : or(...levelConditions)! ) - // If triggers filter is set and doesn't include 'mothership', skip job logs - const triggersList = params.triggers?.split(',').filter(Boolean) || [] - const triggersExcludeJobs = - triggersList.length > 0 && - !triggersList.includes('all') && - !triggersList.includes('mothership') - const includeJobLogs = !hasWorkflowSpecificFilters && !triggersExcludeJobs - - const fetchSize = params.limit + params.offset - - const workflowLogs = await baseQuery - .where(and(workspaceFilter, conditions)) - .orderBy(desc(workflowExecutionLogs.startedAt)) - .limit(fetchSize) + } + } - const workflowCountQuery = db - .select({ count: sql`count(*)` }) - .from(workflowExecutionLogs) - .leftJoin( - pausedExecutions, - eq(pausedExecutions.executionId, workflowExecutionLogs.executionId) - ) - .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflowExecutionLogs.workspaceId), - eq(permissions.userId, userId) - ) - ) - .where(and(eq(workflowExecutionLogs.workspaceId, params.workspaceId), conditions)) - - // Build job log filters (subset of filters that apply to job logs) - let jobLogs: Array<{ - id: string - executionId: string - level: string - status: string - trigger: string - startedAt: Date - endedAt: Date | null - totalDurationMs: number | null - executionData: unknown - cost: unknown - createdAt: Date - jobTitle: string | null - }> = [] - let jobCount = 0 - - if (includeJobLogs) { - const jobConditions: SQL[] = [eq(jobExecutionLogs.workspaceId, params.workspaceId)] - - // Permission check + const commonFilters = buildFilterConditions(params, { useSimpleLevelFilter: false }) + if (commonFilters) workflowConditions.push(commonFilters) + + const workflowCursorCond = buildCursorCondition(workflowSortExpr, workflowExecutionLogs.id) + if (workflowCursorCond) workflowConditions.push(workflowCursorCond) + + // Decide whether to include job logs + const hasWorkflowSpecificFilters = !!( + params.workflowIds || + params.folderIds || + params.workflowName || + params.folderName + ) + const triggersList = params.triggers?.split(',').filter(Boolean) || [] + const triggersExcludeJobs = + triggersList.length > 0 && !triggersList.includes('all') && !triggersList.includes('mothership') + const levelList = + params.level && params.level !== 'all' ? params.level.split(',').filter(Boolean) : [] + const levelExcludesJobs = + levelList.length > 0 && !levelList.some((l) => l === 'error' || l === 'info') + const includeJobLogs = !hasWorkflowSpecificFilters && !triggersExcludeJobs && !levelExcludesJobs + + const workflowQuery = 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, + cost: workflowExecutionLogs.cost, + 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, + pausedStatus: pausedExecutions.status, + pausedTotalPauseCount: pausedExecutions.totalPauseCount, + pausedResumedCount: pausedExecutions.resumedCount, + deploymentVersion: workflowDeploymentVersion.version, + deploymentVersionName: workflowDeploymentVersion.name, + sortValue: sql`${workflowSortExpr}`.as('sort_value'), + }) + .from(workflowExecutionLogs) + .leftJoin(pausedExecutions, eq(pausedExecutions.executionId, workflowExecutionLogs.executionId)) + .leftJoin( + workflowDeploymentVersion, + eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId) + ) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .innerJoin( + permissions, + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workflowExecutionLogs.workspaceId), + eq(permissions.userId, userId) + ) + ) + .where(and(...workflowConditions)) + .orderBy(orderByClause(workflowSortExpr), dir(workflowExecutionLogs.id)) + .limit(fetchSize) + + const jobConditions: SQL[] = [eq(jobExecutionLogs.workspaceId, params.workspaceId)] + + if (includeJobLogs) { + jobConditions.push( + sql`EXISTS (SELECT 1 FROM ${permissions} WHERE ${permissions.entityType} = 'workspace' AND ${permissions.entityId} = ${jobExecutionLogs.workspaceId} AND ${permissions.userId} = ${userId})` + ) + + if (params.level && params.level !== 'all') { + const levels = params.level.split(',').filter(Boolean) + const jobLevelConditions: SQL[] = [] + for (const level of levels) { + if (level === 'error') { + jobLevelConditions.push(eq(jobExecutionLogs.level, 'error')) + } else if (level === 'info') { + const c = and(eq(jobExecutionLogs.level, 'info'), isNotNull(jobExecutionLogs.endedAt)) + if (c) jobLevelConditions.push(c) + } + } + if (jobLevelConditions.length > 0) { jobConditions.push( - sql`EXISTS (SELECT 1 FROM ${permissions} WHERE ${permissions.entityType} = 'workspace' AND ${permissions.entityId} = ${jobExecutionLogs.workspaceId} AND ${permissions.userId} = ${userId})` + jobLevelConditions.length === 1 ? jobLevelConditions[0] : or(...jobLevelConditions)! ) + } + } - // Level filter - if (params.level && params.level !== 'all') { - const levels = params.level.split(',').filter(Boolean) - const jobLevelConditions: SQL[] = [] - for (const level of levels) { - if (level === 'error') { - jobLevelConditions.push(eq(jobExecutionLogs.level, 'error')) - } else if (level === 'info') { - const c = and(eq(jobExecutionLogs.level, 'info'), isNotNull(jobExecutionLogs.endedAt)) - if (c) jobLevelConditions.push(c) - } - // 'running' and 'pending' don't apply to job logs (they complete synchronously) - } - if (jobLevelConditions.length > 0) { - jobConditions.push( - jobLevelConditions.length === 1 ? jobLevelConditions[0] : or(...jobLevelConditions)! - ) - } - } - - // Trigger filter - if (triggersList.length > 0 && !triggersList.includes('all')) { - jobConditions.push(inArray(jobExecutionLogs.trigger, triggersList)) - } - - // Date filters - if (params.startDate) { - jobConditions.push(gte(jobExecutionLogs.startedAt, new Date(params.startDate))) - } - if (params.endDate) { - jobConditions.push(lte(jobExecutionLogs.startedAt, new Date(params.endDate))) - } + if (triggersList.length > 0 && !triggersList.includes('all')) { + jobConditions.push(inArray(jobExecutionLogs.trigger, triggersList)) + } - // Search by executionId - if (params.search) { - jobConditions.push(sql`${jobExecutionLogs.executionId} ILIKE ${`%${params.search}%`}`) - } - if (params.executionId) { - jobConditions.push(eq(jobExecutionLogs.executionId, params.executionId)) - } + if (params.startDate) { + jobConditions.push(gte(jobExecutionLogs.startedAt, new Date(params.startDate))) + } + if (params.endDate) { + jobConditions.push(lte(jobExecutionLogs.startedAt, new Date(params.endDate))) + } - // Cost filter - if (params.costOperator && params.costValue !== undefined) { - const costField = sql`(${jobExecutionLogs.cost}->>'total')::numeric` - const ops = { - '=': sql`=`, - '>': sql`>`, - '<': sql`<`, - '>=': sql`>=`, - '<=': sql`<=`, - '!=': sql`!=`, - } as const - jobConditions.push(sql`${costField} ${ops[params.costOperator]} ${params.costValue}`) - } + if (params.search) { + jobConditions.push(sql`${jobExecutionLogs.executionId} ILIKE ${`%${params.search}%`}`) + } + if (params.executionId) { + jobConditions.push(eq(jobExecutionLogs.executionId, params.executionId)) + } - // Duration filter - if (params.durationOperator && params.durationValue !== undefined) { - const durationOps: Record< - string, - (field: typeof jobExecutionLogs.totalDurationMs, val: number) => SQL | undefined - > = { - '=': (f, v) => eq(f, v), - '>': (f, v) => gt(f, v), - '<': (f, v) => lt(f, v), - '>=': (f, v) => gte(f, v), - '<=': (f, v) => lte(f, v), - '!=': (f, v) => ne(f, v), - } - const durationCond = durationOps[params.durationOperator]?.( - jobExecutionLogs.totalDurationMs, - params.durationValue - ) - if (durationCond) jobConditions.push(durationCond) - } + if (params.costOperator && params.costValue !== undefined) { + const costField = sql`(${jobExecutionLogs.cost}->>'total')::numeric` + const ops = { + '=': sql`=`, + '>': sql`>`, + '<': sql`<`, + '>=': sql`>=`, + '<=': sql`<=`, + '!=': sql`!=`, + } as const + jobConditions.push(sql`${costField} ${ops[params.costOperator]} ${params.costValue}`) + } - const jobWhere = and(...jobConditions) - - const [jobLogResults, jobCountResult] = await Promise.all([ - 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: - params.details === 'full' ? jobExecutionLogs.executionData : sql`NULL`, - cost: jobExecutionLogs.cost, - createdAt: jobExecutionLogs.createdAt, - jobTitle: sql`${jobExecutionLogs.executionData}->'trigger'->>'source'`, - }) - .from(jobExecutionLogs) - .where(jobWhere) - .orderBy(desc(jobExecutionLogs.startedAt)) - .limit(fetchSize), - db.select({ count: sql`count(*)` }).from(jobExecutionLogs).where(jobWhere), - ]) - - jobLogs = jobLogResults as typeof jobLogs - jobCount = Number(jobCountResult[0]?.count || 0) + if (params.durationOperator && params.durationValue !== undefined) { + const durationOps: Record< + string, + (field: typeof jobExecutionLogs.totalDurationMs, val: number) => SQL | undefined + > = { + '=': (f, v) => eq(f, v), + '>': (f, v) => gt(f, v), + '<': (f, v) => lt(f, v), + '>=': (f, v) => gte(f, v), + '<=': (f, v) => lte(f, v), + '!=': (f, v) => ne(f, v), } + const durationCond = durationOps[params.durationOperator]?.( + jobExecutionLogs.totalDurationMs, + params.durationValue + ) + if (durationCond) jobConditions.push(durationCond) + } - const workflowCountResult = await workflowCountQuery - const workflowCount = Number(workflowCountResult[0]?.count || 0) - const totalCount = workflowCount + jobCount - - // Transform workflow logs to the unified shape - const blockExecutionsByExecution: Record = {} - - const createTraceSpans = (blockExecutions: any[]) => { - return blockExecutions.map((block, index) => { - let output = block.outputData - if (block.status === 'error' && block.errorMessage) { - output = { - ...output, - error: block.errorMessage, - stackTrace: block.errorStackTrace, - } - } + const jobCursorCond = buildCursorCondition(jobSortExpr, jobExecutionLogs.id) + if (jobCursorCond) jobConditions.push(jobCursorCond) + } - return { - id: block.id, - name: `Block ${block.blockName || block.blockType} (${block.blockType})`, - type: block.blockType, - duration: block.durationMs, - startTime: block.startedAt, - endTime: block.endedAt, - status: block.status === 'success' ? 'success' : 'error', - blockId: block.blockId, - input: block.inputData, - output, - tokens: block.cost?.tokens?.total || 0, - relativeStartMs: index * 100, - children: [], - toolCalls: [], - } + const jobQuery = includeJobLogs + ? 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, + cost: jobExecutionLogs.cost, + createdAt: jobExecutionLogs.createdAt, + jobTitle: sql`${jobExecutionLogs.executionData}->'trigger'->>'source'`, + sortValue: sql`${jobSortExpr}`.as('sort_value'), }) - } + .from(jobExecutionLogs) + .where(and(...jobConditions)) + .orderBy(orderByClause(jobSortExpr), dir(jobExecutionLogs.id)) + .limit(fetchSize) + : Promise.resolve([]) - const extractCostSummary = (blockExecutions: any[]) => { - let totalCost = 0 - let totalInputCost = 0 - let totalOutputCost = 0 - let totalTokens = 0 - let totalPromptTokens = 0 - let totalCompletionTokens = 0 - const models = new Map() - - blockExecutions.forEach((block) => { - if (block.cost) { - totalCost += Number(block.cost.total) || 0 - totalInputCost += Number(block.cost.input) || 0 - totalOutputCost += Number(block.cost.output) || 0 - totalTokens += block.cost.tokens?.total || 0 - totalPromptTokens += block.cost.tokens?.prompt || 0 - totalCompletionTokens += block.cost.tokens?.completion || 0 - - if (block.cost.model) { - if (!models.has(block.cost.model)) { - models.set(block.cost.model, { - input: 0, - output: 0, - total: 0, - tokens: { input: 0, output: 0, total: 0 }, - }) - } - const modelCost = models.get(block.cost.model) - modelCost.input += Number(block.cost.input) || 0 - modelCost.output += Number(block.cost.output) || 0 - modelCost.total += Number(block.cost.total) || 0 - modelCost.tokens.input += block.cost.tokens?.input || block.cost.tokens?.prompt || 0 - modelCost.tokens.output += - block.cost.tokens?.output || block.cost.tokens?.completion || 0 - modelCost.tokens.total += block.cost.tokens?.total || 0 - } - } - }) + const [workflowRows, jobRows] = await Promise.all([workflowQuery, jobQuery]) - return { - total: totalCost, - input: totalInputCost, - output: totalOutputCost, - tokens: { - total: totalTokens, - input: totalPromptTokens, - output: totalCompletionTokens, - }, - models: Object.fromEntries(models), - } - } + type RowWithSort = { + id: string + sortValue: unknown + summary: WorkflowLogSummary + } - const transformedWorkflowLogs = workflowLogs.map((log) => { - const blockExecutions = blockExecutionsByExecution[log.executionId] || [] - - let traceSpans = [] - let finalOutput: any - let costSummary = (log.cost as any) || { total: 0 } - - if (params.details === 'full' && log.executionData) { - const storedTraceSpans = (log.executionData as any)?.traceSpans - traceSpans = - storedTraceSpans && Array.isArray(storedTraceSpans) && storedTraceSpans.length > 0 - ? storedTraceSpans - : createTraceSpans(blockExecutions) - - costSummary = - log.cost && Object.keys(log.cost as any).length > 0 - ? (log.cost as any) - : extractCostSummary(blockExecutions) - - try { - const fo = (log.executionData as any)?.finalOutput - if (fo !== undefined) finalOutput = fo - } catch {} - } + const workflowMapped: RowWithSort[] = workflowRows.map((log) => { + 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') + + const summary: WorkflowLogSummary = { + 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: 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, + jobTitle: null, + cost: (log.cost as WorkflowLogSummary['cost']) ?? null, + pauseSummary: { + status: log.pausedStatus ?? null, + total: totalPauseCount, + resumed: resumedCount, + }, + hasPendingPause, + } + return { id: log.id, sortValue: log.sortValue, summary } + }) + + const jobMapped: RowWithSort[] = (jobRows as Awaited).map((log) => { + const summary: WorkflowLogSummary = { + id: log.id, + workflowId: null, + executionId: log.executionId, + deploymentVersionId: null, + deploymentVersion: null, + deploymentVersionName: null, + level: log.level, + status: log.status, + duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null, + trigger: log.trigger, + createdAt: log.startedAt.toISOString(), + workflow: null, + jobTitle: log.jobTitle ?? null, + cost: (log.cost as WorkflowLogSummary['cost']) ?? null, + pauseSummary: { status: null, total: 0, resumed: 0 }, + hasPendingPause: false, + } + return { id: log.id, sortValue: log.sortValue, summary } + }) + + const compareSortValues = (a: unknown, b: unknown): number => { + if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime() + if (typeof a === 'number' && typeof b === 'number') return a - b + const aStr = String(a) + const bStr = String(b) + if (sortBy === 'date') { + return new Date(aStr).getTime() - new Date(bStr).getTime() + } + const aNum = Number(aStr) + const bNum = Number(bStr) + if (!Number.isNaN(aNum) && !Number.isNaN(bNum)) return aNum - bNum + return aStr.localeCompare(bStr) + } - 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 - - 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(), - files: params.details === 'full' ? log.files || undefined : undefined, - workflow: workflowSummary, - pauseSummary: { - status: log.pausedStatus ?? null, - total: log.pausedTotalPauseCount ?? 0, - resumed: log.pausedResumedCount ?? 0, - }, - executionData: - params.details === 'full' - ? { - totalDuration: log.totalDurationMs, - traceSpans, - blockExecutions, - finalOutput, - enhanced: true, - } - : undefined, - cost: - params.details === 'full' - ? (costSummary as any) - : { total: (costSummary as any)?.total || 0 }, - hasPendingPause: - (Number(log.pausedTotalPauseCount ?? 0) > 0 && - Number(log.pausedResumedCount ?? 0) < Number(log.pausedTotalPauseCount ?? 0)) || - (log.pausedStatus && log.pausedStatus !== 'fully_resumed'), - } - }) - - // Transform job logs to the same shape - const transformedJobLogs = jobLogs.map((log) => { - const execData = log.executionData as any - const costSummary = (log.cost as any) || { total: 0 } - - return { - id: log.id, - workflowId: null as string | null, - executionId: log.executionId, - deploymentVersionId: null as string | null, - deploymentVersion: null as number | null, - deploymentVersionName: null as string | null, - level: log.level, - status: log.status, - duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null, - trigger: log.trigger, - createdAt: log.startedAt.toISOString(), - files: undefined as any, - workflow: null as any, - jobTitle: log.jobTitle, - pauseSummary: { - status: null as string | null, - total: 0, - resumed: 0, - }, - executionData: - params.details === 'full' && execData - ? { - totalDuration: log.totalDurationMs, - traceSpans: execData.traceSpans || [], - blockExecutions: [], - finalOutput: execData.finalOutput, - enhanced: true, - trigger: execData.trigger, - } - : undefined, - cost: params.details === 'full' ? costSummary : { total: costSummary?.total || 0 }, - hasPendingPause: false, - } - }) - - // Merge, sort by createdAt (which is startedAt ISO string) desc, paginate - const allLogs = [...transformedWorkflowLogs, ...transformedJobLogs] - .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) - .slice(params.offset, params.offset + params.limit) - - return NextResponse.json( - { - data: allLogs, - total: totalCount, - page: Math.floor(params.offset / params.limit) + 1, - pageSize: params.limit, - totalPages: Math.ceil(totalCount / params.limit), - }, - { status: 200 } - ) - } catch (validationError) { - if (isZodError(validationError)) { - logger.warn(`[${requestId}] Invalid logs request parameters`, { - errors: validationError.issues, - }) - return NextResponse.json( - { - error: 'Invalid request parameters', - details: validationError.issues, - }, - { status: 400 } - ) - } - throw validationError + const merged = [...workflowMapped, ...jobMapped].sort((a, b) => { + const aNull = a.sortValue === null || a.sortValue === undefined + const bNull = b.sortValue === null || b.sortValue === undefined + // Mirror SQL's NULLS LAST for both ASC and DESC so the cursor stays consistent. + if (aNull && !bNull) return 1 + if (!aNull && bNull) return -1 + if (!aNull && !bNull) { + const cmp = compareSortValues(a.sortValue, b.sortValue) + if (cmp !== 0) return sortOrder === 'asc' ? cmp : -cmp } - } catch (error: any) { - logger.error(`[${requestId}] logs fetch error`, error) - return NextResponse.json({ error: error.message }, { status: 500 }) + const idCmp = a.id.localeCompare(b.id) + return sortOrder === 'asc' ? idCmp : -idCmp + }) + + const page = merged.slice(0, params.limit) + const hasMore = merged.length > params.limit + let nextCursor: string | null = null + if (hasMore && page.length > 0) { + const last = page[page.length - 1] + const v = last.sortValue + const cursorV = + v instanceof Date + ? v.toISOString() + : typeof v === 'number' || typeof v === 'string' + ? v + : v == null + ? null + : String(v) + nextCursor = encodeCursor({ v: cursorV, id: last.id }) } + + logger.debug('Listed logs', { + workspaceId: params.workspaceId, + count: page.length, + hasMore, + sortBy, + sortOrder, + }) + + return NextResponse.json({ + data: page.map((row) => row.summary), + nextCursor, + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx index 302b3bce4c2..d51078e47af 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx @@ -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( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 33d4ade4e56..e793991957c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -185,6 +185,7 @@ export const ResourceContent = memo(function ResourceContent({ return ( 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}` : '' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index c1cd8c78b91..addb5e8932b 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -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 }) { const contentRef = useRef(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('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(resolvedTab) - if (prevResolvedTabRef.current !== resolvedTab) { - prevResolvedTabRef.current = resolvedTab + useLayoutEffect(() => { onActiveTabChange?.(resolvedTab) - } + }, [resolvedTab, onActiveTabChange]) const workflowOutput = useMemo(() => { const executionData = log.executionData as { finalOutput?: Record } | undefined @@ -594,12 +584,26 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP {/* Trace Tab */} - {showTraceTab && log.executionData?.traceSpans && ( + {showTraceTab && ( - + {traceSpans?.length ? ( + + ) : log.executionData ? ( +
+ + No trace data available for this run + +
+ ) : ( +
+ + Loading trace… + +
+ )}
)} @@ -608,7 +612,7 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP {log.executionId && ( 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('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({ - { - activeTabRef.current = tab - onActiveTabChange?.(tab) - }} - /> + )} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx index 0ce7aafa9f2..d8435907db8 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx @@ -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 diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-list/logs-list.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-list/logs-list.tsx index e8dd1d912be..7bf398a99ca 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-list/logs-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-list/logs-list.tsx @@ -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 | 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 isFetchingNextPage: boolean loaderRef: React.RefObject @@ -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 hasNextPage: boolean isFetchingNextPage: boolean diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index de2dce93250..5c3b3b0af66 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -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(false) - const pendingExecutionIdRef = useRef(undefined) - if (pendingExecutionIdRef.current === undefined) { - pendingExecutionIdRef.current = - typeof window !== 'undefined' - ? new URLSearchParams(window.location.search).get('executionId') - : null - } + const [pendingExecutionId, setPendingExecutionId] = useState(() => + 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()) - const logsRef = useRef([]) + const logsRef = useRef([]) const selectedLogIndexRef = useRef(-1) const selectedLogIdRef = useRef(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(null) + const [contextMenuLog, setContextMenuLog] = useState(null) const [previewLogId, setPreviewLogId] = useState(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 && ( - 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 | 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 | 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).executionState as | { blockStates?: Record< string, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts index c4b0e1e5e67..411ab163f47 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts @@ -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) => ({ diff --git a/apps/sim/hooks/queries/logs.ts b/apps/sim/hooks/queries/logs.ts index d2f4bbfa4e9..00b1aac4985 100644 --- a/apps/sim/hooks/queries/logs.ts +++ b/apps/sim/hooks/queries/logs.ts @@ -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) => + 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): void { +function applyFilterParams( + params: URLSearchParams, + filters: Omit +): void { if (filters.level !== 'all') { params.set('level', filters.level) } @@ -100,61 +104,53 @@ function applyFilterParams(params: URLSearchParams, filters: Omit { +): Promise { 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 { - const { data } = await requestJson(getLogDetailContract, { - params: { id: logId }, - signal, - }) - return toWorkflowLog(data) -} - -async function fetchLogByExecutionId( +export async function fetchLogDetail( + logId: string, workspaceId: string, - executionId: string, signal?: AbortSignal -): Promise { - const apiData = await requestJson(listLogsContract, { - query: { - workspaceId, - executionId, - details: 'full', - limit: 1, - }, +): Promise { + const { data } = await requestJson(getLogDetailContract, { + params: { id: logId }, + query: { workspaceId }, 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, + filters: Omit, signal?: AbortSignal ): Promise { 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, + filters: Omit, 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>({ 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(logKeys.detail(affectedLogId)) + if (previousDetail) { + queryClient.setQueryData(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() }) }, }) diff --git a/apps/sim/lib/api/contracts/logs.ts b/apps/sim/lib/api/contracts/logs.ts index b0298e349ec..6e94720f91a 100644 --- a/apps/sim/lib/api/contracts/logs.ts +++ b/apps/sim/lib/api/contracts/logs.ts @@ -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 +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(), +}) + +const tokenBreakdownSchema = 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(), + 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() -export const workflowLogSchema = z +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> + children?: TraceSpan[] +} + +const traceSpanSchema: z.ZodType = 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({ - 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(), + 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 type WorkflowLogData = z.output +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 logsResponseSchema = z.object({ - data: z.array(workflowLogSchema), - total: z.number(), - page: z.number(), - pageSize: z.number(), - totalPages: z.number(), +export const workflowLogDetailSchema = workflowLogSummarySchema.extend({ + executionData: executionDataDetailSchema, + files: z.array(fileSchema).nullable(), }) -export type LogsResponse = z.output +export type WorkflowLogSummary = z.output +export type WorkflowLogDetail = z.output + +/** + * 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> + +export const listLogsResponseSchema = z.object({ + data: z.array(workflowLogSummarySchema), + nextCursor: z.string().nullable(), +}) + +export type ListLogsResponse = z.output 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: workflowLogDetailSchema, + }), + }, +}) + +export const getLogByExecutionIdContract = defineRouteContract({ + method: 'GET', + path: '/api/logs/by-execution/[executionId]', + params: executionIdParamsSchema, + query: logDetailQuerySchema, response: { mode: 'json', schema: z.object({ - data: workflowLogSchema, + data: workflowLogDetailSchema, }), }, }) diff --git a/apps/sim/lib/logs/fetch-log-detail.ts b/apps/sim/lib/logs/fetch-log-detail.ts new file mode 100644 index 00000000000..1a5aea4dc26 --- /dev/null +++ b/apps/sim/lib/logs/fetch-log-detail.ts @@ -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 | 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 | 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 | 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, + } +} diff --git a/apps/sim/stores/logs/filters/types.ts b/apps/sim/stores/logs/filters/types.ts index 3fbd85bfaee..cf95d3bee3e 100644 --- a/apps/sim/stores/logs/filters/types.ts +++ b/apps/sim/stores/logs/filters/types.ts @@ -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 - 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 - }> - } -} - -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', diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 34cbacb0f6e..14a57e05fad 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -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