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 (
+
+ );
+ }
+
+ return (
+
+
+
+
+ | Started At |
+ Status |
+ Error |
+
+
+
+ {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' : ''
+ }`}
+ >
+ |
+ {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])