diff --git a/apps/http-backend/src/routes/userRoutes/userRoutes.ts b/apps/http-backend/src/routes/userRoutes/userRoutes.ts index aab360d..91ea6af 100644 --- a/apps/http-backend/src/routes/userRoutes/userRoutes.ts +++ b/apps/http-backend/src/routes/userRoutes/userRoutes.ts @@ -743,7 +743,8 @@ router.get("/workflow/logs/:workflowId", userMiddleware, async (req: AuthRequest where: { workflowId: workflowId }, include: { nodeExecutions: { include: { node: true } }, - } + }, + orderBy: { startAt: "desc" }, }) if (!executions) { diff --git a/apps/web/app/components/ExecutionHistoryFooter.tsx b/apps/web/app/components/ExecutionHistoryFooter.tsx index 95ad1d7..2cc58f0 100644 --- a/apps/web/app/components/ExecutionHistoryFooter.tsx +++ b/apps/web/app/components/ExecutionHistoryFooter.tsx @@ -1,80 +1,194 @@ 'use client'; -import React, { useEffect, useState, useRef } from 'react'; -import { WorkflowExecutionLog } from '@/app/types/execution.types'; +import React, { useEffect, useState, useRef, useCallback } from 'react'; +import { WorkflowExecutionLog, NodeExecutionLog } from '@/app/types/execution.types'; import { formatDate, - getStatusColor, - getStatusLabel, + formatDuration, + getStatusIcon, } from '@/app/lib/formatters'; interface ExecutionHistoryFooterProps { workflowId: string; onExecutionFetch: (executions: WorkflowExecutionLog[]) => void; isLoading?: boolean; - autoRefreshInterval?: number; // in milliseconds, default 5000 (5 seconds) + autoRefreshInterval?: number; } -// Inline table component for simplicity -const ExecutionListTable: React.FC<{ - executions: WorkflowExecutionLog[]; - selectedExecution?: WorkflowExecutionLog | null; - onSelectExecution: (execution: WorkflowExecutionLog) => void; - onLoadMore: () => void; - isLoading?: boolean; - hasMore?: boolean; -}> = ({ executions, selectedExecution, onSelectExecution, isLoading }) => { - if (executions.length === 0 && !isLoading) { - return ( -
-

No executions found

-
- ); - } +// ─── Status Badge ─── +const StatusBadge: React.FC<{ status: string }> = ({ status }) => { + const styles: Record = { + Completed: 'background:linear-gradient(135deg,#059669,#10b981);color:#ecfdf5;', + Failed: 'background:linear-gradient(135deg,#dc2626,#ef4444);color:#fef2f2;', + InProgress: 'background:linear-gradient(135deg,#2563eb,#3b82f6);color:#eff6ff;', + Pending: 'background:linear-gradient(135deg,#d97706,#f59e0b);color:#fffbeb;', + Start: 'background:linear-gradient(135deg,#6b7280,#9ca3af);color:#f9fafb;', + ReConnecting: 'background:linear-gradient(135deg,#ea580c,#f97316);color:#fff7ed;', + }; + return ( + { + const [k, v] = s.split(':'); + return [k!.trim().replace(/-([a-z])/g, (_, l) => l.toUpperCase()), v!.trim()]; + })), + padding: '3px 10px', + borderRadius: '6px', + fontSize: '11px', + fontWeight: 600, + letterSpacing: '0.3px', + display: 'inline-flex', + alignItems: 'center', + gap: '4px', + whiteSpace: 'nowrap' as const, + }} + > + {getStatusIcon(status)} {status} + + ); +}; +// ─── Test Badge ─── +const TestBadge: React.FC<{ isTest: boolean }> = ({ isTest }) => { + if (!isTest) return ; return ( -
- - - - - - - - - - {executions.map((execution) => { - const isSelected = selectedExecution?.id === execution.id; - const statusColor = getStatusColor(execution.status); + + TEST + + ); +}; - return ( - onSelectExecution(execution)} - className={`border-b border-gray-200 dark:border-gray-700 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 ${ - isSelected ? 'bg-blue-50 dark:bg-blue-900/30' : '' - }`} - > - - - - - ); - })} - -
Started AtStatusError
- {formatDate(execution.startAt)} - - - {getStatusLabel(execution.status)} - - - {execution.error ? execution.error.substring(0, 40) + '...' : '—'} -
+// ─── Collapsible JSON Viewer ─── +const JsonViewer: React.FC<{ data: any; label: string; accent: string }> = ({ data, label, accent }) => { + const [open, setOpen] = useState(false); + if (data === null || data === undefined) return null; + + const jsonStr = typeof data === 'string' ? data : JSON.stringify(data, null, 2); + + return ( +
+ + {open && ( +
+          {jsonStr}
+        
+ )}
); }; +// ─── Node Execution Detail Row ─── +const NodeExecutionRow: React.FC<{ nodeExec: NodeExecutionLog }> = ({ nodeExec }) => { + const [expanded, setExpanded] = useState(false); + const nodeName = nodeExec.node?.name || 'Unknown Node'; + + return ( + <> + setExpanded(!expanded)} + style={{ + cursor: 'pointer', + transition: 'background 0.15s ease', + }} + onMouseEnter={e => (e.currentTarget.style.background = '#141b2d')} + onMouseLeave={e => (e.currentTarget.style.background = 'transparent')} + > + + + {expanded ? '▾' : '▸'} + + {nodeName} + + + + + + + + + {formatDate(nodeExec.startedAt)} + + + {nodeExec.completedAt ? formatDate(nodeExec.completedAt) : '—'} + + + {formatDuration(nodeExec.startedAt, nodeExec.completedAt || undefined)} + + + {nodeExec.error ? nodeExec.error.substring(0, 60) + (nodeExec.error.length > 60 ? '...' : '') : '—'} + + + {expanded && ( + + +
+ + +
+ {nodeExec.error && ( +
+ Error: {nodeExec.error} +
+ )} + + + )} + + ); +}; + +// ─── Main Component ─── export default function ExecutionHistoryFooter({ workflowId, onExecutionFetch, @@ -89,11 +203,42 @@ export default function ExecutionHistoryFooter({ const refreshIntervalRef = useRef(null); const lastFetchRef = useRef(0); + // Detect sidebar state via CSS custom property / DOM attribute + const [sidebarWidth, setSidebarWidth] = useState(256); // 16rem default + + useEffect(() => { + const observeSidebar = () => { + const wrapper = document.querySelector('[data-slot="sidebar-wrapper"]'); + if (!wrapper) return; + + const observer = new MutationObserver(() => { + const sidebarEl = document.querySelector('[data-slot="sidebar"]'); + if (sidebarEl) { + const state = sidebarEl.getAttribute('data-state'); + setSidebarWidth(state === 'collapsed' ? 48 : 256); + } + }); + + observer.observe(wrapper, { attributes: true, subtree: true, attributeFilter: ['data-state'] }); + + // Initial check + const sidebarEl = document.querySelector('[data-slot="sidebar"]'); + if (sidebarEl) { + const state = sidebarEl.getAttribute('data-state'); + setSidebarWidth(state === 'collapsed' ? 48 : 256); + } + + return () => observer.disconnect(); + }; + + const cleanup = observeSidebar(); + return () => cleanup?.(); + }, []); + // Load initial execution history useEffect(() => { fetchExecutionLogs(); - // Initialize localStorage state const savedExpandedState = localStorage.getItem('executionFooterExpanded'); if (savedExpandedState !== null) { setIsExpanded(JSON.parse(savedExpandedState)); @@ -110,13 +255,10 @@ export default function ExecutionHistoryFooter({ return; } - // Initial fetch immediately fetchExecutionLogs(); - // Setup polling interval refreshIntervalRef.current = setInterval(() => { const now = Date.now(); - // Only fetch if enough time has passed since last fetch if (now - lastFetchRef.current >= autoRefreshInterval) { fetchExecutionLogs(); } @@ -130,15 +272,14 @@ export default function ExecutionHistoryFooter({ }; }, [autoRefreshEnabled, autoRefreshInterval]); - // Persist expanded state to localStorage + // Persist expanded state useEffect(() => { localStorage.setItem('executionFooterExpanded', JSON.stringify(isExpanded)); }, [isExpanded]); - const fetchExecutionLogs = async () => { + const fetchExecutionLogs = useCallback(async () => { try { setFooterLoading(true); - // Import API dynamically to avoid issues const { api } = await import('@/app/lib/api'); const logs = await api.executions.getWorkflowLogs(workflowId, 0, 20); const executionsList = Array.isArray(logs) ? logs : []; @@ -150,140 +291,373 @@ export default function ExecutionHistoryFooter({ } finally { setFooterLoading(false); } + }, [workflowId, onExecutionFetch]); + + const hasAnyTest = (exec: WorkflowExecutionLog) => { + return exec.nodeExecutions?.some(ne => ne.isTest) || false; }; const lastExecution = executions[0]; - const lastStatus = lastExecution ? getStatusLabel(lastExecution.status) : 'No executions'; + const lastStatus = lastExecution?.status || 'No executions'; return ( <> - {/* Sticky Footer Taskbar */} -
-
- {/* Left side - Title and status */} -
- - 📊 Execution History - - - Last: {lastStatus} - -
+ {/* ── Sticky Footer Bar ── */} +
+ {/* Left */} +
+ + 📊 Executions + + + Last: + + + {executions.length} runs + +
- {/* Right side - Action buttons */} -
- - - -
+ {/* Right */} +
+ + +
- {/* Expanded Panel */} + {/* ── Expanded Panel ── */} {isExpanded && ( -
-
- {/* Header */} -
-

Execution History

- -
+
+ {/* Panel Header */} +
+

+ {selectedExecution ? ( + <> + + Node Executions + + {selectedExecution.id.substring(0, 8)}… + + + ) : ( + 'Workflow Executions' + )} +

+ +
- {/* Content */} -
- {footerLoading && executions.length === 0 ? ( -
-
-
-

Loading executions...

+ {/* Panel Body */} +
+ {footerLoading && executions.length === 0 ? ( +
+
+
+

Loading executions…

+ +
+
+ ) : !selectedExecution ? ( + /* ─── Level 1: Workflow Executions Table ─── */ + + + + {['#', 'Execution ID', 'Status', 'Test', 'Started', 'Completed', 'Duration', 'Nodes', 'Error'].map(h => ( + + ))} + + + + {executions.length === 0 ? ( + + + + ) : ( + executions.map((exec, idx) => ( + setSelectedExecution(exec)} + style={{ + cursor: 'pointer', + borderBottom: '1px solid #111827', + transition: 'background 0.15s ease', + }} + onMouseEnter={e => (e.currentTarget.style.background = '#141b2d')} + onMouseLeave={e => (e.currentTarget.style.background = 'transparent')} + > + + + + + + + + + + + )) + )} + +
{h}
+ No executions found. Execute your workflow to see results here. +
+ {executions.length - idx} + + {exec.id.substring(0, 8)}… + + + + + + {formatDate(exec.startAt)} + + {exec.completedAt ? formatDate(exec.completedAt) : '—'} + + {formatDuration(exec.startAt, exec.completedAt || undefined)} + + + {exec.nodeExecutions?.length || 0} + + + {exec.error ? exec.error.substring(0, 50) + (exec.error.length > 50 ? '…' : '') : '—'} +
+ ) : ( + /* ─── Level 2: Node Executions Table ─── */ +
+ {/* Execution Summary Bar */} +
+
+ Status: + +
+
+ Started: + {formatDate(selectedExecution.startAt)} +
+
+ Duration: + + {formatDuration(selectedExecution.startAt, selectedExecution.completedAt || undefined)} + +
+
+ Nodes: + {selectedExecution.nodeExecutions?.length || 0}
- ) : ( - <> - {/* Executions Table */} -
- {}} - isLoading={footerLoading} - hasMore={false} - /> + + {/* Error Banner */} + {selectedExecution.error && ( +
+ Workflow Error: {selectedExecution.error}
+ )} - {/* Selected Execution Details */} - {selectedExecution && ( -
-

- Execution Details -

-
-
-

Status:

-

- {getStatusLabel(selectedExecution.status)} -

-
-
-

Started:

-

- {formatDate(selectedExecution.startAt)} -

-
- {selectedExecution.error && ( -
-

Error:

-

{selectedExecution.error}

-
- )} -
-

Nodes executed:

-

- {selectedExecution.nodeExecutions?.length || 0} -

-
-
-
- )} - - )} -
+ {/* Node Executions Table */} + + + + {['Node Name', 'Status', 'Test', 'Started', 'Completed', 'Duration', 'Error'].map(h => ( + + ))} + + + + {(!selectedExecution.nodeExecutions || selectedExecution.nodeExecutions.length === 0) ? ( + + + + ) : ( + selectedExecution.nodeExecutions.map(ne => ( + + )) + )} + +
{h}
+ No node executions recorded. +
+
+ )}
)} - - {/* Add padding to page if footer is expanded */} - {isExpanded &&
} ); } diff --git a/apps/web/app/workflows/[id]/components/ConfigModal.tsx b/apps/web/app/workflows/[id]/components/ConfigModal.tsx index aa8b980..d0bc3ee 100644 --- a/apps/web/app/workflows/[id]/components/ConfigModal.tsx +++ b/apps/web/app/workflows/[id]/components/ConfigModal.tsx @@ -440,7 +440,7 @@ export default function ConfigModal({ ); } - if (field.type === "text") { + if (field.type === "textarea") { return (