From f586ecd9846fe204a31ab13a619162928dba5308 Mon Sep 17 00:00:00 2001 From: BUDUMURU SRINIVAS SAI SARAN TEJA Date: Thu, 16 Apr 2026 14:06:30 +0530 Subject: [PATCH] feat: implement execution logging for workflows - Added execution logging functionality to track workflow and node executions. - Created new API endpoint to fetch execution logs for a specific workflow. - Introduced ExecutionHistoryFooter component to display execution history in the UI. - Enhanced error handling and formatting for better user experience. - Updated Redux slice to manage execution state and pagination. - Added utility functions for formatting dates and statuses. - Integrated execution logs into the existing workflow management system. --- .../src/routes/userRoutes/executionRoutes.ts | 76 ++++- .../src/routes/userRoutes/userRoutes.ts | 130 +++++--- .../app/components/ExecutionHistoryFooter.tsx | 289 ++++++++++++++++++ apps/web/app/lib/api.ts | 16 + apps/web/app/lib/errorHelpers.ts | 122 ++++++++ apps/web/app/lib/formatters.ts | 157 ++++++++++ apps/web/app/types/execution.types.ts | 69 +++++ apps/web/app/workflows/[id]/page.tsx | 6 + apps/web/store/root-reducer.ts | 7 +- apps/web/store/slices/executionSlice.ts | 123 ++++++++ packages/common/src/index.ts | 46 +++ packages/db/prisma/schema.prisma | 1 + 12 files changed, 979 insertions(+), 63 deletions(-) create mode 100644 apps/web/app/components/ExecutionHistoryFooter.tsx create mode 100644 apps/web/app/lib/errorHelpers.ts create mode 100644 apps/web/app/lib/formatters.ts create mode 100644 apps/web/app/types/execution.types.ts create mode 100644 apps/web/store/slices/executionSlice.ts diff --git a/apps/http-backend/src/routes/userRoutes/executionRoutes.ts b/apps/http-backend/src/routes/userRoutes/executionRoutes.ts index df43c15..b3687cd 100644 --- a/apps/http-backend/src/routes/userRoutes/executionRoutes.ts +++ b/apps/http-backend/src/routes/userRoutes/executionRoutes.ts @@ -29,23 +29,75 @@ execRouter.post('/node', userMiddleware, async(req: AuthRequest, res: Response) if(nodeData){ const type = nodeData.AvailableNode.type const config = dataSafe.data.Config ? dataSafe.data.Config : nodeData.config // for test api data prefered fist then config in db - console.log(`config and type: ${JSON.stringify(config)} & ${type}`) + // // console.log(`config and type: ${JSON.stringify(config)} & ${type}`) const context = { userId: req.user.sub, config: config, credentialId: nodeData.CredentialsID || config?.credentialId || "" } - - console.log(`Execution context: ${JSON.stringify(context)}`) - const executionResult = await ExecutionRegister.execute(type, context) - - console.log(`Execution result: ${executionResult}`) - - if(executionResult.success) - return res.status(statusCodes.ACCEPTED).json({ - message: `${nodeData.name} node execution done` , - data: executionResult + const result = await prismaClient.$transaction(async(tx)=>{ + // // console.log(`Execution context: ${JSON.stringify(context)}`) + const workflowExecution = await tx.workflowExecution.create({ + data:{ + workflowId: nodeData.workflowId || "", + status: "Start", + startAt: new Date(), + metadata:{"isTesting": true}, + } + }) + const NodeExecution = await tx.nodeExecution.create({ + data:{ + status: "Start", + nodeId: nodeData.id, + workflowExecId: workflowExecution.id, + startedAt: new Date(), + inputData: context, + isTest: true + } + }) + const executionResult = await ExecutionRegister.execute(type, context) + + + // console.log(`Execution result: ${executionResult}`) + + if(executionResult.success){ + await tx.nodeExecution.update({ + where: { id: NodeExecution.id}, + data:{ + status: "Completed", + outputData: executionResult.output, + completedAt: new Date() + } + }) + await tx.workflowExecution.update({ + where: {id: workflowExecution.id}, + data: { + status: "Completed", + completedAt: new Date() + } + }) + return res.status(statusCodes.ACCEPTED).json({ + message: `${nodeData.name} node execution done` , + data: executionResult + }) + } + await tx.nodeExecution.update({ + where: {id : NodeExecution.id}, + data:{ + status: "Failed", + completedAt: new Date(), + error: executionResult.error + } + }) + await tx.workflowExecution.update({ + where: {id: workflowExecution.id}, + data:{ + status: "Failed", + completedAt: new Date(), + error: executionResult.output + } + }) }) return res.status(statusCodes.FORBIDDEN).json({ @@ -57,7 +109,7 @@ execRouter.post('/node', userMiddleware, async(req: AuthRequest, res: Response) }) }catch(e){ - console.log("This is the error from executing node", e); + // console.log("This is the error from executing node", e); return res.status(statusCodes.INTERNAL_SERVER_ERROR).json({ message: "Internal server Error from node execution ", }); diff --git a/apps/http-backend/src/routes/userRoutes/userRoutes.ts b/apps/http-backend/src/routes/userRoutes/userRoutes.ts index 50664ab..aab360d 100644 --- a/apps/http-backend/src/routes/userRoutes/userRoutes.ts +++ b/apps/http-backend/src/routes/userRoutes/userRoutes.ts @@ -44,15 +44,14 @@ router.post("/createAvaliableNode", async (req: AuthRequest, res: Response) => { Data: createNode, }); } catch (e) { - console.log("This is the error from Node creating", e); + // console.log("This is the error from Node creating", e); return res.status(statusCodes.INTERNAL_SERVER_ERROR).json({ message: "Internal server Error from Node creation", }); } }); -router.get( - "/getAvailableNodes", +router.get("/getAvailableNodes", userMiddleware, async (req: AuthRequest, res: Response) => { if (!req.user) { @@ -77,8 +76,7 @@ router.get( } ); -router.post( - "/createAvaliableTriggers", +router.post("/createAvaliableTriggers", // userMiddleware, async (req: Request, res: Response) => { try { @@ -112,12 +110,11 @@ router.post( } ); -router.get( - "/getAvailableTriggers", +router.get("/getAvailableTriggers", userMiddleware, async (req: AuthRequest, res: Response) => { try { - console.log("RequestRecieved from the frontend"); + // console.log("RequestRecieved from the frontend"); if (!req.user) return res .status(statusCodes.UNAUTHORIZED) @@ -138,8 +135,7 @@ router.get( ); -router.get( - "/getCredentials/:type", +router.get("/getCredentials/:type", userMiddleware, async (req: AuthRequest, res: Response) => { try { @@ -151,7 +147,7 @@ router.get( } const userId = req.user.sub; const type = req.params.type; - console.log("The type of data comming to backed is ", type) + // console.log("The type of data comming to backed is ", type) // console.log(userId, " -userid"); if (!type || !userId) { @@ -208,8 +204,7 @@ router.get( } ); -router.get( - "/getAllCreds", +router.get("/getAllCreds", userMiddleware, async (req: AuthRequest, res: Response) => { try { @@ -241,8 +236,7 @@ router.get( ); // ----------------------------------- CREATE WORKFLOW --------------------------------- -router.post( - "/create/workflow", +router.post("/create/workflow", userMiddleware, async (req: AuthRequest, res: Response) => { @@ -289,8 +283,7 @@ router.post( // ------------------------------------ FETCHING WORKFLOWS ----------------------------------- -router.get( - "/workflows", +router.get("/workflows", userMiddleware, async (req: AuthRequest, res: Response) => { try { @@ -305,7 +298,7 @@ router.get( userId, }, }); - console.log(workflows); + // console.log(workflows); return res .status(statusCodes.OK) .json({ message: "Workflows fetched succesfullu", Data: workflows }); @@ -319,8 +312,7 @@ router.get( } ); -router.get( - "/empty/workflow", +router.get("/empty/workflow", userMiddleware, async (req: AuthRequest, res: Response) => { try { @@ -354,8 +346,7 @@ router.get( } ); -router.get( - "/workflow/:workflowId", +router.get("/workflow/:workflowId", userMiddleware, async (req: AuthRequest, res: Response) => { try { @@ -373,19 +364,20 @@ router.get( }, include: { Trigger: { - include: { + include: { triggerType: { - select:{icon : true} + select: { icon: true } } } }, - nodes: { orderBy: { stage: "asc" }, - include: { - AvailableNode: { - select: { icon: true} + nodes: { + orderBy: { stage: "asc" }, + include: { + AvailableNode: { + select: { icon: true } + } } - } - }, + }, }, }); if (!getWorkflow) { @@ -400,7 +392,7 @@ router.get( icon: getWorkflow.Trigger.triggerType.icon || null, AvailableTrigger: undefined } : null, - nodes: getWorkflow.nodes.map(node=> ({ + nodes: getWorkflow.nodes.map(node => ({ ...node, icon: node.AvailableNode.icon || null, AvailableNode: undefined @@ -469,8 +461,7 @@ router.put("/workflow/update", userMiddleware, async (req: AuthRequest, res: Res //------------------------------------------ TRIGGER AND NODE CREATION ------------------------------ //TRIGGER CREATION -router.post( - "/create/trigger", +router.post("/create/trigger", userMiddleware, async (req: AuthRequest, res: Response) => { try { @@ -481,7 +472,7 @@ router.post( } const data = req.body; const dataSafe = TriggerSchema.safeParse(data); - console.log("The error from creation of trigger is ", dataSafe.error); + // console.log("The error from creation of trigger is ", dataSafe.error); if (!dataSafe.success) return res.status(statusCodes.BAD_REQUEST).json({ @@ -529,8 +520,7 @@ router.post( ); //NODE CREATION -router.post( - "/create/node", +router.post("/create/node", userMiddleware, async (req: AuthRequest, res: Response) => { try { @@ -543,7 +533,7 @@ router.post( // console.log(" from http-backeden", data); const dataSafe = NodeSchema.safeParse(data); - console.log("The error is ", dataSafe.error); + // console.log("The error is ", dataSafe.error); if (!dataSafe.success) { return res.status(statusCodes.BAD_REQUEST).json({ message: "Invalid input", @@ -553,7 +543,7 @@ router.post( // Use an empty array for credentials (if required) or don't pass it at all // Config must be valid JSON (not an empty string) // const stage = dataSafe.data.Position - console.log("This is from the backend log of positions", dataSafe.data.position) + // console.log("This is from the backend log of positions", dataSafe.data.position) const createdNode = await prismaClient.node.create({ data: { name: dataSafe.data.Name, @@ -607,9 +597,9 @@ router.put("/update/node", where: { id: dataSafe.data.NodeId }, data: { - ...(dataSafe.data.position !== undefined ? { position: dataSafe.data.position } : {}), - ...(dataSafe.data.Config !== undefined ? { config: dataSafe.data.Config } : {}), - ...(dataSafe.data.Config?.credentialId ? { CredentialsID: dataSafe.data.Config.credentialId } : {}) + ...(dataSafe.data.position !== undefined ? { position: dataSafe.data.position } : {}), + ...(dataSafe.data.Config !== undefined ? { config: dataSafe.data.Config } : {}), + ...(dataSafe.data.Config?.credentialId ? { CredentialsID: dataSafe.data.Config.credentialId } : {}) } }); @@ -647,9 +637,9 @@ router.put("/update/trigger", const updatedTrigger = await prismaClient.trigger.update({ where: { id: dataSafe.data.TriggerId }, data: { - ...(dataSafe.data.Config !== undefined ? { config: dataSafe.data.Config} : {}) , - ...(dataSafe.data.CredentialID !== undefined ? { CredentialsID: dataSafe.data.CredentialID} : {}), - ...(dataSafe.data.Position !== undefined ? {Position: dataSafe.data.Position} : {}) + ...(dataSafe.data.Config !== undefined ? { config: dataSafe.data.Config } : {}), + ...(dataSafe.data.CredentialID !== undefined ? { CredentialsID: dataSafe.data.CredentialID } : {}), + ...(dataSafe.data.Position !== undefined ? { Position: dataSafe.data.Position } : {}) }, }); @@ -668,7 +658,7 @@ router.put("/update/trigger", ); router.post("/executeWorkflow", userMiddleware, async (req: AuthRequest, res: Response) => { - console.log("REcieved REquest to the execute route ") + // console.log("REcieved REquest to the execute route ") const Data = req.body if (!req.user) { return res.status(statusCodes.UNAUTHORIZED).json({ @@ -676,7 +666,7 @@ router.post("/executeWorkflow", userMiddleware, async (req: AuthRequest, res: Re }) } const parsedData = ExecuteWorkflow.safeParse(Data); - console.log("This is the log data of execute work flow zod", parsedData.error) + // console.log("This is the log data of execute work flow zod", parsedData.error) if (!parsedData.success) { return res.status(statusCodes.BAD_REQUEST).json({ message: "Error in Zod Schma", @@ -697,8 +687,8 @@ router.post("/executeWorkflow", userMiddleware, async (req: AuthRequest, res: Re message: "Workflow not found or not authorized" }); } - console.log("This is the Trigger Name of the workflow", trigger?.Trigger?.name) - console.log("This is the Trigger Data of the workflow", trigger) + // console.log("This is the Trigger Name of the workflow", trigger?.Trigger?.name) + // console.log("This is the Trigger Data of the workflow", trigger) if (trigger?.Trigger?.name === "webhook") { const data = await axios.post(`${HOOKS_URL}/hooks/catch/${userId}/${workflowId}`, { @@ -706,7 +696,7 @@ router.post("/executeWorkflow", userMiddleware, async (req: AuthRequest, res: Re }, { timeout: 30000 },) - console.log("Workflow Execution for webhook started with Execution Id is ", data.data.workflowExecutionId) + // console.log("Workflow Execution for webhook started with Execution Id is ", data.data.workflowExecutionId) const workflowExecutionId = data.data.workflowExecutionId; if (!workflowExecutionId) { return res.status(statusCodes.INTERNAL_SERVER_ERROR).json({ @@ -735,6 +725,48 @@ router.post("/executeWorkflow", userMiddleware, async (req: AuthRequest, res: Re } }) + +router.get("/workflow/logs/:workflowId", userMiddleware, async (req: AuthRequest, res) => { + try { + // if (!req.user) + // return res + // .status(statusCodes.UNAUTHORIZED) + // .json({ message: "User isnot logged in /not authorized" }); + // const userId = req.user.sub; + const workflowId = req.params.workflowId; + if (!workflowId) + return res.status(statusCodes.BAD_REQUEST).json({ + message: "Invalid input" + }) + + const executions = await prismaClient.workflowExecution.findMany({ + where: { workflowId: workflowId }, + include: { + nodeExecutions: { include: { node: true } }, + } + }) + + if (!executions) { + return res.status(statusCodes.NOT_FOUND).json({ + message: `logs not found for ${workflowId}` + }) + } + + return res.status(statusCodes.ACCEPTED).json({ + message: `logs found for ${workflowId}`, + data: executions + }) + + } + catch (e) { + console.log("Error workflow logs:", e); + return res.status(statusCodes.INTERNAL_SERVER_ERROR).json({ + message: "Internal Server Error from workflow logs", + error: e instanceof Error ? e.message : "Unknown error" + }) + } +}) + router.get("/protected", userMiddleware, (req: AuthRequest, res) => { return res.json({ ok: true, diff --git a/apps/web/app/components/ExecutionHistoryFooter.tsx b/apps/web/app/components/ExecutionHistoryFooter.tsx new file mode 100644 index 0000000..95ad1d7 --- /dev/null +++ b/apps/web/app/components/ExecutionHistoryFooter.tsx @@ -0,0 +1,289 @@ +'use client'; + +import React, { useEffect, useState, useRef } from 'react'; +import { WorkflowExecutionLog } from '@/app/types/execution.types'; +import { + formatDate, + getStatusColor, + getStatusLabel, +} from '@/app/lib/formatters'; + +interface ExecutionHistoryFooterProps { + workflowId: string; + onExecutionFetch: (executions: WorkflowExecutionLog[]) => void; + isLoading?: boolean; + autoRefreshInterval?: number; // in milliseconds, default 5000 (5 seconds) +} + +// 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

+
+ ); + } + + return ( +
+ + + + + + + + + + {executions.map((execution) => { + const isSelected = selectedExecution?.id === execution.id; + const statusColor = getStatusColor(execution.status); + + 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) + '...' : '—'} +
+
+ ); +}; + +export default function ExecutionHistoryFooter({ + workflowId, + onExecutionFetch, + isLoading = false, + autoRefreshInterval = 5000, +}: ExecutionHistoryFooterProps) { + const [isExpanded, setIsExpanded] = useState(false); + const [executions, setExecutions] = useState([]); + const [selectedExecution, setSelectedExecution] = useState(null); + const [footerLoading, setFooterLoading] = useState(false); + const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true); + const refreshIntervalRef = useRef(null); + const lastFetchRef = useRef(0); + + // Load initial execution history + useEffect(() => { + fetchExecutionLogs(); + + // Initialize localStorage state + const savedExpandedState = localStorage.getItem('executionFooterExpanded'); + if (savedExpandedState !== null) { + setIsExpanded(JSON.parse(savedExpandedState)); + } + }, [workflowId]); + + // Setup auto-refresh polling + useEffect(() => { + if (!autoRefreshEnabled) { + if (refreshIntervalRef.current) { + clearInterval(refreshIntervalRef.current); + refreshIntervalRef.current = null; + } + 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(); + } + }, autoRefreshInterval); + + return () => { + if (refreshIntervalRef.current) { + clearInterval(refreshIntervalRef.current); + refreshIntervalRef.current = null; + } + }; + }, [autoRefreshEnabled, autoRefreshInterval]); + + // Persist expanded state to localStorage + useEffect(() => { + localStorage.setItem('executionFooterExpanded', JSON.stringify(isExpanded)); + }, [isExpanded]); + + const fetchExecutionLogs = 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 : []; + setExecutions(executionsList); + onExecutionFetch(executionsList); + lastFetchRef.current = Date.now(); + } catch (error) { + console.error('Failed to fetch execution logs:', error); + } finally { + setFooterLoading(false); + } + }; + + const lastExecution = executions[0]; + const lastStatus = lastExecution ? getStatusLabel(lastExecution.status) : 'No executions'; + + return ( + <> + {/* Sticky Footer Taskbar */} +
+
+ {/* Left side - Title and status */} +
+ + 📊 Execution History + + + Last: {lastStatus} + +
+ + {/* Right side - Action buttons */} +
+ + + +
+
+
+ + {/* Expanded Panel */} + {isExpanded && ( +
+
+ {/* Header */} +
+

Execution History

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

Loading executions...

+
+
+ ) : ( + <> + {/* Executions Table */} +
+ {}} + isLoading={footerLoading} + hasMore={false} + /> +
+ + {/* 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} +

+
+
+
+ )} + + )} +
+
+
+ )} + + {/* Add padding to page if footer is expanded */} + {isExpanded &&
} + + ); +} diff --git a/apps/web/app/lib/api.ts b/apps/web/app/lib/api.ts index c20312f..d9b17e4 100644 --- a/apps/web/app/lib/api.ts +++ b/apps/web/app/lib/api.ts @@ -146,5 +146,21 @@ export const api = { const errorMessage = res.data?.data?.error || res.data?.message || "Execution failed"; throw new Error(errorMessage); } + }, + executions: { + // Fetch execution logs for a workflow + getWorkflowLogs: async (workflowId: string, skip: number = 0, take: number = 20) => { + const res = await axios.get(`${BACKEND_URL}/user/workflow/logs/${workflowId}`, { + params: { skip, take }, + withCredentials: true, + headers: { "Content-Type": "application/json" }, + }); + + if (res.data?.data) { + return res.data.data; + } + + throw new Error(res.data?.message || "Failed to fetch execution logs"); + } } }; diff --git a/apps/web/app/lib/errorHelpers.ts b/apps/web/app/lib/errorHelpers.ts new file mode 100644 index 0000000..14f4bb3 --- /dev/null +++ b/apps/web/app/lib/errorHelpers.ts @@ -0,0 +1,122 @@ +// Error formatting helpers for naive user consumption + +export const formatErrorMessage = (error: string | null | undefined): string => { + if (!error) return '—'; + + try { + // Remove common technical jargon and stack traces + let message = error + .replace(/Error: /g, '') + .replace(/TypeError: /g, '') + .replace(/ReferenceError: /g, '') + .replace(/SyntaxError: /g, '') + .replace(/at \w+.*:\d+:\d+/g, '') + .replace(/\n.*$/gs, '') + .trim(); + + // If error starts with a backtick, it's likely JSON or code, don't modify + if (message.startsWith('`')) { + return message.slice(1, -1); + } + + // Truncate very long errors to be user-friendly + if (message.length > 200) { + message = message.substring(0, 200) + '...'; + } + + return message || 'Unknown error occurred'; + } catch { + return String(error).substring(0, 200); + } +}; + +export const getErrorSeverity = (error: string | null | undefined): 'critical' | 'warning' | 'info' => { + if (!error) return 'info'; + + const errorLower = error.toLowerCase(); + + // Critical errors + if ( + errorLower.includes('fail') || + errorLower.includes('error') || + errorLower.includes('fatal') || + errorLower.includes('crash') || + errorLower.includes('exception') + ) { + return 'critical'; + } + + // Warnings + if ( + errorLower.includes('warn') || + errorLower.includes('timeout') || + errorLower.includes('retry') || + errorLower.includes('deprecated') || + errorLower.includes('connection') + ) { + return 'warning'; + } + + return 'info'; +}; + +export interface ErrorContext { + level: 'workflow' | 'node'; + nodeId?: string; + nodeName?: string; + message: string; + severity: 'critical' | 'warning' | 'info'; +} + +export const extractErrorContext = ( + workflowError: string | null | undefined, + nodeErrors: Array<{ nodeId: string; nodeName?: string; error: string | null }> = [] +): ErrorContext[] => { + const errors: ErrorContext[] = []; + + // Add workflow-level error if present + if (workflowError) { + errors.push({ + level: 'workflow', + message: formatErrorMessage(workflowError), + severity: getErrorSeverity(workflowError), + }); + } + + // Add node-level errors if present + for (const nodeError of nodeErrors) { + if (nodeError.error) { + errors.push({ + level: 'node', + nodeId: nodeError.nodeId, + nodeName: nodeError.nodeName, + message: formatErrorMessage(nodeError.error), + severity: getErrorSeverity(nodeError.error), + }); + } + } + + return errors; +}; + +export const getSeverityColor = (severity: 'critical' | 'warning' | 'info'): string => { + switch (severity) { + case 'critical': + return 'text-red-700 dark:text-red-400'; + case 'warning': + return 'text-yellow-700 dark:text-yellow-400'; + case 'info': + return 'text-blue-700 dark:text-blue-400'; + } +}; + +export const getSeverityIcon = (severity: 'critical' | 'warning' | 'info'): string => { + switch (severity) { + case 'critical': + return 'âš ī¸'; + case 'warning': + return '⚡'; + case 'info': + return 'â„šī¸'; + } +}; diff --git a/apps/web/app/lib/formatters.ts b/apps/web/app/lib/formatters.ts new file mode 100644 index 0000000..aac56ba --- /dev/null +++ b/apps/web/app/lib/formatters.ts @@ -0,0 +1,157 @@ +// Utility formatters for execution history display + +export const formatDate = (date: Date | string | undefined): string => { + if (!date) return '—'; + + try { + const dateObj = typeof date === 'string' ? new Date(date) : date; + + if (isNaN(dateObj.getTime())) { + return '—'; + } + + return dateObj.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: true, + }); + } catch { + return '—'; + } +}; + +export const formatDuration = ( + startDate: Date | string | undefined, + endDate: Date | string | undefined +): string => { + if (!startDate || !endDate) return '—'; + + try { + const start = typeof startDate === 'string' ? new Date(startDate) : startDate; + const end = typeof endDate === 'string' ? new Date(endDate) : endDate; + + if (isNaN(start.getTime()) || isNaN(end.getTime())) { + return '—'; + } + + const diffMs = Math.max(0, end.getTime() - start.getTime()); + const totalSeconds = Math.floor(diffMs / 1000); + + if (totalSeconds === 0) { + return '0s'; + } + + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const parts: string[] = []; + + if (hours > 0) { + parts.push(`${hours}h`); + } + if (minutes > 0) { + parts.push(`${minutes}m`); + } + if (seconds > 0 || parts.length === 0) { + parts.push(`${seconds}s`); + } + + return parts.join(' '); + } catch { + return '—'; + } +}; + +export const getStatusColor = (status: string | undefined): string => { + if (!status) { + return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'; + } + + const normalizedStatus = status.trim(); + + switch (normalizedStatus) { + case 'Completed': + return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'; + case 'Failed': + return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'; + case 'InProgress': + return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'; + case 'Pending': + return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'; + case 'ReConnecting': + return 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200'; + case 'Start': + return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'; + default: + return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'; + } +}; + +export const getStatusLabel = (status: string | undefined): string => { + if (!status) return 'Unknown'; + + const normalizedStatus = status.trim(); + + switch (normalizedStatus) { + case 'Start': + return 'Started'; + case 'Pending': + return 'Pending'; + case 'InProgress': + return 'In Progress'; + case 'ReConnecting': + return 'Reconnecting'; + case 'Failed': + return 'Failed'; + case 'Completed': + return 'Completed'; + default: + return status; + } +}; + +export const formatJson = (data: any): string => { + if (data === null || data === undefined) return 'null'; + + try { + return JSON.stringify(data, null, 2); + } catch { + return String(data); + } +}; + +export const parseJsonSafe = (str: string): any => { + if (!str || typeof str !== 'string') { + return null; + } + + try { + return JSON.parse(str); + } catch { + return null; + } +}; + +export const getStatusIcon = (status: string): string => { + switch (status) { + case 'Completed': + return '✓'; + case 'Failed': + return '✕'; + case 'InProgress': + return 'âŸŗ'; + case 'Pending': + return '⋯'; + case 'ReConnecting': + return 'â†ģ'; + case 'Start': + return '●'; + default: + return '○'; + } +}; diff --git a/apps/web/app/types/execution.types.ts b/apps/web/app/types/execution.types.ts new file mode 100644 index 0000000..621329e --- /dev/null +++ b/apps/web/app/types/execution.types.ts @@ -0,0 +1,69 @@ +/** + * Execution Types + * Type definitions for workflow execution logs and node executions + */ + +/** + * Valid workflow status values + */ +export type Status = 'Start' | 'Pending' | 'InProgress' | 'ReConnecting' | 'Failed' | 'Completed'; + +/** + * Execution error details + */ +export interface ExecutionError { + message: string; + code?: string; + details?: Record; +} + +/** + * Node information included with node execution + */ +export interface NodeInfo { + id: string; + name: string; + config: Record; + type: string; // From AvailableNode.type +} + +/** + * Node execution log - represents a single node's execution in a workflow + */ +export interface NodeExecutionLog { + id: string; + nodeId: string; + workflowExecId: string; + status: Status; + startedAt: string; // ISO 8601 DateTime + completedAt: string | null; // ISO 8601 DateTime or null + inputData: Record | null; + outputData: Record | null; + error: string | null; + retries: number; + isTest: boolean; + node: NodeInfo; +} + +/** + * Workflow execution log - represents a complete workflow execution with all node executions + */ +export interface WorkflowExecutionLog { + id: string; + workflowId: string; + status: Status; + startAt: string; // ISO 8601 DateTime + completedAt: string | null; // ISO 8601 DateTime or null + error: string | null; + metadata: Record; + nodeExecutions: NodeExecutionLog[]; +} + +/** + * Response format for GET /user/workflow/logs/:workflowId + */ +export interface WorkflowExecutionResponse { + success: boolean; + data: WorkflowExecutionLog | null; + message?: string; +} diff --git a/apps/web/app/workflows/[id]/page.tsx b/apps/web/app/workflows/[id]/page.tsx index 9f3f5ac..95299e0 100644 --- a/apps/web/app/workflows/[id]/page.tsx +++ b/apps/web/app/workflows/[id]/page.tsx @@ -28,6 +28,7 @@ import { setNodeOutput, setNodeLoading, selectAllOutputs } from "@/store/slices/ import { resolveConfigVariables } from "@repo/common/zod"; import { SidebarProvider } from "@workspace/ui/components/sidebar"; import { AppSidebar } from "@/app/components/ui/app-sidebar"; +import ExecutionHistoryFooter from "@/app/components/ExecutionHistoryFooter"; export default function WorkflowCanvas() { const params = useParams(); const workflowId = params.id as string; @@ -905,6 +906,11 @@ export default function WorkflowCanvas() { onClose={() => setActionOpen(false)} onSelectAction={handleActionSelection} /> + + {}} + />
); } diff --git a/apps/web/store/root-reducer.ts b/apps/web/store/root-reducer.ts index ade7410..5058f61 100644 --- a/apps/web/store/root-reducer.ts +++ b/apps/web/store/root-reducer.ts @@ -2,12 +2,15 @@ import { combineReducers } from "@reduxjs/toolkit"; import { userReducer } from "./slices/userSlice"; import { workflowReducer } from "./slices/workflowSlice"; import { nodeOutputReducer, NodeOutputState } from "./slices/nodeOutputSlice"; +import executionReducer from "./slices/executionSlice"; +import type { ExecutionState } from "./slices/executionSlice"; // Re-export types for use in types.ts -export type { NodeOutputState }; +export type { NodeOutputState, ExecutionState }; export const rootReducer = combineReducers({ user: userReducer, workflow: workflowReducer, - nodeOutput: nodeOutputReducer + nodeOutput: nodeOutputReducer, + execution: executionReducer }); \ No newline at end of file diff --git a/apps/web/store/slices/executionSlice.ts b/apps/web/store/slices/executionSlice.ts new file mode 100644 index 0000000..4c6d2ef --- /dev/null +++ b/apps/web/store/slices/executionSlice.ts @@ -0,0 +1,123 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { WorkflowExecutionLog } from "@/app/types/execution.types"; + +export interface ExecutionState { + executions: WorkflowExecutionLog[]; + selectedExecution: WorkflowExecutionLog | null; + isLoading: boolean; + error: string | null; + isFooterExpanded: boolean; + hasMore: boolean; + skip: number; + take: number; +} + +const initialState: ExecutionState = { + executions: [], + selectedExecution: null, + isLoading: false, + error: null, + isFooterExpanded: false, + hasMore: true, + skip: 0, + take: 20, +}; + +export const executionSlice = createSlice({ + name: "execution", + initialState, + reducers: { + // Set full execution list (replace) + setExecutions: (state, action: PayloadAction) => { + state.executions = action.payload; + }, + + // Append more executions (lazy load) + appendExecutions: (state, action: PayloadAction) => { + state.executions = [...state.executions, ...action.payload]; + }, + + // Select an execution + selectExecution: (state, action: PayloadAction) => { + state.selectedExecution = action.payload; + }, + + // Deselect current execution + clearSelectedExecution: (state) => { + state.selectedExecution = null; + }, + + // Toggle footer expanded state + setIsFooterExpanded: (state, action: PayloadAction) => { + state.isFooterExpanded = action.payload; + // Persist to localStorage + if (typeof window !== "undefined") { + localStorage.setItem( + "executionFooterExpanded", + JSON.stringify(action.payload) + ); + } + }, + + // Set loading state + setIsLoading: (state, action: PayloadAction) => { + state.isLoading = action.payload; + }, + + // Set error + setError: (state, action: PayloadAction) => { + state.error = action.payload; + }, + + // Set if more data available + setHasMore: (state, action: PayloadAction) => { + state.hasMore = action.payload; + }, + + // Reset pagination + resetPagination: (state) => { + state.skip = 0; + state.executions = []; + }, + + // Increment skip by take amount + incrementSkip: (state) => { + state.skip += state.take; + }, + + // Initialize from localStorage + initializeFromStorage: (state) => { + if (typeof window !== "undefined") { + const expanded = localStorage.getItem("executionFooterExpanded"); + if (expanded !== null) { + state.isFooterExpanded = JSON.parse(expanded); + } + } + }, + + // Clear all execution data + clearExecutions: (state) => { + state.executions = []; + state.selectedExecution = null; + state.skip = 0; + state.hasMore = true; + }, + }, +}); + +export const { + setExecutions, + appendExecutions, + selectExecution, + clearSelectedExecution, + setIsFooterExpanded, + setIsLoading, + setError, + setHasMore, + resetPagination, + incrementSkip, + initializeFromStorage, + clearExecutions, +} = executionSlice.actions; + +export default executionSlice.reducer; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 48fb472..461ae26 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -74,6 +74,52 @@ export const workflowUpdateSchema = z.object({ edges : z.any().optional(), workflowId : z.string() }) + +// Execution Logs Schemas - for GET /user/workflow/logs/:workflowId +export const ExecutionStatusEnum = z.enum(['Start', 'Pending', 'InProgress', 'ReConnecting', 'Failed', 'Completed']); + +export const NodeExecutionSchema = z.object({ + id: z.string(), + nodeId: z.string(), + workflowExecId: z.string(), + status: ExecutionStatusEnum, + startedAt: z.string().or(z.date()), + completedAt: z.string().or(z.date()).nullable().optional(), + inputData: z.any().nullable().optional(), + outputData: z.any().nullable().optional(), + error: z.string().nullable().optional(), + retries: z.number().default(0), + isTest: z.boolean().default(false), + node: z.object({ + id: z.string(), + name: z.string(), + config: z.any(), + AvailableNode: z.object({ + id: z.string(), + name: z.string(), + type: z.string(), + description: z.string().optional(), + icon: z.string().optional(), + }).optional(), + }).optional(), +}); + +export const WorkflowExecutionSchema = z.object({ + id: z.string(), + workflowId: z.string(), + status: ExecutionStatusEnum, + startAt: z.string().or(z.date()), + completedAt: z.string().or(z.date()).nullable().optional(), + error: z.string().nullable().optional(), + metadata: z.any().optional(), + nodeExecutions: z.array(NodeExecutionSchema).optional(), +}); + +export const WorkflowExecutionResponseSchema = z.object({ + message: z.string(), + data: z.array(WorkflowExecutionSchema), +}); + export enum statusCodes { OK = 200, CREATED = 201, diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index babf97c..2a424f4 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -119,6 +119,7 @@ model NodeExecution { outputData Json? error String? retries Int @default(0) + isTest Boolean @default(false) workflowExecId String status WorkflowStatus node Node @relation(fields: [nodeId], references: [id])