mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
feat(logs): add retry from context menu and detail sidebar for failed runs
This commit is contained in:
@@ -15,7 +15,7 @@ import {
|
||||
Input,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Copy as CopyIcon, Search as SearchIcon } from '@/components/emcn/icons'
|
||||
import { Copy as CopyIcon, Redo, Search as SearchIcon } from '@/components/emcn/icons'
|
||||
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
@@ -264,6 +264,8 @@ interface LogDetailsProps {
|
||||
hasNext?: boolean
|
||||
/** Whether there is a previous log available */
|
||||
hasPrev?: boolean
|
||||
/** Callback to retry a failed execution */
|
||||
onRetryExecution?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -280,6 +282,7 @@ export const LogDetails = memo(function LogDetails({
|
||||
onNavigatePrev,
|
||||
hasNext = false,
|
||||
hasPrev = false,
|
||||
onRetryExecution,
|
||||
}: LogDetailsProps) {
|
||||
const [isExecutionSnapshotOpen, setIsExecutionSnapshotOpen] = useState(false)
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
||||
@@ -389,6 +392,21 @@ export const LogDetails = memo(function LogDetails({
|
||||
>
|
||||
<ChevronUp className='h-[14px] w-[14px] rotate-180' />
|
||||
</Button>
|
||||
{log?.status === 'failed' && (log?.workflow?.id || log?.workflowId) && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='!p-1'
|
||||
onClick={() => onRetryExecution?.()}
|
||||
aria-label='Retry execution'
|
||||
>
|
||||
<Redo className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='bottom'>Retry</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
<Button variant='ghost' className='!p-1' onClick={onClose} aria-label='Close'>
|
||||
<X className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/emcn'
|
||||
import { Copy, Eye, Link, ListFilter, SquareArrowUpRight, X } from '@/components/emcn/icons'
|
||||
import { Copy, Eye, Link, ListFilter, Redo, SquareArrowUpRight, X } from '@/components/emcn/icons'
|
||||
import type { WorkflowLog } from '@/stores/logs/filters/types'
|
||||
|
||||
interface LogRowContextMenuProps {
|
||||
@@ -23,6 +23,7 @@ interface LogRowContextMenuProps {
|
||||
onToggleWorkflowFilter: () => void
|
||||
onClearAllFilters: () => void
|
||||
onCancelExecution: () => void
|
||||
onRetryExecution: () => void
|
||||
isFilteredByThisWorkflow: boolean
|
||||
hasActiveFilters: boolean
|
||||
}
|
||||
@@ -43,6 +44,7 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
|
||||
onToggleWorkflowFilter,
|
||||
onClearAllFilters,
|
||||
onCancelExecution,
|
||||
onRetryExecution,
|
||||
isFilteredByThisWorkflow,
|
||||
hasActiveFilters,
|
||||
}: LogRowContextMenuProps) {
|
||||
@@ -50,6 +52,7 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
|
||||
const hasWorkflow = Boolean(log?.workflow?.id || log?.workflowId)
|
||||
const isCancellable =
|
||||
(log?.status === 'running' || log?.status === 'pending') && hasExecutionId && hasWorkflow
|
||||
const isRetryable = log?.status === 'failed' && hasWorkflow
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
|
||||
@@ -73,6 +76,15 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
|
||||
sideOffset={4}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{isRetryable && (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={onRetryExecution}>
|
||||
<Redo />
|
||||
Retry
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{isCancellable && (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={onCancelExecution}>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
Library,
|
||||
Loader,
|
||||
toast,
|
||||
} from '@/components/emcn'
|
||||
import { DatePicker } from '@/components/emcn/components/date-picker/date-picker'
|
||||
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
|
||||
@@ -53,11 +54,14 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { useFolderMap, useFolders } from '@/hooks/queries/folders'
|
||||
import {
|
||||
fetchLogDetail,
|
||||
logKeys,
|
||||
prefetchLogDetail,
|
||||
useCancelExecution,
|
||||
useDashboardStats,
|
||||
useLogDetail,
|
||||
useLogsList,
|
||||
useRetryExecution,
|
||||
} from '@/hooks/queries/logs'
|
||||
import { useWorkflowMap, useWorkflows } from '@/hooks/queries/workflows'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
@@ -74,6 +78,7 @@ import {
|
||||
import {
|
||||
DELETED_WORKFLOW_COLOR,
|
||||
DELETED_WORKFLOW_LABEL,
|
||||
extractRetryInput,
|
||||
formatDate,
|
||||
getDisplayStatus,
|
||||
type LogStatus,
|
||||
@@ -536,6 +541,7 @@ export default function Logs() {
|
||||
}, [contextMenuLog])
|
||||
|
||||
const cancelExecution = useCancelExecution()
|
||||
const retryExecution = useRetryExecution()
|
||||
|
||||
const handleCancelExecution = useCallback(() => {
|
||||
const workflowId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId
|
||||
@@ -546,6 +552,37 @@ export default function Logs() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [contextMenuLog])
|
||||
|
||||
const retryLog = useCallback(
|
||||
async (log: WorkflowLog | null) => {
|
||||
const workflowId = log?.workflow?.id || log?.workflowId
|
||||
const logId = log?.id
|
||||
if (!workflowId || !logId) return
|
||||
|
||||
try {
|
||||
const detailLog = await queryClient.fetchQuery({
|
||||
queryKey: logKeys.detail(logId),
|
||||
queryFn: ({ signal }) => fetchLogDetail(logId, signal),
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
const input = extractRetryInput(detailLog)
|
||||
await retryExecution.mutateAsync({ workflowId, input })
|
||||
toast.success('Retry started')
|
||||
} catch {
|
||||
toast.error('Failed to retry execution')
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
)
|
||||
|
||||
const handleRetryExecution = useCallback(() => {
|
||||
retryLog(contextMenuLog)
|
||||
}, [contextMenuLog, retryLog])
|
||||
|
||||
const handleRetrySidebarExecution = useCallback(() => {
|
||||
retryLog(selectedLog)
|
||||
}, [selectedLog, retryLog])
|
||||
|
||||
const contextMenuWorkflowId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId
|
||||
const isFilteredByThisWorkflow = Boolean(
|
||||
contextMenuWorkflowId && workflowIds.length === 1 && workflowIds[0] === contextMenuWorkflowId
|
||||
@@ -783,6 +820,7 @@ export default function Logs() {
|
||||
onNavigatePrev={handleNavigatePrev}
|
||||
hasNext={selectedLogIndex < sortedLogs.length - 1}
|
||||
hasPrev={selectedLogIndex > 0}
|
||||
onRetryExecution={handleRetrySidebarExecution}
|
||||
/>
|
||||
),
|
||||
[
|
||||
@@ -791,6 +829,7 @@ export default function Logs() {
|
||||
handleCloseSidebar,
|
||||
handleNavigateNext,
|
||||
handleNavigatePrev,
|
||||
handleRetrySidebarExecution,
|
||||
selectedLogIndex,
|
||||
sortedLogs.length,
|
||||
]
|
||||
@@ -1191,6 +1230,7 @@ export default function Logs() {
|
||||
onOpenWorkflow={handleOpenWorkflow}
|
||||
onOpenPreview={handleOpenPreview}
|
||||
onCancelExecution={handleCancelExecution}
|
||||
onRetryExecution={handleRetryExecution}
|
||||
onToggleWorkflowFilter={handleToggleWorkflowFilter}
|
||||
onClearAllFilters={handleClearAllFilters}
|
||||
isFilteredByThisWorkflow={isFilteredByThisWorkflow}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Badge } from '@/components/emcn'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import type { WorkflowLog } from '@/stores/logs/filters/types'
|
||||
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
|
||||
|
||||
export const LOG_COLUMNS = {
|
||||
@@ -422,3 +423,30 @@ export const formatDate = (dateString: string) => {
|
||||
})(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the original workflow input from a log entry for retry.
|
||||
* Prefers the persisted `workflowInput` field (new logs), falls back to
|
||||
* reconstructing from `executionState.blockStates` (old logs).
|
||||
*/
|
||||
export function extractRetryInput(log: WorkflowLog): unknown | undefined {
|
||||
const execData = log.executionData as Record<string, unknown> | undefined
|
||||
if (!execData) return undefined
|
||||
|
||||
if (execData.workflowInput !== undefined) {
|
||||
return execData.workflowInput
|
||||
}
|
||||
|
||||
const executionState = execData.executionState as
|
||||
| { blockStates?: Record<string, { output?: unknown }> }
|
||||
| undefined
|
||||
if (!executionState?.blockStates) return undefined
|
||||
|
||||
for (const state of Object.values(executionState.blockStates)) {
|
||||
if (state.output && typeof state.output === 'object' && 'input' in state.output) {
|
||||
return state.output
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ async function fetchLogsPage(
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLogDetail(logId: string, signal?: AbortSignal): Promise<WorkflowLog> {
|
||||
export async function fetchLogDetail(logId: string, signal?: AbortSignal): Promise<WorkflowLog> {
|
||||
const response = await fetch(`/api/logs/${logId}`, { signal })
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -331,3 +331,34 @@ export function useCancelExecution() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRetryExecution() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({ workflowId, input }: { workflowId: string; input?: unknown }) => {
|
||||
const res = await fetch(`/api/workflows/${workflowId}/execute`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input, triggerType: 'manual', stream: true }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
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()
|
||||
reader.cancel()
|
||||
}
|
||||
return { started: true }
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: logKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: logKeys.details() })
|
||||
queryClient.invalidateQueries({ queryKey: [...logKeys.all, 'stats'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
models: NonNullable<WorkflowExecutionLog['executionData']['models']>
|
||||
}
|
||||
executionState?: SerializableExecutionState
|
||||
workflowInput?: unknown
|
||||
}): WorkflowExecutionLog['executionData'] {
|
||||
const {
|
||||
existingExecutionData,
|
||||
@@ -94,6 +95,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
completionFailure,
|
||||
executionCost,
|
||||
executionState,
|
||||
workflowInput,
|
||||
} = params
|
||||
const traceSpanCount = countTraceSpans(traceSpans)
|
||||
|
||||
@@ -129,6 +131,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
},
|
||||
models: executionCost.models,
|
||||
...(executionState ? { executionState } : {}),
|
||||
...(workflowInput !== undefined ? { workflowInput } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,6 +380,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
completionFailure,
|
||||
executionCost,
|
||||
executionState,
|
||||
workflowInput,
|
||||
})
|
||||
|
||||
const [updatedLog] = await db
|
||||
|
||||
@@ -149,6 +149,7 @@ export interface WorkflowExecutionLog {
|
||||
>
|
||||
executionState?: SerializableExecutionState
|
||||
finalOutput?: any
|
||||
workflowInput?: unknown
|
||||
errorDetails?: {
|
||||
blockId: string
|
||||
blockName: string
|
||||
|
||||
Reference in New Issue
Block a user