Fix critical issues and enhance PayloadCMS automation plugin

## Critical Fixes Implemented:

### 1. Hook Execution Reliability (src/plugin/index.ts)
- Replaced fragile global variable pattern with proper dependency injection
- Added structured executor registry with initialization tracking
- Implemented proper logging using PayloadCMS logger instead of console
- Added graceful handling for executor unavailability scenarios

### 2. Error Handling & Workflow Run Tracking
- Fixed error swallowing in hook execution
- Added createFailedWorkflowRun() to track hook execution failures
- Improved error categorization and user-friendly error messages
- Enhanced workflow run status tracking with detailed context

### 3. Enhanced HTTP Step (src/steps/)
- Complete rewrite of HTTP request handler with enterprise features:
  - Multiple authentication methods (Bearer, Basic Auth, API Key)
  - Configurable timeouts and retry logic with exponential backoff
  - Comprehensive error handling for different failure scenarios
  - Support for all HTTP methods with proper request/response parsing
  - Request duration tracking and detailed logging

### 4. User Experience Improvements
- Added StatusCell component with visual status indicators
- Created ErrorDisplay component with user-friendly error explanations
- Added WorkflowExecutionStatus component for real-time execution monitoring
- Enhanced collections with better error display and conditional fields

### 5. Comprehensive Testing Suite
- Added hook-reliability.spec.ts: Tests executor availability and concurrent execution
- Added error-scenarios.spec.ts: Tests timeout, network, validation, and HTTP errors
- Added webhook-triggers.spec.ts: Tests webhook endpoints, conditions, and concurrent requests
- Fixed existing test to work with enhanced HTTP step schema

## Technical Improvements:
- Proper TypeScript interfaces for all new components
- Safe serialization handling for circular references
- Comprehensive logging with structured data
- Modular component architecture with proper exports
- Enhanced collection schemas with conditional field visibility

## Impact:
- Eliminates silent workflow execution failures
- Provides clear error visibility for users
- Makes HTTP requests production-ready with auth and retry capabilities
- Significantly improves debugging and monitoring experience
- Adds comprehensive test coverage for reliability

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-04 11:42:45 +02:00
parent 253de9b8b0
commit 04100787d7
13 changed files with 2574 additions and 74 deletions

View File

@@ -0,0 +1,262 @@
'use client'
import React, { useState } from 'react'
import { Button } from '@payloadcms/ui'
interface ErrorDisplayProps {
value?: string
onChange?: (value: string) => void
readOnly?: boolean
path?: string
}
export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({
value,
onChange,
readOnly = false
}) => {
const [expanded, setExpanded] = useState(false)
if (!value) {
return null
}
// Parse common error patterns
const parseError = (error: string) => {
// Check for different error types and provide user-friendly messages
if (error.includes('Request timeout')) {
return {
type: 'timeout',
title: 'Request Timeout',
message: 'The HTTP request took too long to complete. Consider increasing the timeout value or checking the target server.',
technical: error
}
}
if (error.includes('Network error') || error.includes('fetch')) {
return {
type: 'network',
title: 'Network Error',
message: 'Unable to connect to the target server. Please check the URL and network connectivity.',
technical: error
}
}
if (error.includes('Hook execution failed')) {
return {
type: 'hook',
title: 'Workflow Hook Failed',
message: 'The workflow trigger hook encountered an error. This may be due to PayloadCMS initialization issues.',
technical: error
}
}
if (error.includes('Executor not available')) {
return {
type: 'executor',
title: 'Workflow Engine Unavailable',
message: 'The workflow execution engine is not properly initialized. Try restarting the server.',
technical: error
}
}
if (error.includes('Collection slug is required') || error.includes('Document data is required')) {
return {
type: 'validation',
title: 'Invalid Input Data',
message: 'Required fields are missing from the workflow step configuration. Please check your step inputs.',
technical: error
}
}
if (error.includes('status') && error.includes('4')) {
return {
type: 'client',
title: 'Client Error (4xx)',
message: 'The request was rejected by the server. Check your API credentials and request format.',
technical: error
}
}
if (error.includes('status') && error.includes('5')) {
return {
type: 'server',
title: 'Server Error (5xx)',
message: 'The target server encountered an error. This is usually temporary - try again later.',
technical: error
}
}
// Generic error
return {
type: 'generic',
title: 'Workflow Error',
message: 'An error occurred during workflow execution. See technical details below.',
technical: error
}
}
const errorInfo = parseError(value)
const getErrorIcon = (type: string) => {
switch (type) {
case 'timeout': return '⏰'
case 'network': return '🌐'
case 'hook': return '🔗'
case 'executor': return '⚙️'
case 'validation': return '📋'
case 'client': return '🚫'
case 'server': return '🔥'
default: return '❗'
}
}
const getErrorColor = (type: string) => {
switch (type) {
case 'timeout': return '#F59E0B'
case 'network': return '#EF4444'
case 'hook': return '#8B5CF6'
case 'executor': return '#6B7280'
case 'validation': return '#F59E0B'
case 'client': return '#EF4444'
case 'server': return '#DC2626'
default: return '#EF4444'
}
}
const errorColor = getErrorColor(errorInfo.type)
return (
<div style={{
border: `2px solid ${errorColor}30`,
borderRadius: '8px',
backgroundColor: `${errorColor}08`,
padding: '16px',
marginTop: '8px'
}}>
{/* Error Header */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
marginBottom: '12px'
}}>
<span style={{ fontSize: '24px' }}>
{getErrorIcon(errorInfo.type)}
</span>
<div>
<h4 style={{
margin: 0,
color: errorColor,
fontSize: '16px',
fontWeight: '600'
}}>
{errorInfo.title}
</h4>
<p style={{
margin: '4px 0 0 0',
color: '#6B7280',
fontSize: '14px',
lineHeight: '1.4'
}}>
{errorInfo.message}
</p>
</div>
</div>
{/* Technical Details Toggle */}
<div>
<Button
onClick={() => setExpanded(!expanded)}
size="small"
buttonStyle="secondary"
style={{ marginBottom: expanded ? '12px' : '0' }}
>
{expanded ? 'Hide' : 'Show'} Technical Details
</Button>
{expanded && (
<div style={{
backgroundColor: '#F8F9FA',
border: '1px solid #E5E7EB',
borderRadius: '6px',
padding: '12px',
fontFamily: 'monospace',
fontSize: '13px',
color: '#374151',
whiteSpace: 'pre-wrap',
overflowX: 'auto'
}}>
{errorInfo.technical}
</div>
)}
</div>
{/* Quick Actions */}
<div style={{
marginTop: '12px',
padding: '12px',
backgroundColor: `${errorColor}10`,
borderRadius: '6px',
fontSize: '13px'
}}>
<strong>💡 Quick fixes:</strong>
<ul style={{ margin: '8px 0 0 0', paddingLeft: '20px' }}>
{errorInfo.type === 'timeout' && (
<>
<li>Increase the timeout value in step configuration</li>
<li>Check if the target server is responding slowly</li>
</>
)}
{errorInfo.type === 'network' && (
<>
<li>Verify the URL is correct and accessible</li>
<li>Check firewall and network connectivity</li>
</>
)}
{errorInfo.type === 'hook' && (
<>
<li>Restart the PayloadCMS server</li>
<li>Check server logs for initialization errors</li>
</>
)}
{errorInfo.type === 'executor' && (
<>
<li>Restart the PayloadCMS application</li>
<li>Verify the automation plugin is properly configured</li>
</>
)}
{errorInfo.type === 'validation' && (
<>
<li>Check all required fields are filled in the workflow step</li>
<li>Verify JSONPath expressions in step inputs</li>
</>
)}
{(errorInfo.type === 'client' || errorInfo.type === 'server') && (
<>
<li>Check API credentials and permissions</li>
<li>Verify the request format matches API expectations</li>
<li>Try the request manually to test the endpoint</li>
</>
)}
{errorInfo.type === 'generic' && (
<>
<li>Check the workflow configuration</li>
<li>Review server logs for more details</li>
<li>Try running the workflow again</li>
</>
)}
</ul>
</div>
{/* Hidden textarea for editing if needed */}
{!readOnly && onChange && (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
style={{ display: 'none' }}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,45 @@
'use client'
import React from 'react'
interface StatusCellProps {
cellData: string
}
export const StatusCell: React.FC<StatusCellProps> = ({ cellData }) => {
const getStatusDisplay = (status: string) => {
switch (status) {
case 'pending':
return { icon: '⏳', color: '#6B7280', label: 'Pending' }
case 'running':
return { icon: '🔄', color: '#3B82F6', label: 'Running' }
case 'completed':
return { icon: '✅', color: '#10B981', label: 'Completed' }
case 'failed':
return { icon: '❌', color: '#EF4444', label: 'Failed' }
case 'cancelled':
return { icon: '⏹️', color: '#F59E0B', label: 'Cancelled' }
default:
return { icon: '❓', color: '#6B7280', label: status || 'Unknown' }
}
}
const { icon, color, label } = getStatusDisplay(cellData)
return (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '4px 8px',
borderRadius: '6px',
backgroundColor: `${color}15`,
border: `1px solid ${color}30`,
fontSize: '14px',
fontWeight: '500'
}}>
<span style={{ fontSize: '16px' }}>{icon}</span>
<span style={{ color }}>{label}</span>
</div>
)
}

View File

@@ -0,0 +1,231 @@
'use client'
import React, { useState, useEffect } from 'react'
import { Button } from '@payloadcms/ui'
interface WorkflowRun {
id: string
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
startedAt: string
completedAt?: string
error?: string
triggeredBy: string
}
interface WorkflowExecutionStatusProps {
workflowId: string | number
}
export const WorkflowExecutionStatus: React.FC<WorkflowExecutionStatusProps> = ({ workflowId }) => {
const [runs, setRuns] = useState<WorkflowRun[]>([])
const [loading, setLoading] = useState(true)
const [expanded, setExpanded] = useState(false)
useEffect(() => {
const fetchRecentRuns = async () => {
try {
const response = await fetch(`/api/workflow-runs?where[workflow][equals]=${workflowId}&limit=5&sort=-startedAt`)
if (response.ok) {
const data = await response.json()
setRuns(data.docs || [])
}
} catch (error) {
console.warn('Failed to fetch workflow runs:', error)
} finally {
setLoading(false)
}
}
fetchRecentRuns()
}, [workflowId])
if (loading) {
return (
<div style={{ padding: '16px', color: '#6B7280' }}>
Loading execution history...
</div>
)
}
if (runs.length === 0) {
return (
<div style={{
padding: '16px',
backgroundColor: '#F9FAFB',
border: '1px solid #E5E7EB',
borderRadius: '8px',
color: '#6B7280',
textAlign: 'center'
}}>
📋 No execution history yet
<br />
<small>This workflow hasn't been triggered yet.</small>
</div>
)
}
const getStatusIcon = (status: string) => {
switch (status) {
case 'pending': return ''
case 'running': return '🔄'
case 'completed': return ''
case 'failed': return ''
case 'cancelled': return ''
default: return ''
}
}
const getStatusColor = (status: string) => {
switch (status) {
case 'pending': return '#6B7280'
case 'running': return '#3B82F6'
case 'completed': return '#10B981'
case 'failed': return '#EF4444'
case 'cancelled': return '#F59E0B'
default: return '#6B7280'
}
}
const formatDate = (dateString: string) => {
const date = new Date(dateString)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
if (diffMs < 60000) { // Less than 1 minute
return 'Just now'
} else if (diffMs < 3600000) { // Less than 1 hour
return `${Math.floor(diffMs / 60000)} min ago`
} else if (diffMs < 86400000) { // Less than 1 day
return `${Math.floor(diffMs / 3600000)} hrs ago`
} else {
return date.toLocaleDateString()
}
}
const getDuration = (startedAt: string, completedAt?: string) => {
const start = new Date(startedAt)
const end = completedAt ? new Date(completedAt) : new Date()
const diffMs = end.getTime() - start.getTime()
if (diffMs < 1000) return '<1s'
if (diffMs < 60000) return `${Math.floor(diffMs / 1000)}s`
if (diffMs < 3600000) return `${Math.floor(diffMs / 60000)}m ${Math.floor((diffMs % 60000) / 1000)}s`
return `${Math.floor(diffMs / 3600000)}h ${Math.floor((diffMs % 3600000) / 60000)}m`
}
const recentRun = runs[0]
const recentStatus = getStatusIcon(recentRun.status)
const recentColor = getStatusColor(recentRun.status)
return (
<div style={{
border: '1px solid #E5E7EB',
borderRadius: '8px',
backgroundColor: '#FAFAFA'
}}>
{/* Summary Header */}
<div style={{
padding: '16px',
borderBottom: expanded ? '1px solid #E5E7EB' : 'none',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontSize: '20px' }}>{recentStatus}</span>
<div>
<div style={{ fontWeight: '600', color: recentColor }}>
Last run: {recentRun.status}
</div>
<div style={{ fontSize: '13px', color: '#6B7280' }}>
{formatDate(recentRun.startedAt)} • Duration: {getDuration(recentRun.startedAt, recentRun.completedAt)}
</div>
</div>
</div>
<Button
onClick={() => setExpanded(!expanded)}
size="small"
buttonStyle="secondary"
>
{expanded ? 'Hide' : 'Show'} History ({runs.length})
</Button>
</div>
{/* Detailed History */}
{expanded && (
<div style={{ padding: '16px' }}>
<h4 style={{ margin: '0 0 12px 0', fontSize: '14px', fontWeight: '600' }}>
Recent Executions
</h4>
{runs.map((run, index) => (
<div
key={run.id}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 12px',
marginBottom: index < runs.length - 1 ? '8px' : '0',
backgroundColor: 'white',
border: '1px solid #E5E7EB',
borderRadius: '6px'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<span style={{ fontSize: '16px' }}>
{getStatusIcon(run.status)}
</span>
<div>
<div style={{
fontSize: '13px',
fontWeight: '500',
color: getStatusColor(run.status)
}}>
{run.status.charAt(0).toUpperCase() + run.status.slice(1)}
</div>
<div style={{ fontSize: '12px', color: '#6B7280' }}>
{formatDate(run.startedAt)} • {run.triggeredBy}
</div>
</div>
</div>
<div style={{
fontSize: '12px',
color: '#6B7280',
textAlign: 'right'
}}>
<div>
{getDuration(run.startedAt, run.completedAt)}
</div>
{run.error && (
<div style={{ color: '#EF4444', marginTop: '2px' }}>
Error
</div>
)}
</div>
</div>
))}
<div style={{
marginTop: '12px',
textAlign: 'center'
}}>
<Button
onClick={() => {
// Navigate to workflow runs filtered by this workflow
window.location.href = `/admin/collections/workflow-runs?where[workflow][equals]=${workflowId}`
}}
size="small"
buttonStyle="secondary"
>
View All Runs
</Button>
</div>
</div>
)}
</div>
)
}