@@ -69,7 +83,7 @@ export default function LiveActivity({
{config.label}
-
+
{activity.message}
@@ -90,3 +104,4 @@ export default function LiveActivity({
);
}
+
diff --git a/frontend/components/dashboard/QuickActions.tsx b/frontend/components/dashboard/QuickActions.tsx
index 8cc963e..abb9a9b 100644
--- a/frontend/components/dashboard/QuickActions.tsx
+++ b/frontend/components/dashboard/QuickActions.tsx
@@ -1,80 +1,97 @@
-"use client";
-
-import { useRouter } from "next/navigation";
-import {
- Plus,
- Play,
- LayoutTemplate,
- History,
-} from "lucide-react";
-
-export default function QuickActions() {
- const router = useRouter();
-
- const actions = [
- {
- label: "Create Workflow",
- description: "Build a new automation",
- icon: Plus,
- onClick: () => router.push("/workflows/new"),
- },
- {
- label: "Run Workflow",
- description: "Execute an existing workflow",
- icon: Play,
- onClick: () => router.push("/workflows"),
- },
- {
- label: "Browse Templates",
- description: "Start from a template",
- icon: LayoutTemplate,
- onClick: () => router.push("/templates"),
- },
- {
- label: "View History",
- description: "Check past executions",
- icon: History,
- onClick: () => router.push("/executions"),
- },
- ];
-
- return (
-
-
-
Quick Actions
-
- Common workflow actions
-
-
-
-
- {actions.map((action) => {
- const Icon = action.icon;
-
- return (
-
-
-
-
-
-
-
- {action.label}
-
-
-
- {action.description}
-
-
-
- );
- })}
-
-
- );
-}
\ No newline at end of file
+"use client";
+
+import { useRouter } from "next/navigation";
+import {
+ Plus,
+ Play,
+ LayoutTemplate,
+ History,
+ ChevronRight,
+} from "lucide-react";
+
+export default function QuickActions() {
+ const router = useRouter();
+
+ const actions = [
+ {
+ label: "Create Workflow",
+ description: "Build a new automation",
+ icon: Plus,
+ isPrimary: true,
+ iconBg: "bg-[#8174ff]/15 text-[#a49aff] border border-[#8174ff]/30",
+ onClick: () => router.push("/workflows/new"),
+ },
+ {
+ label: "Run Workflow",
+ description: "Execute an existing workflow",
+ icon: Play,
+ isPrimary: false,
+ iconBg: "bg-blue-500/10 text-blue-400 border border-blue-500/20",
+ onClick: () => router.push("/workflows"),
+ },
+ {
+ label: "Browse Templates",
+ description: "Start from a template",
+ icon: LayoutTemplate,
+ isPrimary: false,
+ iconBg: "bg-violet-500/10 text-violet-400 border border-violet-500/20",
+ onClick: () => router.push("/templates"),
+ },
+ {
+ label: "View History",
+ description: "Check past executions",
+ icon: History,
+ isPrimary: false,
+ iconBg: "bg-amber-500/10 text-amber-400 border border-amber-500/20",
+ onClick: () => router.push("/executions"),
+ },
+ ];
+
+ return (
+
+
+
Quick Actions
+
+ Common workflow actions
+
+
+
+
+ {actions.map((action) => {
+ const Icon = action.icon;
+
+ return (
+
+
+
+
+
+
+
+
+ {action.label}
+
+
+
+ {action.description}
+
+
+
+
+
+
+ );
+ })}
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/components/dashboard/RunningExecutions.tsx b/frontend/components/dashboard/RunningExecutions.tsx
index 996b4fd..d60ab13 100644
--- a/frontend/components/dashboard/RunningExecutions.tsx
+++ b/frontend/components/dashboard/RunningExecutions.tsx
@@ -30,7 +30,7 @@ export default function RunningExecutions({
{executions.map((execution) => {
- const status = statusConfig[execution.status];
+ const status = statusConfig[execution.status] || statusConfig.RUNNING;
return (
0
+ ? "text-red-400 bg-red-500/10 border-red-500/20"
+ : "text-zinc-400 bg-zinc-800/50 border-zinc-700/30",
},
{
label: "Active Instances",
value: health.activeInstances,
+ badgeClass: "text-sky-400 bg-sky-500/10 border-sky-500/20",
},
{
label: "Avg. Execution Time",
value: `${health.averageExecutionTime}s`,
- },
- {
- label: "Resource Usage",
- value: health.resourceUsage,
+ badgeClass: "text-zinc-300 bg-zinc-800/40 border-zinc-700/30",
},
];
@@ -48,13 +47,15 @@ export default function SystemHealth({
{healthItems.map((item) => (
{item.label}
-
+
{item.value}
@@ -62,4 +63,4 @@ export default function SystemHealth({
);
-}
\ No newline at end of file
+}
diff --git a/frontend/components/execution-details/ActiveLogs.tsx b/frontend/components/execution-details/ActiveLogs.tsx
index 3df8da9..1b35ca3 100644
--- a/frontend/components/execution-details/ActiveLogs.tsx
+++ b/frontend/components/execution-details/ActiveLogs.tsx
@@ -1,15 +1,22 @@
+"use client";
+
+import type {
+ ExecutionDetails,
+ ExecutionLog,
+} from "@/types/execution-details";
+
const logLevelConfig = {
INFO: {
- className: "text-blue-500",
+ className: "bg-blue-500/10 text-blue-400 border-blue-500/20",
},
WAIT: {
- className: "text-amber-500",
+ className: "bg-amber-500/10 text-amber-400 border-amber-500/20",
},
SYSTEM: {
- className: "text-muted-foreground",
+ className: "bg-zinc-800/60 text-zinc-400 border-zinc-700/40",
},
ERROR: {
- className: "text-red-500",
+ className: "bg-red-500/10 text-red-400 border-red-500/20",
},
} as const;
@@ -17,63 +24,55 @@ interface LogRowProps {
log: ExecutionLog;
}
-function LogRow({
- log,
-}: LogRowProps) {
+function LogRow({ log }: LogRowProps) {
const config =
- logLevelConfig[log.level];
+ logLevelConfig[log.level] || logLevelConfig.SYSTEM;
return (
-
-
-
+
+
{log.timestamp}
-
- {log.level}
-
-
-
{log.message}
+
+
+ {log.level}
+
+
+
+ {log.message}
+
);
}
-import type {
- ExecutionDetails,
- ExecutionLog,
-} from "@/types/execution-details";
-
interface ActiveLogsProps {
execution: ExecutionDetails;
}
-export default function ActiveLogs({
- execution,
-}: ActiveLogsProps) {
+export default function ActiveLogs({ execution }: ActiveLogsProps) {
return (
-
-
-
-
-
- Active Logs
+
+
+
+ Execution Logs
-
-
-
- {execution.logs.map((log) => (
-
- ))}
-
+
+ {execution.logs.length === 0 ? (
+
+ No execution logs available.
+
+ ) : (
+ execution.logs.map((log) => (
+
+ ))
+ )}
-
);
}
\ No newline at end of file
diff --git a/frontend/components/execution-details/ExecutionDetailsView.tsx b/frontend/components/execution-details/ExecutionDetailsView.tsx
index 95c765e..b6b9dfe 100644
--- a/frontend/components/execution-details/ExecutionDetailsView.tsx
+++ b/frontend/components/execution-details/ExecutionDetailsView.tsx
@@ -1,66 +1,151 @@
"use client";
+import { useEffect, useState, useCallback } from "react";
+import { notFound } from "next/navigation";
+import { AlertCircle, RefreshCw } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Node, Edge } from "@xyflow/react";
+
import { ExecutionDetails } from "@/types/execution-details";
+import { getExecutionDetails } from "@/lib/api/execution-details";
+import { getWorkflowDefinition } from "@/lib/api/workflow";
+import { fromWorkflowDefinition } from "@/lib/mappers/workflow.mapper";
import ExecutionHeader from "./ExecutionHeader";
-import ExecutionSidebar from "./ExecutionSidebar";
import WorkflowExecutionCanvas from "./WorkflowExecutionCanvas";
-import { executionWorkflowEdges, executionWorkflowNodes } from "@/mocks/execution-workflow";
import ExecutionTimeline from "./ExecutionTimeline";
import ActiveLogs from "./ActiveLogs";
interface ExecutionDetailsViewProps {
- execution: ExecutionDetails;
+ executionId?: string;
+ execution?: ExecutionDetails;
}
export default function ExecutionDetailsView({
- execution,
+ executionId,
+ execution: initialExecution,
}: ExecutionDetailsViewProps) {
+ const [execution, setExecution] = useState
(
+ initialExecution || null
+ );
+ const [isLoading, setIsLoading] = useState(
+ !initialExecution && Boolean(executionId)
+ );
+ const [error, setError] = useState(null);
- const nodes = executionWorkflowNodes.map((node) => {
- const executionNode = execution.nodes.find(
- (n) => n.id === node.id
+ const [workflowNodes, setWorkflowNodes] = useState([]);
+ const [workflowEdges, setWorkflowEdges] = useState([]);
+
+ const fetchExecution = useCallback(async () => {
+ if (!executionId) return;
+ setIsLoading(true);
+ setError(null);
+ try {
+ const data = await getExecutionDetails(executionId);
+ if (!data) {
+ notFound();
+ return;
+ }
+ setExecution(data);
+ } catch (err: any) {
+ console.error("Failed to fetch execution details:", err);
+ if (err?.response?.status === 404) {
+ notFound();
+ return;
+ }
+ setError(
+ err?.response?.data?.message ||
+ err?.message ||
+ "Failed to load execution details."
);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [executionId]);
+
+ useEffect(() => {
+ if (!initialExecution && executionId) {
+ fetchExecution();
+ }
+ }, [fetchExecution, initialExecution, executionId]);
+
+ useEffect(() => {
+ async function loadWorkflowCanvas() {
+ if (!execution?.workflowId) return;
+ try {
+ const definition = await getWorkflowDefinition(execution.workflowId);
+ if (definition) {
+ const { nodes, edges } = fromWorkflowDefinition(definition);
+ setWorkflowNodes(nodes);
+ setWorkflowEdges(edges);
+ }
+ } catch (err) {
+ console.warn("Failed to load workflow canvas definition:", err);
+ }
+ }
+ loadWorkflowCanvas();
+ }, [execution?.workflowId]);
- return {
- ...node,
- data: {
- ...node.data,
- executionStatus: executionNode?.status,
- },
- };
- });
+ if (isLoading) {
+ return (
+
+
+
Loading execution details...
+
+ );
+ }
+
+ if (error || !execution) {
+ return (
+
+
+
+
Error Loading Execution
+
{error || "Execution details could not be loaded."}
+
+
+
+ Retry
+
+
+ );
+ }
+
+ const canvasNodes = workflowNodes.map((node) => {
+ const executionNode = execution.nodes.find(
+ (n) => n.id === node.id
+ );
+
+ return {
+ ...node,
+ data: {
+ ...node.data,
+ executionStatus: executionNode?.status || "PENDING",
+ },
+ };
+ });
return (
- {/* Left Sidebar */}
-
-
{/* Center */}
{/* Header */}
-
+
{/* Canvas */}
-
+
{/* Logs */}
-
+
{/* Right Timeline */}
-
+
);
}
\ No newline at end of file
diff --git a/frontend/components/execution-details/ExecutionHeader.tsx b/frontend/components/execution-details/ExecutionHeader.tsx
index eb88d2b..d728414 100644
--- a/frontend/components/execution-details/ExecutionHeader.tsx
+++ b/frontend/components/execution-details/ExecutionHeader.tsx
@@ -1,76 +1,54 @@
"use client";
-import {
- Clock3,
- Pause,
- X,
-} from "lucide-react";
-
-import { Button } from "@/components/ui/button";
-
+import { Clock3 } from "lucide-react";
import { ExecutionDetails } from "@/types/execution-details";
interface ExecutionHeaderProps {
execution: ExecutionDetails;
}
+const statusStyles: Record = {
+ COMPLETED: "bg-emerald-500/10 text-emerald-500 border-emerald-500/20",
+ RUNNING: "bg-blue-500/10 text-blue-500 border-blue-500/20",
+ FAILED: "bg-destructive/10 text-destructive border-destructive/20",
+ WAITING: "bg-amber-500/10 text-amber-500 border-amber-500/20",
+};
+
export default function ExecutionHeader({
execution,
}: ExecutionHeaderProps) {
+ const statusClass =
+ statusStyles[execution.status?.toUpperCase()] ||
+ "bg-muted text-muted-foreground border-border";
+
return (
);
}
\ No newline at end of file
diff --git a/frontend/components/execution-details/ExecutionTimeline.tsx b/frontend/components/execution-details/ExecutionTimeline.tsx
index 9c07bb7..802d333 100644
--- a/frontend/components/execution-details/ExecutionTimeline.tsx
+++ b/frontend/components/execution-details/ExecutionTimeline.tsx
@@ -1,10 +1,19 @@
-import type { ExecutionTimelineItem } from "@/types/execution-details";
+"use client";
+
+import { useState } from "react";
+import type { ExecutionTimelineItem, ExecutionDetails } from "@/types/execution-details";
import { cn } from "@/lib/utils";
const timelineStatusConfig = {
SUCCESS: {
dot: "bg-green-500",
},
+ RUNNING: {
+ dot: "bg-blue-500",
+ },
+ PENDING: {
+ dot: "bg-zinc-500",
+ },
WAITING: {
dot: "bg-amber-500",
},
@@ -18,57 +27,56 @@ interface TimelineItemProps {
isLast: boolean;
}
-function TimelineItem({
- item,
- isLast,
-}: TimelineItemProps) {
+function TimelineItem({ item, isLast }: TimelineItemProps) {
+ const [showDetails, setShowDetails] = useState(false);
const config =
- timelineStatusConfig[item.status];
+ timelineStatusConfig[item.status] || timelineStatusConfig.SUCCESS;
return (
-
+
-
-
- {!isLast && (
-
- )}
-
+ {!isLast &&
}
-
-
-
-
-
- {item.title}
-
+
+
+
{item.title}
-
+
{item.timestamp}
-
-
- {item.description}
-
+ {item.description && (
+
+ {item.description}
+
+ )}
+ {item.status === "FAILED" && item.errorMessage && (
+
+
setShowDetails((prev) => !prev)}
+ className="text-xs font-medium text-destructive hover:underline focus:outline-none"
+ >
+ {showDetails ? "Hide full error" : "View full error"}
+
+
+ {showDetails && (
+
+ {item.errorMessage}
+
+ )}
+
+ )}
-
);
}
-import { ExecutionDetails } from "@/types/execution-details";
-
interface ExecutionTimelineProps {
execution: ExecutionDetails;
}
@@ -77,25 +85,16 @@ export default function ExecutionTimeline({
execution,
}: ExecutionTimelineProps) {
return (
-
-
-
- Timeline
-
-
- {execution.timeline.map(
- (item, index) => (
-
- )
- )}
-
+
+ Timeline
+
+ {execution.timeline.map((item, index) => (
+
+ ))}
);
}
\ No newline at end of file
diff --git a/frontend/components/execution-details/WorkflowExecutionCanvas.tsx b/frontend/components/execution-details/WorkflowExecutionCanvas.tsx
index b4074bd..053dcc5 100644
--- a/frontend/components/execution-details/WorkflowExecutionCanvas.tsx
+++ b/frontend/components/execution-details/WorkflowExecutionCanvas.tsx
@@ -3,7 +3,6 @@
import {
Background,
Controls,
- MiniMap,
ReactFlow,
type Node,
type Edge,
@@ -33,10 +32,13 @@ export default function WorkflowExecutionCanvas({
nodesFocusable={false}
edgesFocusable={false}
connectOnClick={false}
+ colorMode="dark"
>
-
-
-
+
+
);
}
\ No newline at end of file
diff --git a/frontend/components/executions/ExecutionFiltersBar.tsx b/frontend/components/executions/ExecutionFiltersBar.tsx
index 1323320..d10edc7 100644
--- a/frontend/components/executions/ExecutionFiltersBar.tsx
+++ b/frontend/components/executions/ExecutionFiltersBar.tsx
@@ -36,31 +36,27 @@ const statuses: {
label: string;
value: ExecutionStatus | "ALL";
}[] = [
- {
- label: "All States",
- value: "ALL",
- },
- {
- label: "Running",
- value: "RUNNING",
- },
- {
- label: "Completed",
- value: "COMPLETED",
- },
- {
- label: "Failed",
- value: "FAILED",
- },
- {
- label: "Waiting Approval",
- value: "WAITING_APPROVAL",
- },
- {
- label: "Retrying",
- value: "RETRYING",
- },
-];
+ {
+ label: "All States",
+ value: "ALL",
+ },
+ {
+ label: "Pending",
+ value: "PENDING",
+ },
+ {
+ label: "Running",
+ value: "RUNNING",
+ },
+ {
+ label: "Completed",
+ value: "COMPLETED",
+ },
+ {
+ label: "Failed",
+ value: "FAILED",
+ },
+ ];
export default function ExecutionFiltersBar({
filters,
diff --git a/frontend/components/executions/ExecutionMetrics.tsx b/frontend/components/executions/ExecutionMetrics.tsx
index 5a37bdb..89ec8e5 100644
--- a/frontend/components/executions/ExecutionMetrics.tsx
+++ b/frontend/components/executions/ExecutionMetrics.tsx
@@ -84,11 +84,11 @@ export default function ExecutionMetrics({
function formatSystemHealth(
health: ExecutionMetricsType["systemHealth"]
) {
- const labels = {
+ const labels: Record = {
OPTIMAL: "Optimal",
DEGRADED: "Degraded",
CRITICAL: "Critical",
};
- return labels[health];
+ return labels[health] || "Optimal";
}
\ No newline at end of file
diff --git a/frontend/components/executions/ExecutionPagination.tsx b/frontend/components/executions/ExecutionPagination.tsx
index 1d76ced..0d58e43 100644
--- a/frontend/components/executions/ExecutionPagination.tsx
+++ b/frontend/components/executions/ExecutionPagination.tsx
@@ -11,6 +11,41 @@ interface ExecutionPaginationProps {
onPageChange: (page: number) => void;
}
+function getPageNumbers(
+ currentPage: number,
+ totalPages: number
+): (number | "...")[] {
+ if (totalPages <= 7) {
+ return Array.from({ length: totalPages }, (_, i) => i + 1);
+ }
+
+ if (currentPage <= 4) {
+ return [1, 2, 3, 4, 5, "...", totalPages];
+ }
+
+ if (currentPage >= totalPages - 3) {
+ return [
+ 1,
+ "...",
+ totalPages - 4,
+ totalPages - 3,
+ totalPages - 2,
+ totalPages - 1,
+ totalPages,
+ ];
+ }
+
+ return [
+ 1,
+ "...",
+ currentPage - 1,
+ currentPage,
+ currentPage + 1,
+ "...",
+ totalPages,
+ ];
+}
+
export default function ExecutionPagination({
currentPage,
totalPages,
@@ -18,15 +53,13 @@ export default function ExecutionPagination({
pageSize,
onPageChange,
}: ExecutionPaginationProps) {
- const startItem =
- totalItems === 0
- ? 0
- : (currentPage - 1) * pageSize + 1;
+ if (totalItems === 0 || totalPages <= 0) {
+ return null;
+ }
- const endItem = Math.min(
- currentPage * pageSize,
- totalItems
- );
+ const startItem = (currentPage - 1) * pageSize + 1;
+ const endItem = Math.min(currentPage * pageSize, totalItems);
+ const pages = getPageNumbers(currentPage, totalPages);
return (
@@ -39,39 +72,41 @@ export default function ExecutionPagination({
- onPageChange(currentPage - 1)
- }
+ onClick={() => onPageChange(currentPage - 1)}
className="flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-40"
aria-label="Previous page"
>
- {Array.from(
- { length: totalPages },
- (_, index) => index + 1
- ).map((page) => (
-
onPageChange(page)}
- className={`size-8 rounded-md text-sm transition-colors ${
- page === currentPage
- ? "bg-primary text-primary-foreground"
- : "text-muted-foreground hover:bg-muted"
- }`}
- >
- {page}
-
- ))}
+ {pages.map((page, index) =>
+ typeof page === "number" ? (
+
onPageChange(page)}
+ className={`size-8 rounded-md text-sm transition-colors ${
+ page === currentPage
+ ? "bg-primary font-medium text-primary-foreground"
+ : "text-muted-foreground hover:bg-muted"
+ }`}
+ >
+ {page}
+
+ ) : (
+
+ …
+
+ )
+ )}
- onPageChange(currentPage + 1)
- }
+ onClick={() => onPageChange(currentPage + 1)}
className="flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-40"
aria-label="Next page"
>
diff --git a/frontend/components/executions/ExecutionTable.tsx b/frontend/components/executions/ExecutionTable.tsx
index a7ffebe..41385a1 100644
--- a/frontend/components/executions/ExecutionTable.tsx
+++ b/frontend/components/executions/ExecutionTable.tsx
@@ -5,121 +5,85 @@ import {
Clock3,
Eye,
RefreshCw,
- RotateCcw,
XCircle,
} from "lucide-react";
+import Link from "next/link";
-import {
- Execution,
- ExecutionStatus,
-} from "@/types/execution";
-
+import { Button } from "@/components/ui/button";
+import { Execution } from "@/types/execution";
import { formatDuration } from "@/lib/utils/format-duration";
import { formatRelativeTime } from "@/lib/utils/format-relative-time";
import { ReactNode } from "react";
interface ExecutionTableProps {
executions: Execution[];
+ totalExecutionsCount?: number;
+ onClearFilters?: () => void;
pagination?: ReactNode;
}
const statusConfig: Record<
- ExecutionStatus,
+ string,
{
label: string;
className: string;
icon: typeof CheckCircle2;
}
> = {
+ PENDING: {
+ label: "Pending",
+ className: "bg-amber-500/10 text-amber-500",
+ icon: Clock3,
+ },
RUNNING: {
label: "Running",
className: "bg-cyan-500/10 text-cyan-500",
icon: RefreshCw,
},
-
FAILED: {
label: "Failed",
className: "bg-red-500/10 text-red-500",
icon: XCircle,
},
-
COMPLETED: {
label: "Completed",
className: "bg-emerald-500/10 text-emerald-500",
icon: CheckCircle2,
},
-
- WAITING_APPROVAL: {
- label: "Waiting Approval",
- className: "bg-violet-500/10 text-violet-400",
- icon: Clock3,
- },
-
- RETRYING: {
- label: "Retrying",
- className: "bg-indigo-500/10 text-indigo-400",
- icon: RotateCcw,
- },
};
export default function ExecutionTable({
executions,
- pagination
+ totalExecutionsCount,
+ onClearFilters,
+ pagination,
}: ExecutionTableProps) {
return (
-
+
-
- Execution ID
-
-
-
- Workflow
-
-
-
- Status
-
-
-
- Progress
-
-
-
- Started
-
-
-
- Duration
-
-
-
- Node
-
-
-
- Actions
-
+ Execution ID
+ Workflow
+ Status
+ Started At
+ Completed At
+ Duration
+ Actions
{executions.map((execution) => {
- const status = statusConfig[execution.status];
+ const statusKey = (execution.status || "PENDING").toUpperCase();
+ const status = statusConfig[statusKey] || {
+ label: execution.status,
+ className: "bg-muted text-muted-foreground",
+ icon: Clock3,
+ };
const StatusIcon = status.icon;
- const progressPercentage =
- execution.progress.totalNodes === 0
- ? 0
- : Math.round(
- (execution.progress.completedNodes /
- execution.progress.totalNodes) *
- 100
- );
-
return (
- {/* Workflow */}
+ {/* Workflow Name */}
-
-
- {execution.workflow.name}
-
-
-
- {execution.workflow.category}
- {" · "}
- {execution.workflow.source}
-
-
+ {execution.workflow.name}
{/* Status */}
@@ -153,67 +107,40 @@ export default function ExecutionTable({
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${status.className}`}
>
-
{status.label}
- {/* Progress */}
-
-
-
-
- {execution.progress.completedNodes}/
- {execution.progress.totalNodes} Nodes
-
-
-
-
-
+ {/* Started At */}
+
+ {execution.startedAt
+ ? formatRelativeTime(execution.startedAt)
+ : "—"}
- {/* Started */}
+ {/* Completed At */}
- {formatRelativeTime(execution.startedAt)}
+ {execution.completedAt
+ ? formatRelativeTime(execution.completedAt)
+ : "—"}
{/* Duration */}
-
-
- {formatDuration(execution.duration)}
-
- {execution.retryCount > 0 && (
-
- Retry {execution.retryCount}
-
- )}
-
- {execution.status ===
- "WAITING_APPROVAL" && (
-
- Paused
-
- )}
-
-
-
- {/* Current Node */}
-
-
- {execution.currentNode ?? "—"}
-
+
+ {execution.duration !== null
+ ? formatDuration(execution.duration)
+ : "—"}
{/* Actions */}
-
-
+
+
+
+
);
@@ -223,59 +150,41 @@ export default function ExecutionTable({
{executions.length === 0 && (
-
-
-
- No executions found
-
-
-
- Try changing your filters.
-
+
+
+ {totalExecutionsCount && totalExecutionsCount > 0 ? (
+ <>
+
+ No executions match the selected filters
+
+
+ Try adjusting or clearing your filters to see more results.
+
+ {onClearFilters && (
+
+ Clear Filters
+
+ )}
+ >
+ ) : (
+ <>
+
+ No executions found
+
+
+ Trigger a workflow execution to see history here.
+
+ >
+ )}
)}
{pagination}
);
-}
-
-interface ExecutionActionsProps {
- execution: Execution;
-}
-
-function ExecutionActions({
- execution,
-}: ExecutionActionsProps) {
- return (
-
- {execution.status === "FAILED" && (
-
-
- Retry
-
- )}
-
- {execution.status === "WAITING_APPROVAL" && (
-
- Approve
-
- )}
-
-
-
-
-
-
- );
}
\ No newline at end of file
diff --git a/frontend/components/executions/ExecutionsView.tsx b/frontend/components/executions/ExecutionsView.tsx
index 7dd642f..49c6582 100644
--- a/frontend/components/executions/ExecutionsView.tsx
+++ b/frontend/components/executions/ExecutionsView.tsx
@@ -1,109 +1,159 @@
"use client";
-import { useMemo, useState, useEffect } from "react";
+import { useMemo, useState, useEffect, useCallback } from "react";
+import { AlertCircle, RefreshCw } from "lucide-react";
+import { Button } from "@/components/ui/button";
-import {
- ExecutionFilters,
- ExecutionsData,
-} from "@/types/execution";
+import { ExecutionFilters, Execution } from "@/types/execution";
+import { getExecutionsData } from "@/lib/api/executions";
import ExecutionFiltersBar from "./ExecutionFiltersBar";
import ExecutionTable from "./ExecutionTable";
import ExecutionPagination from "./ExecutionPagination";
-import ExecutionMetrics from "./ExecutionMetrics";
-
interface ExecutionsViewProps {
- data: ExecutionsData;
+ initialData?: Execution[];
}
const initialFilters: ExecutionFilters = {
status: "ALL",
- range: "24H",
+ range: "ALL",
workflowId: "ALL",
};
-const PAGE_SIZE = 5;
-
-export default function ExecutionsView({
- data,
-}: ExecutionsViewProps) {
-
-
-
- const [currentPage, setCurrentPage] = useState(1)
-
- function isWithinRange(
- startedAt: string,
- range: ExecutionFilters["range"]
- ) {
- if (range === "ALL") {
- return true;
- }
-
- const startedTime = new Date(startedAt).getTime();
- const now = Date.now();
-
- const rangeInMilliseconds = {
- "24H": 24 * 60 * 60 * 1000,
- "7D": 7 * 24 * 60 * 60 * 1000,
- "30D": 30 * 24 * 60 * 60 * 1000,
- }[range];
-
- return now - startedTime <= rangeInMilliseconds;
+const PAGE_SIZE = 10;
+
+export default function ExecutionsView({ initialData }: ExecutionsViewProps) {
+ const [executions, setExecutions] = useState
(
+ initialData || null
+ );
+ const [isLoading, setIsLoading] = useState(!initialData);
+ const [error, setError] = useState(null);
+ const [currentPage, setCurrentPage] = useState(1);
+ const [filters, setFilters] = useState(initialFilters);
+
+ const fetchExecutions = useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+ try {
+ const result = await getExecutionsData();
+ setExecutions(result);
+ } catch (err: any) {
+ console.error("Failed to load executions:", err);
+ setError(
+ err?.response?.data?.message ||
+ err?.message ||
+ "Failed to load execution history. Please check your connection or login status."
+ );
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ const handleClearFilters = useCallback(() => {
+ setFilters(initialFilters);
+ setCurrentPage(1);
+ }, []);
+
+ useEffect(() => {
+ if (!initialData) {
+ fetchExecutions();
+ }
+ }, [fetchExecutions, initialData]);
+
+ function isWithinRange(
+ startedAt: string,
+ range: ExecutionFilters["range"]
+ ) {
+ if (range === "ALL" || !startedAt) {
+ return true;
}
- const [filters, setFilters] =
- useState(initialFilters);
+ const startedTime = new Date(startedAt).getTime();
+ const now = Date.now();
+
+ const rangeInMilliseconds = {
+ "24H": 24 * 60 * 60 * 1000,
+ "7D": 7 * 24 * 60 * 60 * 1000,
+ "30D": 30 * 24 * 60 * 60 * 1000,
+ }[range];
+
+ return now - startedTime <= rangeInMilliseconds;
+ }
const filteredExecutions = useMemo(() => {
- return data.executions.filter((execution) => {
- const matchesStatus =
- filters.status === "ALL" ||
- execution.status === filters.status;
-
- const matchesWorkflow =
- filters.workflowId === "ALL" ||
- execution.workflow.id === filters.workflowId;
-
- const matchesRange = isWithinRange(
- execution.startedAt,
- filters.range
- );
-
- return (
- matchesStatus &&
- matchesWorkflow &&
- matchesRange
- );
- });
- }, [data.executions, filters]);
-
- const totalPages = Math.max(
- 1,
- Math.ceil(filteredExecutions.length / PAGE_SIZE)
- );
+ if (!executions) return [];
+ return executions.filter((execution) => {
+ const matchesStatus =
+ filters.status === "ALL" ||
+ execution.status.toUpperCase() === filters.status.toUpperCase();
- const paginatedExecutions = useMemo(() => {
- const startIndex = (currentPage - 1) * PAGE_SIZE;
- const endIndex = startIndex + PAGE_SIZE;
-
- return filteredExecutions.slice(startIndex, endIndex);
- }, [filteredExecutions, currentPage]);
+ const matchesWorkflow =
+ filters.workflowId === "ALL" ||
+ execution.workflow.id === filters.workflowId;
- const workflows = useMemo(() => {
- return Array.from(
- new Map(
- data.executions.map((execution) => [
- execution.workflow.id,
- execution.workflow,
- ])
- ).values()
- );
- }, [data.executions]);
+ const matchesRange = isWithinRange(execution.startedAt, filters.range);
+
+ return matchesStatus && matchesWorkflow && matchesRange;
+ });
+ }, [executions, filters]);
+
+ const totalPages = Math.max(
+ 1,
+ Math.ceil(filteredExecutions.length / PAGE_SIZE)
+ );
+
+ const paginatedExecutions = useMemo(() => {
+ const startIndex = (currentPage - 1) * PAGE_SIZE;
+ const endIndex = startIndex + PAGE_SIZE;
+
+ return filteredExecutions.slice(startIndex, endIndex);
+ }, [filteredExecutions, currentPage]);
- useEffect(() => {
- setCurrentPage(1);
- }, [filters]);
+ const workflows = useMemo(() => {
+ if (!executions) return [];
+ return Array.from(
+ new Map(
+ executions.map((execution) => [
+ execution.workflow.id,
+ execution.workflow,
+ ])
+ ).values()
+ );
+ }, [executions]);
+
+ useEffect(() => {
+ setCurrentPage(1);
+ }, [filters]);
+
+ if (isLoading) {
+ return (
+
+
+
Loading execution history...
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+
Failed to Load Executions
+
{error}
+
+
+
+ Retry
+
+
+ );
+ }
return (
@@ -111,14 +161,13 @@ export default function ExecutionsView({
filters={filters}
workflows={workflows}
onFiltersChange={setFilters}
- onRefresh={()=>{
- setFilters(initialFilters);
- setCurrentPage(1);
- }}
+ onRefresh={fetchExecutions}
/>
}
/>
-
-
-
);
}
\ No newline at end of file
diff --git a/frontend/components/workflow-builder/editor/workflow-canvas.tsx b/frontend/components/workflow-builder/editor/workflow-canvas.tsx
index 65b0707..ccf0499 100644
--- a/frontend/components/workflow-builder/editor/workflow-canvas.tsx
+++ b/frontend/components/workflow-builder/editor/workflow-canvas.tsx
@@ -32,100 +32,109 @@ export function WorkflowCanvas() {
deleteNode,
selectedNodeId,
duplicateNode,
- setIsDirty
+ setIsDirty,
+ workflowStatus
} = useWorkflowStore();
+ const isPublished = workflowStatus === "PUBLISHED";
+
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
+ if (isPublished) return;
setNodes((nds) => applyNodeChanges(changes, nds));
- setIsDirty(true)
+ setIsDirty(true);
},
- [setNodes]
+ [setNodes, setIsDirty, isPublished]
);
const onEdgesChange = useCallback(
(changes: EdgeChange[]) => {
+ if (isPublished) return;
setEdges((eds) => applyEdgeChanges(changes, eds));
- setIsDirty(true)
+ setIsDirty(true);
},
- [setEdges]
+ [setEdges, setIsDirty, isPublished]
);
const onConnect = useCallback(
(connection: Connection) => {
+ if (isPublished) return;
setEdges((eds) => addEdge(connection, eds));
},
- [setEdges]
+ [setEdges, isPublished]
);
const { screenToFlowPosition } = useReactFlow();
const onDragOver = useCallback(
- (event: React.DragEvent) => {
- event.preventDefault();
-
- event.dataTransfer.dropEffect = "move";
- },
- []
-);
+ (event: React.DragEvent) => {
+ if (isPublished) return;
+ event.preventDefault();
+ event.dataTransfer.dropEffect = "move";
+ },
+ [isPublished]
+ );
const onDrop = useCallback(
- (event: React.DragEvent) => {
- event.preventDefault();
+ (event: React.DragEvent) => {
+ if (isPublished) return;
+ event.preventDefault();
- const type = event.dataTransfer.getData(
- "application/reactflow"
- );
+ const type = event.dataTransfer.getData(
+ "application/reactflow"
+ );
- if (!type) return;
+ if (!type) return;
- const position = screenToFlowPosition({
- x: event.clientX,
- y: event.clientY,
- });
+ const position = screenToFlowPosition({
+ x: event.clientX,
+ y: event.clientY,
+ });
- addNode(
- type as WorkflowNodeType,
- position
- );
- },
- [screenToFlowPosition, addNode]
-);
-
-useEffect(() => {
- const handleKeyDown = (event: KeyboardEvent) => {
- if (
- (event.key === "Delete" ||
- event.key === "Backspace") &&
- selectedNodeId
- ) {
- deleteNode(selectedNodeId);
- }
-
- if (
- (event.ctrlKey || event.metaKey) &&
- event.key.toLowerCase() === "d"
- ) {
- event.preventDefault();
-
- if (selectedNodeId) {
- duplicateNode(selectedNodeId);
+ addNode(
+ type as WorkflowNodeType,
+ position
+ );
+ },
+ [screenToFlowPosition, addNode, isPublished]
+ );
+
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (isPublished) return;
+
+ if (
+ (event.key === "Delete" ||
+ event.key === "Backspace") &&
+ selectedNodeId
+ ) {
+ deleteNode(selectedNodeId);
}
- }
- };
- window.addEventListener(
- "keydown",
- handleKeyDown
- );
+ if (
+ (event.ctrlKey || event.metaKey) &&
+ event.key.toLowerCase() === "d"
+ ) {
+ event.preventDefault();
+
+ if (selectedNodeId) {
+ duplicateNode(selectedNodeId);
+ }
+ }
+ };
- return () => {
- window.removeEventListener(
+ window.addEventListener(
"keydown",
handleKeyDown
);
- };
-}, [deleteNode, selectedNodeId]);
+
+ return () => {
+ window.removeEventListener(
+ "keydown",
+ handleKeyDown
+ );
+ };
+ }, [deleteNode, duplicateNode, selectedNodeId, isPublished]);
return (
{
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
+ nodesDraggable={!isPublished}
+ nodesConnectable={!isPublished}
+ deleteKeyCode={isPublished ? null : ["Backspace", "Delete"]}
onNodeClick={(_, node) => {
setSelectedNodeId(node.id);
}}
diff --git a/frontend/components/workflow-builder/editor/workflow-properties.tsx b/frontend/components/workflow-builder/editor/workflow-properties.tsx
index a55d32b..8b6dc33 100644
--- a/frontend/components/workflow-builder/editor/workflow-properties.tsx
+++ b/frontend/components/workflow-builder/editor/workflow-properties.tsx
@@ -5,8 +5,9 @@ import { Node } from "@xyflow/react";
export function WorkflowProperties() {
+ const { nodes, setNodes, selectedNodeId, workflowName, setIsDirty, workflowStatus } = useWorkflowStore();
- const{nodes, setNodes, selectedNodeId, workflowName, setIsDirty} = useWorkflowStore();
+ const isPublished = workflowStatus === "PUBLISHED";
const selectedNode = nodes.find(
(node) => node.id === selectedNodeId
@@ -21,30 +22,38 @@ export function WorkflowProperties() {
}
const updateNodeField = (
- key: string,
- value: unknown
-) => {
- setNodes((nds: Node[]) =>
- nds.map((node) =>
- node.id === selectedNode.id
- ? {
- ...node,
- data: {
- ...(node.data as any),
- [key]: value,
- },
- }
- : node
- )
- );
- setIsDirty(true)
-};
+ key: string,
+ value: unknown
+ ) => {
+ if (isPublished) return;
+ setNodes((nds: Node[]) =>
+ nds.map((node) =>
+ node.id === selectedNode.id
+ ? {
+ ...node,
+ data: {
+ ...(node.data as any),
+ [key]: value,
+ },
+ }
+ : node
+ )
+ );
+ setIsDirty(true);
+ };
return (
-
- {workflowName}
-
+
+
+ {workflowName}
+
+ {isPublished && (
+
+ Read Only
+
+ )}
+
@@ -55,7 +64,7 @@ export function WorkflowProperties() {
@@ -66,48 +75,40 @@ export function WorkflowProperties() {
{
- updateNodeField("label", e.target.value)
-
+ disabled={isPublished}
+ onChange={(e) => {
+ updateNodeField("label", e.target.value);
}}
- className="w-full rounded-lg border border-zinc-800 bg-zinc-900 p-2"
+ className="w-full rounded-lg border border-zinc-800 bg-zinc-900 p-2 text-sm disabled:opacity-60"
/>
{selectedNode.type === "httpRequest" && (
)}
{selectedNode.type === "condition" && (
-
Condition
-
)}
-
diff --git a/frontend/components/workflow-builder/editor/workflow-toolbar.tsx b/frontend/components/workflow-builder/editor/workflow-toolbar.tsx
index 1c14d31..5be2c2c 100644
--- a/frontend/components/workflow-builder/editor/workflow-toolbar.tsx
+++ b/frontend/components/workflow-builder/editor/workflow-toolbar.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useEffect, useState } from "react";
import {
ChevronLeft,
ChevronRight,
@@ -8,6 +9,10 @@ import {
ZoomIn,
ZoomOut,
ScanSearch,
+ Upload,
+ Loader2,
+ CheckCircle2,
+ XCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -15,95 +20,221 @@ import { Input } from "@/components/ui/input";
import { useReactFlow, useStore } from "@xyflow/react";
import { useWorkflowStore } from "@/lib/stores/workflow-store";
import { workflowService } from "@/services/workflow.service";
+import { getExecution, startExecution } from "@/lib/api/executions";
import { validateWorkflow } from "@/lib/workflow/validator";
import { useRouter } from "next/navigation";
import { Badge } from "@/components/ui/badge";
export function WorkflowToolbar() {
+ const router = useRouter();
+ const [isPublishing, setIsPublishing] = useState(false);
+
+ const {
+ nodes,
+ edges,
+ workflowName,
+ setWorkflowName,
+ selectedNodeId,
+ duplicateNode,
+ exportWorkflow,
+ importWorkflow,
+ workflowId,
+ setWorkflowId,
+ workflowStatus,
+ setWorkflowStatus,
+ activeExecutionId,
+ activeExecutionStatus,
+ setActiveExecution,
+ setIsDirty,
+ isDirty
+ } = useWorkflowStore();
+
+ const isPublished = workflowStatus === "PUBLISHED";
+ const isExecuting =
+ activeExecutionStatus === "PENDING" || activeExecutionStatus === "RUNNING";
+
+ const { zoomIn, zoomOut, fitView } = useReactFlow();
+ const zoom = useStore((state) => state.transform[2]);
+
+ useEffect(() => {
+ if (!activeExecutionId) return;
+ if (
+ activeExecutionStatus === "COMPLETED" ||
+ activeExecutionStatus === "FAILED"
+ ) {
+ return;
+ }
-
- const router = useRouter()
-
- const {
- nodes,
- edges,
- workflowName,
- setWorkflowName,
- selectedNodeId,
- duplicateNode,
- exportWorkflow,
- importWorkflow,
- workflowId,
- setWorkflowId,
- setIsDirty,
- isDirty
- } = useWorkflowStore();
-
- const {zoomIn, zoomOut, fitView} = useReactFlow()
- const zoom = useStore((state) => state.transform[2])
-
- const handleSave = async () => {
- const workflow = exportWorkflow();
-
- const savedWorkflow = workflowId
- ? await workflowService.update(
- workflow.id,
- {
- name: workflow.name,
- description: workflow.description,
- },
- workflow.nodes,
- workflow.edges
- )
- : await workflowService.create(
- {
- name: workflow.name,
- description: workflow.description,
- },
- workflow.nodes,
- workflow.edges
- );
-
- setIsDirty(false)
-
- if(!workflowId){
- setWorkflowId(savedWorkflow.id);
-
- router.replace(`/workflows/${savedWorkflow.id}`)
+ let errorCount = 0;
+ const maxErrors = 3;
+
+ const intervalId = setInterval(async () => {
+ try {
+ const data = await getExecution(activeExecutionId);
+ errorCount = 0;
+ setActiveExecution(activeExecutionId, data.status);
+
+ if (data.status === "COMPLETED" || data.status === "FAILED") {
+ clearInterval(intervalId);
+ }
+ } catch (err) {
+ console.error("Failed to poll execution status:", err);
+ errorCount++;
+ if (errorCount >= maxErrors) {
+ clearInterval(intervalId);
+ alert("Lost connection to execution updates.");
+ }
}
+ }, 1500);
- console.log("Saved", savedWorkflow)
- };
-
- const handleLoad = async () => {
- const workflows =
- await workflowService.getAll();
-
- if (workflows.length === 0) return;
-
- const workflow =
- await workflowService.loadWorkflow(
- workflows[0].id
- );
-
- importWorkflow(workflow);
+ return () => clearInterval(intervalId);
+ }, [activeExecutionId, activeExecutionStatus, setActiveExecution]);
+
+ const handleSave = async () => {
+ if (isPublished) {
+ alert("Published workflows cannot be modified.");
+ return null;
}
- const handleValidate = () => {
- const errors = validateWorkflow(
- nodes,
- edges
+ const workflow = exportWorkflow();
+
+ const savedWorkflow = workflowId
+ ? await workflowService.update(
+ workflow.id,
+ {
+ name: workflow.name,
+ description: workflow.description,
+ },
+ workflow.nodes,
+ workflow.edges
+ )
+ : await workflowService.create(
+ {
+ name: workflow.name,
+ description: workflow.description,
+ },
+ workflow.nodes,
+ workflow.edges
);
-
- if (errors.length === 0) {
- alert("Workflow is valid");
+
+ setIsDirty(false);
+ setWorkflowStatus(savedWorkflow.status || "DRAFT");
+
+ if (!workflowId) {
+ setWorkflowId(savedWorkflow.id);
+
+ router.replace(`/workflows/${savedWorkflow.id}`);
+ }
+
+ console.log("Saved", savedWorkflow);
+ return savedWorkflow;
+ };
+
+ const handlePublish = async () => {
+ if (isPublished) {
+ alert("Workflow is already published.");
+ return;
+ }
+
+ setIsPublishing(true);
+
+ try {
+ let targetWorkflowId = workflowId;
+
+ if (!targetWorkflowId || isDirty) {
+ const savedWorkflow = await handleSave();
+ if (!savedWorkflow) {
+ setIsPublishing(false);
+ return;
+ }
+ targetWorkflowId = savedWorkflow.id;
+ }
+
+ if (!targetWorkflowId) {
+ setIsPublishing(false);
return;
}
-
- console.log(errors);
-
- alert(`${errors.length} validation errors`);
- };
+
+ await workflowService.publish(targetWorkflowId);
+ setWorkflowStatus("PUBLISHED");
+ setIsDirty(false);
+ alert("Workflow published successfully!");
+ } catch (error: any) {
+ console.error("Failed to publish workflow:", error);
+ alert(
+ error?.response?.data?.message ||
+ "Failed to publish workflow. Please try again."
+ );
+ } finally {
+ setIsPublishing(false);
+ }
+ };
+
+ const handleRunExecution = async () => {
+ if (!workflowId) {
+ alert("Please save and publish the workflow before running.");
+ return;
+ }
+
+ if (!isPublished) {
+ alert(
+ "Workflow must be published before it can be executed."
+ );
+ return;
+ }
+
+ if (isExecuting) {
+ return;
+ }
+
+ try {
+ setActiveExecution(null, "PENDING");
+ const response = await startExecution(workflowId);
+ // Backend returns record StartExecutionResponse(UUID workflowId, WorkflowExecutionStatus status)
+ // where response.workflowId is the execution UUID.
+ const executionId = response.workflowId;
+ const status = response.status || "PENDING";
+
+ setActiveExecution(executionId, status);
+ } catch (error: any) {
+ console.error("Failed to run workflow:", error);
+ setActiveExecution(null, null);
+ alert(
+ error?.response?.data?.message ||
+ "Failed to run workflow. Please ensure the workflow is published and try again."
+ );
+ }
+ };
+
+ const handleLoad = async () => {
+ const workflows =
+ await workflowService.getAll();
+
+ if (workflows.length === 0) return;
+
+ const workflow =
+ await workflowService.loadWorkflow(
+ workflows[0].id
+ );
+
+ importWorkflow(workflow);
+ };
+
+ const handleValidate = () => {
+ const errors = validateWorkflow(
+ nodes,
+ edges
+ );
+
+ if (errors.length === 0) {
+ alert("Workflow is valid");
+ return;
+ }
+
+ console.log(errors);
+
+ alert(`${errors.length} validation errors`);
+ };
return (
@@ -112,21 +243,47 @@ export function WorkflowToolbar() {
{
- setWorkflowName(e.target.value)
- setIsDirty(true)
+ disabled={isPublished}
+ onChange={(e) => {
+ setWorkflowName(e.target.value);
+ setIsDirty(true);
}}
className="w-64 border-zinc-700 bg-zinc-900"
/>
- {isDirty && (
+ {isDirty && !isPublished && (
- Unsaved
+ Unsaved
)}
-
- Draft
-
+ {isPublished ? (
+
+
+ Published
+
+ ) : (
+
+
+ Draft
+
+ )}
+
+ {activeExecutionStatus === "PENDING" || activeExecutionStatus === "RUNNING" ? (
+
+
+ Execution: {activeExecutionStatus}
+
+ ) : activeExecutionStatus === "COMPLETED" ? (
+
+
+ Execution: COMPLETED
+
+ ) : activeExecutionStatus === "FAILED" ? (
+
+
+ Execution: FAILED
+
+ ) : null}
{/* Center */}
@@ -179,37 +336,75 @@ export function WorkflowToolbar() {
{
- if(selectedNodeId){
- duplicateNode(selectedNodeId)
- }
- }}
+ variant="outline"
+ disabled={isPublished}
+ onClick={() => {
+ if (selectedNodeId && !isPublished) {
+ duplicateNode(selectedNodeId);
+ }
+ }}
>
- Duplicate
+ Duplicate
{/* Right */}
-
Validate
-
- Save
+
+ Save
+
+
+
+ {isPublishing ? (
+ <>
+
+ Publishing...
+ >
+ ) : isPublished ? (
+ <>
+
+ Published
+ >
+ ) : (
+ <>
+
+ Publish
+ >
+ )}
-
+
- Load
+ Load
-
-
- Run Workflow
+
+ {isExecuting ? (
+ <>
+
+ Running...
+ >
+ ) : (
+ <>
+
+ Run Workflow
+ >
+ )}
diff --git a/frontend/components/workflow-builder/hooks/useAuth.ts b/frontend/components/workflow-builder/hooks/useAuth.ts
index be2fcc5..262500f 100644
--- a/frontend/components/workflow-builder/hooks/useAuth.ts
+++ b/frontend/components/workflow-builder/hooks/useAuth.ts
@@ -4,13 +4,13 @@ import { useRouter } from "next/navigation";
import { useAuthStore } from "@/lib/stores/auth-store";
export function useAuth() {
-
const router = useRouter();
const {
accessToken,
refreshToken,
tokenType,
+ orgId,
setTokens,
clearTokens,
} = useAuthStore();
@@ -20,11 +20,15 @@ export function useAuth() {
router.replace("/login");
};
+ const isAuthenticated = Boolean(accessToken);
+
return {
accessToken,
refreshToken,
tokenType,
+ orgId,
setTokens,
logout,
+ isAuthenticated,
};
}
\ No newline at end of file
diff --git a/frontend/components/workflow-builder/nodes/condition-node.tsx b/frontend/components/workflow-builder/nodes/condition-node.tsx
index 2b6e04e..2cd9644 100644
--- a/frontend/components/workflow-builder/nodes/condition-node.tsx
+++ b/frontend/components/workflow-builder/nodes/condition-node.tsx
@@ -5,6 +5,7 @@ import {
Position,
} from "@xyflow/react";
import { GitBranch } from "lucide-react";
+import { getNodeStatusStyle } from "@/lib/react-flow/node-types";
export function ConditionNode({
data,
@@ -12,10 +13,16 @@ export function ConditionNode({
data: {
label: string;
condition: string;
+ executionStatus?: string;
};
}) {
+ const borderClass = getNodeStatusStyle(
+ data?.executionStatus,
+ "border-yellow-500/50"
+ );
+
return (
-
+
+ );
+}
diff --git a/frontend/components/workflow-builder/nodes/http-request-node.tsx b/frontend/components/workflow-builder/nodes/http-request-node.tsx
index 7976948..9fc460a 100644
--- a/frontend/components/workflow-builder/nodes/http-request-node.tsx
+++ b/frontend/components/workflow-builder/nodes/http-request-node.tsx
@@ -2,6 +2,7 @@
import { Handle, Position } from "@xyflow/react";
import { Globe } from "lucide-react";
+import { getNodeStatusStyle } from "@/lib/react-flow/node-types";
export function HttpRequestNode({
data,
@@ -10,18 +11,24 @@ export function HttpRequestNode({
label: string;
method: string;
endpoint: string;
+ executionStatus?: string;
};
}) {
+ const borderClass = getNodeStatusStyle(
+ data?.executionStatus,
+ "border-zinc-800"
+ );
+
return (
-
+
-
-
-
+
+
+
{data.label}
@@ -30,7 +37,10 @@ export function HttpRequestNode({
{data.method}
-
+
{data.endpoint}
diff --git a/frontend/components/workflow-builder/nodes/start-node.tsx b/frontend/components/workflow-builder/nodes/start-node.tsx
index f810e2b..393673a 100644
--- a/frontend/components/workflow-builder/nodes/start-node.tsx
+++ b/frontend/components/workflow-builder/nodes/start-node.tsx
@@ -1,10 +1,22 @@
"use client";
import { Handle, Position } from "@xyflow/react";
+import { getNodeStatusStyle } from "@/lib/react-flow/node-types";
+
+export function StartNode({
+ data,
+}: {
+ data?: {
+ executionStatus?: string;
+ };
+}) {
+ const borderClass = getNodeStatusStyle(
+ data?.executionStatus,
+ "border-green-500"
+ );
-export function StartNode() {
return (
-
+
Start
{
+ const response = await api.post(
+ "/api/auth/refresh",
+ {
+ refreshToken,
+ }
+ );
+
+ return response.data;
}
export async function registerOrg(data: RegisterRequest) {
diff --git a/frontend/lib/api/axios.ts b/frontend/lib/api/axios.ts
index 3920d9d..6444dcb 100644
--- a/frontend/lib/api/axios.ts
+++ b/frontend/lib/api/axios.ts
@@ -1,19 +1,102 @@
-import axios from "axios";
+import axios, {
+ AxiosError,
+ InternalAxiosRequestConfig,
+} from "axios";
import { useAuthStore } from "../stores/auth-store";
+import { LoginResponse } from "@/types/auth";
export const api = axios.create({
- baseURL: process.env.NEXT_PUBLIC_API_URL
+ baseURL: process.env.NEXT_PUBLIC_API_URL,
+});
+
+const refreshApi = axios.create({
+ baseURL: process.env.NEXT_PUBLIC_API_URL,
});
api.interceptors.request.use(
(config) => {
- const token = useAuthStore.getState().accessToken;
+ const { accessToken, orgId } = useAuthStore.getState();
+
+ if (accessToken) {
+ config.headers.Authorization = `Bearer ${accessToken}`;
+ }
- if (token) {
- config.headers.Authorization = `Bearer ${token}`;
+ if (orgId) {
+ config.headers["X-Organization-Id"] = orgId;
}
return config;
},
(error) => Promise.reject(error)
+);
+
+let refreshPromise: Promise | null = null;
+
+api.interceptors.response.use(
+ (response) => response,
+
+ async (error: AxiosError) => {
+ const originalRequest =
+ error.config as InternalAxiosRequestConfig & {
+ _retry?: boolean;
+ };
+
+ // Only handle 401 responses
+ if (error.response?.status !== 401) {
+ return Promise.reject(error);
+ }
+
+ // Don't retry the same request more than once
+ if (originalRequest._retry) {
+ useAuthStore.getState().clearTokens();
+ return Promise.reject(error);
+ }
+
+ const { refreshToken } = useAuthStore.getState();
+
+ if (!refreshToken) {
+ useAuthStore.getState().clearTokens();
+ return Promise.reject(error);
+ }
+
+ originalRequest._retry = true;
+
+ try {
+ if (!refreshPromise) {
+ refreshPromise = refreshApi
+ .post("/api/auth/refresh", {
+ refreshToken,
+ })
+ .then((response) => {
+ const data = response.data.data;
+
+ useAuthStore.getState().setTokens(
+ data.accessToken,
+ data.refreshToken,
+ data.tokenType
+ );
+
+ return data.accessToken;
+ })
+ .finally(() => {
+ refreshPromise = null;
+ });
+ }
+
+ const newAccessToken = await refreshPromise;
+
+ originalRequest.headers.Authorization =
+ `Bearer ${newAccessToken}`;
+
+ return api(originalRequest);
+ } catch (refreshError) {
+ useAuthStore.getState().clearTokens();
+
+ if (typeof window !== "undefined") {
+ window.location.href = "/login";
+ }
+
+ return Promise.reject(refreshError);
+ }
+ }
);
\ No newline at end of file
diff --git a/frontend/lib/api/dashboard.ts b/frontend/lib/api/dashboard.ts
index a0475ac..7d6f378 100644
--- a/frontend/lib/api/dashboard.ts
+++ b/frontend/lib/api/dashboard.ts
@@ -1,9 +1,192 @@
-import { mockDashboardData } from "@/mocks/dashboard";
-import { delay } from "@/mocks/delay";
-import { DashboardData } from "@/types/dashboard";
+import { getWorkflows } from "./workflow";
+import { getExecutionsData, getExecutionTasks } from "./executions";
+import { Activity, DashboardData, RunningExecution } from "@/types/dashboard";
+
+function formatNodeType(nodeType?: string): string {
+ if (!nodeType) return "Processing";
+ const upper = nodeType.toUpperCase();
+ if (upper === "START") return "Start Node";
+ if (upper === "END") return "End Node";
+ if (upper === "HTTP_REQUEST" || upper === "HTTPREQUEST") return "HTTP Request";
+ if (upper === "CONDITION") return "Condition";
+ return nodeType
+ .replace(/_/g, " ")
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
+ .replace(/\b\w/g, (c) => c.toUpperCase());
+}
export async function getDashboardData(): Promise {
- await delay(500);
+ const now = Date.now();
+ const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000;
+ const twentyFourHoursAgo = now - 24 * 60 * 60 * 1000;
+ const fortyEightHoursAgo = now - 48 * 60 * 60 * 1000;
+
+ const [workflowsResult, executionsResult] = await Promise.allSettled([
+ getWorkflows(),
+ getExecutionsData(),
+ ]);
+
+ const workflows =
+ workflowsResult.status === "fulfilled" && Array.isArray(workflowsResult.value)
+ ? workflowsResult.value
+ : [];
+
+ const executions =
+ executionsResult.status === "fulfilled" && Array.isArray(executionsResult.value)
+ ? executionsResult.value
+ : [];
+
+ const totalWorkflows = workflows.length;
+ const createdThisWeek = workflows.filter((w) => {
+ if (!w.createdAt) return false;
+ return new Date(w.createdAt).getTime() >= sevenDaysAgo;
+ }).length;
+
+ const totalExecutions = executions.length;
+ const runningExecutionsList = executions.filter(
+ (e) => e.status === "RUNNING" || e.status === "PENDING" || e.status === "WAITING"
+ );
+ const runningExecutionsCount = runningExecutionsList.length;
+
+ const completedExecutionsCount = executions.filter(
+ (e) => e.status === "COMPLETED"
+ ).length;
+
+ const failedExecutions = executions.filter((e) => e.status === "FAILED");
+
+ const finishedExecutionsCount = completedExecutionsCount + failedExecutions.length;
+ const successRate =
+ finishedExecutionsCount > 0
+ ? Number(((completedExecutionsCount / finishedExecutionsCount) * 100).toFixed(1))
+ : 100;
+
+ const failedExecutions24h = failedExecutions.filter((e) => {
+ if (!e.startedAt) return false;
+ return new Date(e.startedAt).getTime() >= twentyFourHoursAgo;
+ }).length;
+
+ const prev24hFailures = failedExecutions.filter((e) => {
+ if (!e.startedAt) return false;
+ const time = new Date(e.startedAt).getTime();
+ return time >= fortyEightHoursAgo && time < twentyFourHoursAgo;
+ }).length;
+
+ const failureChange = failedExecutions24h - prev24hFailures;
+
+ const runningExecutionsData: RunningExecution[] = await Promise.all(
+ runningExecutionsList.slice(0, 10).map(async (exec) => {
+ let progress = exec.status === "RUNNING" ? 50 : 0;
+ let currentStep = exec.status === "RUNNING" ? "Execution In Progress" : "Pending Queue";
+
+ try {
+ const tasks = await getExecutionTasks(exec.id);
+ if (Array.isArray(tasks) && tasks.length > 0) {
+ const totalTasks = tasks.length;
+ const completedTasks = tasks.filter(
+ (t: any) =>
+ t.status?.toUpperCase() === "COMPLETED" ||
+ t.status?.toUpperCase() === "SUCCESS"
+ ).length;
+
+ progress = Math.round((completedTasks / totalTasks) * 100);
+
+ const activeTask =
+ tasks.find(
+ (t: any) =>
+ t.status?.toUpperCase() === "RUNNING" ||
+ t.status?.toUpperCase() === "WAITING"
+ ) ||
+ tasks.find((t: any) => t.status?.toUpperCase() === "PENDING") ||
+ tasks[tasks.length - 1];
+
+ if (activeTask) {
+ currentStep = formatNodeType(activeTask.nodeType);
+ }
+ }
+ } catch (err) {
+ console.warn(`Failed to fetch tasks for active execution ${exec.id}:`, err);
+ }
+
+ return {
+ id: exec.id,
+ workflowName: exec.workflow.name,
+ currentStep,
+ progress,
+ status: exec.status === "WAITING" ? "WAITING" : "RUNNING",
+ startedAt: exec.startedAt || new Date().toISOString(),
+ };
+ })
+ );
+
+ const activities: Activity[] = [];
+ executions.forEach((e) => {
+ if (e.startedAt) {
+ activities.push({
+ id: `start-${e.id}`,
+ type: "EXECUTION_STARTED",
+ message: `Execution Started: '${e.workflow.name}'`,
+ createdAt: e.startedAt,
+ });
+ }
+
+ if (e.completedAt) {
+ if (e.status === "FAILED") {
+ activities.push({
+ id: `fail-${e.id}`,
+ type: "EXECUTION_FAILED",
+ message: `Execution Failed: '${e.workflow.name}'`,
+ createdAt: e.completedAt,
+ });
+ } else if (e.status === "COMPLETED") {
+ const durationText = e.duration ? ` in ${e.duration}s` : "";
+ activities.push({
+ id: `complete-${e.id}`,
+ type: "EXECUTION_COMPLETED",
+ message: `Execution Completed: '${e.workflow.name}'${durationText}`,
+ createdAt: e.completedAt,
+ });
+ }
+ }
+ });
+
+ activities.sort(
+ (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
+ );
+
+ const completedWithDuration = executions.filter(
+ (e) => e.status === "COMPLETED" && typeof e.duration === "number" && e.duration >= 0
+ );
+
+ const avgDuration =
+ completedWithDuration.length > 0
+ ? Number(
+ (
+ completedWithDuration.reduce((acc, curr) => acc + (curr.duration || 0), 0) /
+ completedWithDuration.length
+ ).toFixed(1)
+ )
+ : 0;
+
+ const failureRate = Number((100 - successRate).toFixed(1));
- return mockDashboardData;
-}
\ No newline at end of file
+ return {
+ stats: {
+ totalWorkflows,
+ createdThisWeek,
+ runningExecutions: runningExecutionsCount,
+ completedExecutions: completedExecutionsCount,
+ successRate,
+ failedExecutions24h,
+ failureChange,
+ totalExecutions,
+ },
+ runningExecutions: runningExecutionsData,
+ activities: activities.slice(0, 10),
+ systemHealth: {
+ successRate,
+ failureRate,
+ activeInstances: runningExecutionsCount,
+ averageExecutionTime: avgDuration,
+ },
+ };
+}
diff --git a/frontend/lib/api/execution-details.ts b/frontend/lib/api/execution-details.ts
index 6fa26eb..f6fdfb9 100644
--- a/frontend/lib/api/execution-details.ts
+++ b/frontend/lib/api/execution-details.ts
@@ -1,9 +1,376 @@
-import { executionDetails } from "@/mocks/execution-details";
-
-export async function getExecutionDetails(id: string) {
- return (
- executionDetails.find(
- (execution) => execution.id === id
- ) ?? null
- );
+import { getExecution, getExecutionTasks } from "./executions";
+import { getWorkflowById } from "./workflow";
+import {
+ ExecutionDetails,
+ ExecutionNodeStatus,
+ ExecutionTimelineItem,
+ ExecutionLog,
+} from "@/types/execution-details";
+import { TaskExecutionResponse } from "@/types/execution";
+
+function formatNodeType(nodeType?: string): string {
+ if (!nodeType) return "Task";
+ const upper = nodeType.toUpperCase();
+ if (upper === "START") return "START";
+ if (upper === "END") return "END";
+ if (upper === "HTTP_REQUEST" || upper === "HTTPREQUEST") return "HTTP Request";
+ if (upper === "CONDITION") return "Condition";
+ return nodeType
+ .replace(/_/g, " ")
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
+ .replace(/\b\w/g, (c) => c.toUpperCase());
+}
+
+function formatStatusName(status?: string): string {
+ const s = (status || "").toUpperCase();
+ if (s === "PENDING") return "Pending";
+ if (s === "RUNNING") return "Running";
+ if (s === "COMPLETED" || s === "SUCCESS") return "Success";
+ if (s === "FAILED") return "Failed";
+ if (s === "WAITING") return "Waiting";
+ return status ? status.charAt(0).toUpperCase() + status.slice(1).toLowerCase() : "Unknown";
+}
+
+function extractUrlFromError(errorMessage?: string | null): string | null {
+ if (!errorMessage) return null;
+ const urlMatch = errorMessage.match(/https?:\/\/[^\s<"']+/i);
+ return urlMatch ? urlMatch[0] : null;
+}
+
+function formatConciseError(errorMessage?: string | null): string {
+ if (!errorMessage) return "Execution failed";
+
+ const statusTextMap: Record = {
+ "400": "Bad Request",
+ "401": "Unauthorized",
+ "403": "Forbidden",
+ "404": "Not Found",
+ "405": "Method Not Allowed",
+ "408": "Request Timeout",
+ "429": "Too Many Requests",
+ "500": "Internal Server Error",
+ "502": "Bad Gateway",
+ "503": "Service Temporarily Unavailable",
+ "504": "Gateway Timeout",
+ };
+
+ if (errorMessage.includes("]*>(.*?)<\/title>/i);
+ if (titleMatch && titleMatch[1]) {
+ const cleanTitle = titleMatch[1].trim();
+ const innerCodeMatch = cleanTitle.match(/\b([1-5]\d\d)\b/);
+ if (innerCodeMatch && statusTextMap[innerCodeMatch[1]]) {
+ return `HTTP ${innerCodeMatch[1]} — ${statusTextMap[innerCodeMatch[1]]}`;
+ }
+ if (cleanTitle) return cleanTitle;
+ }
+ const h1Match = errorMessage.match(/]*>(.*?)<\/h1>/i);
+ if (h1Match && h1Match[1]) {
+ const h1Text = h1Match[1].trim();
+ const innerCodeMatch = h1Text.match(/\b([1-5]\d\d)\b/);
+ if (innerCodeMatch && statusTextMap[innerCodeMatch[1]]) {
+ return `HTTP ${innerCodeMatch[1]} — ${statusTextMap[innerCodeMatch[1]]}`;
+ }
+ return h1Text;
+ }
+ }
+
+ const statusMatch = errorMessage.match(/\b(HTTP\s+)?([1-5]\d\d)\b/i);
+ if (statusMatch) {
+ const code = statusMatch[2];
+ if (statusTextMap[code]) {
+ return `HTTP ${code} — ${statusTextMap[code]}`;
+ }
+ }
+
+ const cleanStr = errorMessage.replace(/<[^>]*>?/gm, "").trim();
+ const firstLine = cleanStr.split("\n")[0].trim();
+ if (firstLine.length > 80) {
+ return firstLine.substring(0, 77) + "...";
+ }
+ return firstLine || "Execution failed";
+}
+
+function formatDuration(
+ startedAt?: string,
+ completedAt?: string | null
+): string {
+ if (!startedAt) return "00:00";
+ const start = new Date(startedAt).getTime();
+ const end = completedAt ? new Date(completedAt).getTime() : Date.now();
+ const diffSec = Math.max(0, Math.floor((end - start) / 1000));
+ const hrs = Math.floor(diffSec / 3600);
+ const mins = Math.floor((diffSec % 3600) / 60);
+ const secs = diffSec % 60;
+ if (hrs > 0) {
+ return `${String(hrs).padStart(2, "0")}:${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
+ }
+ return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
+}
+
+function formatTime(dateStr?: string | null): string {
+ if (!dateStr) return "";
+ try {
+ return new Date(dateStr).toLocaleTimeString();
+ } catch {
+ return dateStr;
+ }
+}
+
+export async function getExecutionDetails(
+ id: string
+): Promise {
+ try {
+ const executionData = await getExecution(id);
+ if (!executionData) return null;
+
+ let tasks: TaskExecutionResponse[] = [];
+ try {
+ const rawTasks = await getExecutionTasks(id);
+ if (Array.isArray(rawTasks)) {
+ tasks = rawTasks;
+ }
+ } catch (err) {
+ console.warn("Failed to fetch task executions:", err);
+ }
+
+ let workflowName = `Workflow (${executionData.workflowId.substring(0, 8)})`;
+ if (executionData.workflowId) {
+ try {
+ const workflow = await getWorkflowById(executionData.workflowId);
+ if (workflow?.name) {
+ workflowName = workflow.name;
+ }
+ } catch {
+ // Fall back gracefully
+ }
+ }
+
+ const duration = formatDuration(
+ executionData.startedAt,
+ executionData.completedAt
+ );
+
+ const completedCount = tasks.filter(
+ (t) =>
+ t.status?.toUpperCase() === "COMPLETED" ||
+ t.status?.toUpperCase() === "SUCCESS"
+ ).length;
+ const activeCount = tasks.filter(
+ (t) =>
+ t.status?.toUpperCase() === "RUNNING" ||
+ t.status?.toUpperCase() === "PENDING"
+ ).length;
+ const errorCount = tasks.filter(
+ (t) => t.status?.toUpperCase() === "FAILED"
+ ).length;
+
+ const totalTasks = tasks.length;
+ const progress =
+ totalTasks > 0
+ ? Math.round((completedCount / totalTasks) * 100)
+ : executionData.status?.toUpperCase() === "COMPLETED"
+ ? 100
+ : 0;
+
+ const mapNodeStatus = (status: string): ExecutionNodeStatus => {
+ const s = (status || "").toUpperCase();
+ if (s === "COMPLETED" || s === "SUCCESS") return "SUCCESS";
+ if (s === "RUNNING") return "RUNNING";
+ if (s === "FAILED") return "FAILED";
+ if (s === "WAITING") return "WAITING";
+ if (s === "PENDING") return "PENDING";
+ return "IDLE";
+ };
+
+ const nodes = tasks.map((t) => ({
+ id: t.nodeId,
+ status: mapNodeStatus(t.status),
+ }));
+
+ interface InternalTimelineItem extends ExecutionTimelineItem {
+ sortTime: number;
+ }
+
+ const timelineItemsWithTime: InternalTimelineItem[] = [];
+
+ if (executionData.startedAt) {
+ timelineItemsWithTime.push({
+ id: "start",
+ title: "Workflow — Started",
+ description: `Execution started for ${workflowName}`,
+ timestamp: formatTime(executionData.startedAt),
+ status: "SUCCESS",
+ sortTime: new Date(executionData.startedAt).getTime(),
+ });
+ }
+
+ tasks.forEach((t, idx) => {
+ const statusUpper = (t.status || "").toUpperCase();
+ if (!t.startedAt && statusUpper === "PENDING") {
+ return;
+ }
+
+ const taskId = t.taskExecutionId || t.id || `task-${idx}`;
+ let itemStatus: "SUCCESS" | "WAITING" | "FAILED" | "PENDING" | "RUNNING" = "SUCCESS";
+ if (statusUpper === "FAILED") itemStatus = "FAILED";
+ else if (statusUpper === "WAITING") itemStatus = "WAITING";
+ else if (statusUpper === "PENDING") itemStatus = "PENDING";
+ else if (statusUpper === "RUNNING") itemStatus = "RUNNING";
+ else if (statusUpper === "COMPLETED" || statusUpper === "SUCCESS") itemStatus = "SUCCESS";
+
+ const nodeTypeName = formatNodeType(t.nodeType);
+ const statusName = formatStatusName(t.status);
+ const title = `${nodeTypeName} — ${statusName}`;
+ const conciseErr = formatConciseError(t.errorMessage);
+ const reqUrl = extractUrlFromError(t.errorMessage);
+
+ let description = "Completed successfully";
+ if (statusUpper === "FAILED") {
+ description = reqUrl ? `${conciseErr} (${reqUrl})` : conciseErr;
+ } else if (statusUpper === "RUNNING") {
+ description = "Execution in progress";
+ } else if (statusUpper === "PENDING") {
+ description = "Execution pending";
+ } else if (statusUpper === "WAITING") {
+ description = "Waiting for approval or input";
+ }
+
+ const rawTimeStr = t.completedAt || t.startedAt || executionData.startedAt;
+ const sortTime = rawTimeStr ? new Date(rawTimeStr).getTime() : Date.now();
+
+ timelineItemsWithTime.push({
+ id: taskId,
+ title,
+ description,
+ timestamp: formatTime(t.completedAt || t.startedAt),
+ status: itemStatus,
+ errorMessage: t.errorMessage || undefined,
+ sortTime,
+ });
+ });
+
+ if (executionData.completedAt) {
+ const isFailed = executionData.status?.toUpperCase() === "FAILED";
+ timelineItemsWithTime.push({
+ id: "completed",
+ title: `Workflow — ${isFailed ? "Failed" : "Completed"}`,
+ description: `Workflow execution ${executionData.status.toLowerCase()}`,
+ timestamp: formatTime(executionData.completedAt),
+ status: isFailed ? "FAILED" : "SUCCESS",
+ sortTime: new Date(executionData.completedAt).getTime(),
+ });
+ }
+
+ timelineItemsWithTime.sort((a, b) => a.sortTime - b.sortTime);
+
+ const timeline: ExecutionTimelineItem[] = timelineItemsWithTime.map(
+ ({ sortTime, ...item }) => item
+ );
+
+ interface InternalExecutionLog extends ExecutionLog {
+ sortTime: number;
+ }
+ const logItemsWithTime: InternalExecutionLog[] = [];
+
+ if (executionData.startedAt) {
+ logItemsWithTime.push({
+ id: "log-start",
+ timestamp: formatTime(executionData.startedAt),
+ level: "SYSTEM",
+ message: `Workflow execution started`,
+ sortTime: new Date(executionData.startedAt).getTime(),
+ });
+ }
+
+ tasks.forEach((t, idx) => {
+ const taskId = t.taskExecutionId || t.id || `task-${idx}`;
+ const nodeTypeName = formatNodeType(t.nodeType);
+ const statusUpper = (t.status || "").toUpperCase();
+ const conciseErr = formatConciseError(t.errorMessage);
+
+ if (t.startedAt) {
+ const logLevel = statusUpper === "WAITING" ? "WAIT" : "INFO";
+ const startStatusText = statusUpper === "WAITING" ? "Waiting" : "Running";
+ logItemsWithTime.push({
+ id: `log-${taskId}-start`,
+ timestamp: formatTime(t.startedAt),
+ level: logLevel,
+ message: `${nodeTypeName} started — ${startStatusText}`,
+ sortTime: new Date(t.startedAt).getTime(),
+ });
+ }
+
+ if (t.completedAt && (statusUpper === "COMPLETED" || statusUpper === "SUCCESS")) {
+ logItemsWithTime.push({
+ id: `log-${taskId}-complete`,
+ timestamp: formatTime(t.completedAt),
+ level: "INFO",
+ message: `${nodeTypeName} completed — Success`,
+ sortTime: new Date(t.completedAt).getTime(),
+ });
+ }
+
+ if (t.errorMessage || statusUpper === "FAILED") {
+ const errorTimeStr = t.completedAt || t.startedAt || executionData.startedAt;
+ logItemsWithTime.push({
+ id: `log-${taskId}-err`,
+ timestamp: formatTime(t.completedAt || t.startedAt),
+ level: "ERROR",
+ message: `${nodeTypeName} failed — ${conciseErr}`,
+ sortTime: errorTimeStr ? new Date(errorTimeStr).getTime() : Date.now(),
+ });
+ }
+ });
+
+ if (executionData.completedAt) {
+ const isFailed = executionData.status?.toUpperCase() === "FAILED";
+ logItemsWithTime.push({
+ id: "log-end",
+ timestamp: formatTime(executionData.completedAt),
+ level: isFailed ? "ERROR" : "SYSTEM",
+ message: `Workflow execution ${executionData.status.toLowerCase()}`,
+ sortTime: new Date(executionData.completedAt).getTime(),
+ });
+ }
+
+ logItemsWithTime.sort((a, b) => a.sortTime - b.sortTime);
+
+ const logs: ExecutionLog[] = logItemsWithTime.map(
+ ({ sortTime, ...log }) => log
+ );
+
+ let mappedStatus: "RUNNING" | "WAITING" | "FAILED" | "COMPLETED" = "RUNNING";
+ const rawStatus = (executionData.status || "").toUpperCase();
+ if (rawStatus === "COMPLETED") mappedStatus = "COMPLETED";
+ else if (rawStatus === "FAILED") mappedStatus = "FAILED";
+ else if (rawStatus === "WAITING") mappedStatus = "WAITING";
+ else mappedStatus = "RUNNING";
+
+ return {
+ id: executionData.executionId,
+ workflowId: executionData.workflowId,
+ workflowName,
+ duration,
+ progress,
+ status: mappedStatus,
+ stats: {
+ success: completedCount,
+ active: activeCount,
+ error: errorCount,
+ },
+ infrastructure: {
+ worker: "default-worker",
+ memory: "N/A",
+ },
+ nodes,
+ timeline,
+ logs,
+ };
+ } catch (err: any) {
+ if (err?.response?.status === 404) {
+ return null;
+ }
+ throw err;
+ }
}
\ No newline at end of file
diff --git a/frontend/lib/api/executions.ts b/frontend/lib/api/executions.ts
index 625390c..23dc76e 100644
--- a/frontend/lib/api/executions.ts
+++ b/frontend/lib/api/executions.ts
@@ -1,8 +1,74 @@
-import { mockExecutionsData } from "@/mocks/executions";
-import { delay } from "@/mocks/delay";
-import { ExecutionsData } from "@/types/execution";
+import { api } from "./axios";
+import { getWorkflows } from "./workflow";
+import {
+ GetExecutionResponse,
+ Execution,
+} from "@/types/execution";
-export async function getExecutionsData(): Promise {
- // await delay(700)
- return mockExecutionsData;
+export async function startExecution(workflowId: string) {
+ const response = await api.post(
+ `/api/v1/executions/${workflowId}`
+ );
+ return response.data;
+}
+
+export async function getExecution(
+ executionId: string
+): Promise {
+ const response = await api.get(
+ `/api/v1/executions/${executionId}`
+ );
+ return response.data;
+}
+
+export async function getExecutionTasks(executionId: string) {
+ const response = await api.get(
+ `/api/v1/executions/${executionId}/tasks`
+ );
+ return response.data;
+}
+
+export async function getExecutionsData(): Promise {
+ const response = await api.get("/api/v1/executions");
+ const rawExecutions = response.data || [];
+
+ const workflowMap = new Map();
+
+ try {
+ const workflows = await getWorkflows();
+ if (Array.isArray(workflows)) {
+ workflows.forEach((w) => {
+ if (w.id) {
+ workflowMap.set(w.id, w.name || "Untitled Workflow");
+ }
+ });
+ }
+ } catch (err) {
+ // If fetching workflow list fails, fall back gracefully
+ }
+
+ return rawExecutions.map((item) => {
+ let duration: number | null = null;
+ if (item.startedAt && item.completedAt) {
+ const startTime = new Date(item.startedAt).getTime();
+ const endTime = new Date(item.completedAt).getTime();
+ duration = Math.max(0, Math.round((endTime - startTime) / 1000));
+ }
+
+ const workflowName =
+ workflowMap.get(item.workflowId) ||
+ `Workflow (${item.workflowId.substring(0, 8)})`;
+
+ return {
+ id: item.executionId,
+ workflow: {
+ id: item.workflowId,
+ name: workflowName,
+ },
+ status: (item.status || "PENDING").toUpperCase(),
+ startedAt: item.startedAt,
+ completedAt: item.completedAt,
+ duration,
+ };
+ });
}
\ No newline at end of file
diff --git a/frontend/lib/api/workflow.ts b/frontend/lib/api/workflow.ts
index 5ac501e..cc71985 100644
--- a/frontend/lib/api/workflow.ts
+++ b/frontend/lib/api/workflow.ts
@@ -1,7 +1,7 @@
import { api } from "./axios";
import { CreateWorkflowRequest, UpdateWorkflowRequest } from "@/types/workflow";
import { Workflow } from "@/types/workflow";
-import { WorkflowDefinition } from "@/types/workflow-definition";
+import { WorkflowDefinition, WorkflowDefinitionResponse } from "@/types/workflow-definition";
export const createWorkflow = async (
data: CreateWorkflowRequest
@@ -50,8 +50,8 @@ export const deleteWorkflow = async (
export const getWorkflowDefinition = async (
workflowId: string
-): Promise => {
- const response = await api.get(
+): Promise => {
+ const response = await api.get(
`/api/v1/workflows/${workflowId}/definition`
);
@@ -66,4 +66,10 @@ export const updateWorkflowDefinition = async (
`/api/v1/workflows/${workflowId}/definition`,
definition
);
+};
+
+export const publishWorkflow = async (
+ workflowId: string
+): Promise => {
+ await api.post(`/api/v1/workflows/${workflowId}/publish`);
};
\ No newline at end of file
diff --git a/frontend/lib/mappers/workflow.mapper.ts b/frontend/lib/mappers/workflow.mapper.ts
index 48717a6..2df180b 100644
--- a/frontend/lib/mappers/workflow.mapper.ts
+++ b/frontend/lib/mappers/workflow.mapper.ts
@@ -4,16 +4,19 @@ import {
WorkflowEdge,
WorkflowNode,
WorkflowNodeType,
+ WorkflowDefinitionResponse,
} from "@/types/workflow-definition";
const NODE_TYPE_MAP: Record = {
start: "START",
+ end: "END",
httpRequest: "HTTP_REQUEST",
condition: "CONDITION",
};
const REVERSE_NODE_TYPE_MAP = {
START: "start",
+ END: "end",
HTTP_REQUEST: "httpRequest",
CONDITION: "condition",
} as const;
@@ -30,7 +33,7 @@ export const toWorkflowDefinition = (
nodeType:
NODE_TYPE_MAP[
- node.type as WorkflowNodeType
+ node.type as WorkflowNodeType
],
positionX: node.position.x,
@@ -47,18 +50,18 @@ export const toWorkflowDefinition = (
};
export const fromWorkflowDefinition = (
- definition: WorkflowDefinition
+ definition: WorkflowDefinitionResponse
): {
nodes: Node[];
edges: Edge[];
} => {
return {
nodes: definition.nodes.map((node): Node => ({
- id: node.clientId,
+ id: node.id,
type:
REVERSE_NODE_TYPE_MAP[
- node.nodeType as keyof typeof REVERSE_NODE_TYPE_MAP
+ node.nodeType as keyof typeof REVERSE_NODE_TYPE_MAP
],
position: {
@@ -70,10 +73,10 @@ export const fromWorkflowDefinition = (
})),
edges: definition.edges.map((edge): Edge => ({
- id: `${edge.sourceClientId}-${edge.targetClientId}`,
-
- source: edge.sourceClientId,
- target: edge.targetClientId,
+ id: `${edge.sourceNodeId}-${edge.targetNodeId}`,
+
+ source: edge.sourceNodeId,
+ target: edge.targetNodeId,
})),
};
};
\ No newline at end of file
diff --git a/frontend/lib/react-flow/node-factory.ts b/frontend/lib/react-flow/node-factory.ts
index 651112e..62d0d1f 100644
--- a/frontend/lib/react-flow/node-factory.ts
+++ b/frontend/lib/react-flow/node-factory.ts
@@ -1,7 +1,8 @@
import { Node, XYPosition } from "@xyflow/react";
+import { WorkflowNodeType } from "@/types/workflow-definition";
export function createNode(
- type: "start" | "httpRequest" | "condition", position : XYPosition
+ type: WorkflowNodeType, position: XYPosition
): Node {
switch (type) {
case "start":
@@ -14,6 +15,16 @@ export function createNode(
},
};
+ case "end":
+ return {
+ id: crypto.randomUUID(),
+ type,
+ position,
+ data: {
+ label: "End",
+ },
+ };
+
case "httpRequest":
return {
id: crypto.randomUUID(),
diff --git a/frontend/lib/react-flow/node-library.ts b/frontend/lib/react-flow/node-library.ts
index dfe3fa6..992785b 100644
--- a/frontend/lib/react-flow/node-library.ts
+++ b/frontend/lib/react-flow/node-library.ts
@@ -1,5 +1,6 @@
import {
Play,
+ Square,
Globe,
GitBranch,
LucideIcon,
@@ -21,6 +22,12 @@ export const NODE_LIBRARY: NodeLibraryItem[] = [
description: "Workflow entry point",
icon: Play,
},
+ {
+ type: "end",
+ title: "End",
+ description: "Workflow exit point",
+ icon: Square,
+ },
{
type: "httpRequest",
title: "HTTP Request",
diff --git a/frontend/lib/react-flow/node-types.ts b/frontend/lib/react-flow/node-types.ts
index ff64a5a..b5121f2 100644
--- a/frontend/lib/react-flow/node-types.ts
+++ b/frontend/lib/react-flow/node-types.ts
@@ -1,11 +1,13 @@
import { HttpRequestNode } from "@/components/workflow-builder/nodes/http-request-node";
import { StartNode } from "@/components/workflow-builder/nodes/start-node";
+import { EndNode } from "@/components/workflow-builder/nodes/end-node";
import { ConditionNode } from "@/components/workflow-builder/nodes/condition-node";
export const nodeTypes = {
start: StartNode,
+ end: EndNode,
httpRequest: HttpRequestNode,
- condition: ConditionNode
+ condition: ConditionNode,
};
export type ExecutionNodeStatus =
@@ -13,9 +15,33 @@ export type ExecutionNodeStatus =
| "RUNNING"
| "WAITING"
| "FAILED"
+ | "PENDING"
| "IDLE";
export interface WorkflowNodeData {
label: string;
executionStatus?: ExecutionNodeStatus;
+}
+
+export function getNodeStatusStyle(
+ executionStatus?: string,
+ defaultBorder: string = "border-zinc-800"
+): string {
+ if (!executionStatus) return defaultBorder;
+ const s = executionStatus.toUpperCase();
+ switch (s) {
+ case "SUCCESS":
+ case "COMPLETED":
+ return "border-emerald-500 shadow-[0_0_12px_rgba(16,185,129,0.25)]";
+ case "FAILED":
+ return "border-red-500 shadow-[0_0_12px_rgba(239,68,68,0.25)]";
+ case "RUNNING":
+ return "border-blue-500 shadow-[0_0_12px_rgba(59,130,246,0.25)] animate-pulse";
+ case "WAITING":
+ return "border-amber-500 shadow-[0_0_12px_rgba(245,158,11,0.25)]";
+ case "PENDING":
+ case "IDLE":
+ default:
+ return "border-zinc-700";
+ }
}
\ No newline at end of file
diff --git a/frontend/lib/stores/auth-store.ts b/frontend/lib/stores/auth-store.ts
index a7845ac..f146117 100644
--- a/frontend/lib/stores/auth-store.ts
+++ b/frontend/lib/stores/auth-store.ts
@@ -5,6 +5,7 @@ interface AuthState {
accessToken: string | null;
refreshToken: string | null;
tokenType: string | null;
+ orgId: string | null;
hasHydrated: boolean;
@@ -18,35 +19,60 @@ interface AuthState {
setHasHydrated: (state: boolean) => void;
}
+function parseJwtPayload(token: string): Record | null {
+ try {
+ const parts = token.split(".");
+ if (parts.length < 2) return null;
+ let base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
+ while (base64.length % 4 !== 0) {
+ base64 += "=";
+ }
+ const jsonPayload = decodeURIComponent(
+ atob(base64)
+ .split("")
+ .map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2))
+ .join("")
+ );
+ return JSON.parse(jsonPayload);
+ } catch {
+ return null;
+ }
+}
+
export const useAuthStore = create()(
persist(
(set) => ({
accessToken: null,
refreshToken: null,
tokenType: null,
+ orgId: null,
hasHydrated: false,
- setTokens: (accessToken, refreshToken, tokenType) =>
+ setTokens: (accessToken, refreshToken, tokenType) => {
+ const payload = parseJwtPayload(accessToken);
+
set({
accessToken,
refreshToken,
tokenType,
- }),
+ orgId: payload?.orgId ?? null,
+ });
+ },
clearTokens: () =>
set({
accessToken: null,
refreshToken: null,
tokenType: null,
+ orgId: null,
}),
setHasHydrated: (state) =>
set({
hasHydrated: state,
- })
-
+ }),
}),
-
+
{
name: "flowforge-auth",
diff --git a/frontend/lib/stores/workflow-store.ts b/frontend/lib/stores/workflow-store.ts
index bfd2a94..0ba2a47 100644
--- a/frontend/lib/stores/workflow-store.ts
+++ b/frontend/lib/stores/workflow-store.ts
@@ -1,4 +1,4 @@
-import {create} from "zustand"
+import { create } from "zustand"
import { Edge, Node, XYPosition } from "@xyflow/react";
import { initialNodes, initialEdges } from "../react-flow/initial-workflow";
import { createNode } from "../react-flow/node-factory";
@@ -11,6 +11,7 @@ interface ExportedWorkflow {
id: string;
name: string;
description: string;
+ status: string;
nodes: Node[];
edges: Edge[];
createdAt: string;
@@ -18,83 +19,110 @@ interface ExportedWorkflow {
}
type WorkflowStore = {
- nodes: Node[];
+ nodes: Node[];
+
+ edges: Edge[];
+
+ isDirty: boolean;
+
+ setIsDirty: (dirty: boolean) => void;
- edges: Edge[];
+ selectedNodeId: string | null;
- isDirty: boolean;
+ workflowId: string | null;
- setIsDirty: (dirty:boolean) => void;
+ setWorkflowId: (id: string | null) => void;
- selectedNodeId: string | null;
+ workflowStatus: string | null;
- workflowId: string | null;
+ setWorkflowStatus: (status: string | null) => void;
- setWorkflowId: (id:string | null) => void;
+ activeExecutionId: string | null;
- workflowName: string;
+ activeExecutionStatus: string | null;
- setWorkflowName: (
- name:string
- ) => void;
+ setActiveExecution: (id: string | null, status: string | null) => void;
- addNode: (type: WorkflowNodeType, position:XYPosition) => void;
+ workflowName: string;
- deleteNode: (id:string) => void;
+ setWorkflowName: (
+ name: string
+ ) => void;
- setNodes: (
- updater:
- | Node[]
- | ((nodes: Node[]) => Node[])
- ) => void;
+ addNode: (type: WorkflowNodeType, position: XYPosition) => void;
- setEdges: (
- updater:
- | Edge[]
- | ((edges: Edge[]) => Edge[])
- ) => void;
+ deleteNode: (id: string) => void;
- setSelectedNodeId: (
- id: string | null
- ) => void;
+ setNodes: (
+ updater:
+ | Node[]
+ | ((nodes: Node[]) => Node[])
+ ) => void;
- duplicateNode: (id:string) => void;
-
- exportWorkflow: ()=> ExportedWorkflow;
+ setEdges: (
+ updater:
+ | Edge[]
+ | ((edges: Edge[]) => Edge[])
+ ) => void;
- importWorkflow: (workflow:ExportedWorkflow) => void;
+ setSelectedNodeId: (
+ id: string | null
+ ) => void;
- resetWorkflow: ()=> void;
+ duplicateNode: (id: string) => void;
+
+ exportWorkflow: () => ExportedWorkflow;
+
+ importWorkflow: (workflow: ExportedWorkflow) => void;
+
+ resetWorkflow: () => void;
};
export const useWorkflowStore =
- create((set)=>({
+ create((set) => ({
- nodes:initialNodes,
- edges:initialEdges,
- selectedNodeId:null,
+ nodes: initialNodes,
+ edges: initialEdges,
+ selectedNodeId: null,
workflowId: null,
- setWorkflowId: (id)=>
+ setWorkflowId: (id) =>
set({
workflowId: id,
}),
+ workflowStatus: "DRAFT",
+
+ setWorkflowStatus: (status) =>
+ set({
+ workflowStatus: status,
+ }),
+
+ activeExecutionId: null,
+
+ activeExecutionStatus: null,
+
+ setActiveExecution: (id, status) =>
+ set({
+ activeExecutionId: id,
+ activeExecutionStatus: status,
+ }),
+
setNodes: (updater) =>
set((state) => ({
nodes:
- typeof updater === "function"
- ? updater(state.nodes)
- : updater,
+ typeof updater === "function"
+ ? updater(state.nodes)
+ : updater,
})),
setEdges: (updater) =>
set((state) => ({
edges:
- typeof updater === "function"
- ? updater(state.edges)
- : updater,
+ typeof updater === "function"
+ ? updater(state.edges)
+ : updater,
})),
setSelectedNodeId: (id) =>
@@ -105,7 +133,7 @@ export const useWorkflowStore =
addNode: (type, position) =>
set((state) => {
const newNode = createNode(type, position);
-
+
return {
nodes: [...state.nodes, newNode],
isDirty: true
@@ -117,89 +145,99 @@ export const useWorkflowStore =
nodes: state.nodes.filter(
(node) => node.id !== id
),
-
+
edges: state.edges.filter(
(edge) =>
edge.source !== id &&
edge.target !== id
),
-
+
selectedNodeId:
state.selectedNodeId === id
? null
: state.selectedNodeId,
})),
- workflowName: "Untitled Workflow",
-
- setWorkflowName: (name) =>
- set({
- workflowName: name,
- }),
-
- duplicateNode: (id) =>
- set((state) => {
- const node = state.nodes.find(
- (n) => n.id === id
- );
-
- if (!node) return state;
-
- const duplicatedNode = {
- ...node,
- id: crypto.randomUUID(),
- position: {
- x: node.position.x + 40,
- y: node.position.y + 40,
- },
- selected: false,
- };
-
- return {
- nodes: [...state.nodes, duplicatedNode],
- isDirty:true
- };
- }),
-
- exportWorkflow: ():ExportedWorkflow => {
- const state = useWorkflowStore.getState();
-
- const now = new Date().toISOString();
+ workflowName: "Untitled Workflow",
- return {
- id: state.workflowId ?? crypto.randomUUID(),
- name: state.workflowName,
- description: "",
+ setWorkflowName: (name) =>
+ set({
+ workflowName: name,
+ }),
- nodes: state.nodes,
- edges: state.edges,
+ duplicateNode: (id) =>
+ set((state) => {
+ const node = state.nodes.find(
+ (n) => n.id === id
+ );
+
+ if (!node) return state;
+
+ const duplicatedNode = {
+ ...node,
+ id: crypto.randomUUID(),
+ position: {
+ x: node.position.x + 40,
+ y: node.position.y + 40,
+ },
+ selected: false,
+ };
- createdAt: now,
- updatedAt: now,
+ return {
+ nodes: [...state.nodes, duplicatedNode],
+ isDirty: true
};
- },
+ }),
+
+
+ exportWorkflow: (): ExportedWorkflow => {
+ const state = useWorkflowStore.getState();
+
+ const now = new Date().toISOString();
+
+ return {
+ id: state.workflowId ?? crypto.randomUUID(),
+ name: state.workflowName,
+ description: "",
+ status: state.workflowStatus ?? "DRAFT",
+
+ nodes: state.nodes,
+ edges: state.edges,
+
+ createdAt: now,
+ updatedAt: now,
+ };
+ },
importWorkflow: (workflow) =>
set({
workflowId: workflow.id,
workflowName: workflow.name,
+ workflowStatus: workflow.status ?? "DRAFT",
+ activeExecutionId: null,
+ activeExecutionStatus: null,
nodes: workflow.nodes,
edges: workflow.edges,
selectedNodeId: null,
+ isDirty: false,
}),
resetWorkflow: () =>
set({
workflowId: null,
workflowName: "Untitled Workflow",
+ workflowStatus: "DRAFT",
+ activeExecutionId: null,
+ activeExecutionStatus: null,
nodes: initialNodes,
edges: initialEdges,
selectedNodeId: null,
+ isDirty: false,
}),
isDirty: false,
- setIsDirty: (dirty)=>
+ setIsDirty: (dirty) =>
set({
isDirty: dirty,
})
diff --git a/frontend/mocks/dashboard.ts b/frontend/mocks/dashboard.ts
index 9812146..e69de29 100644
--- a/frontend/mocks/dashboard.ts
+++ b/frontend/mocks/dashboard.ts
@@ -1,74 +0,0 @@
-import { DashboardData } from "@/types/dashboard";
-
-const minutesAgo = (minutes: number) =>
- new Date(Date.now() - minutes * 60 * 1000).toISOString();
-
-export const mockDashboardData: DashboardData = {
- stats: {
- totalWorkflows: 128,
- workflowGrowth: 5,
-
- runningExecutions: 14,
-
- completedExecutions: 2400,
- successRate: 99.2,
-
- failedExecutions24h: 12,
- failureChange: -2,
-
- pendingApprovals: 3,
- },
-
- runningExecutions: [
- {
- id: "exec-001",
- workflowName: "Order Processing",
- currentStep: "Database Sync",
- progress: 65,
- status: "RUNNING",
- startedAt: "2026-07-13T08:30:00Z",
- },
- {
- id: "exec-002",
- workflowName: "Data Sync Engine",
- currentStep: "Approval Flow",
- progress: 75,
- status: "WAITING",
- startedAt: "2026-07-13T08:10:00Z",
- },
- ],
-
- activities: [
- {
- id: "activity-001",
- type: "APPROVAL_REQUESTED",
- message:
- "Approval Requested: 'Update User Tier' workflow requires manual review.",
- createdAt: minutesAgo(2),
- },
- {
- id: "activity-002",
- type: "EXECUTION_COMPLETED",
- message:
- "Execution Completed: 'Github Webhook' finished successfully in 4.2s.",
- createdAt: minutesAgo(12),
- },
- {
- id: "activity-003",
- type: "EXECUTION_STARTED",
- message:
- "Execution Started: 'Nightly Backup' triggered by Cron Scheduler.",
- createdAt: minutesAgo(45),
- },
- ],
-
- systemHealth: {
- successRate: 99.2,
- failureRate: 0.8,
- retryCount: 45,
- activeInstances: 14,
- averageExecutionTime: 1.2,
- resourceUsage: "OPTIMAL",
- },
-};
-
diff --git a/frontend/mocks/execution-details.ts b/frontend/mocks/execution-details.ts
index 6c1a4aa..2e6c767 100644
--- a/frontend/mocks/execution-details.ts
+++ b/frontend/mocks/execution-details.ts
@@ -4,6 +4,8 @@ export const executionDetails: ExecutionDetails[] = [
{
id: "exec_883a_9921_f2",
+ workflowId: "mock_wf_1",
+
workflowName: "Onboarding Sync",
duration: "04:22:15",
diff --git a/frontend/services/workflow.service.ts b/frontend/services/workflow.service.ts
index 55c0a97..9194b5d 100644
--- a/frontend/services/workflow.service.ts
+++ b/frontend/services/workflow.service.ts
@@ -9,12 +9,11 @@ import {
getWorkflowById,
getWorkflowDefinition,
getWorkflows,
+ publishWorkflow,
updateWorkflow,
updateWorkflowDefinition,
} from "@/lib/api/workflow";
-
-
class WorkflowService {
async create(
workflow: {
@@ -32,7 +31,7 @@ class WorkflowService {
createdWorkflow.id,
definition
);
-
+
return createdWorkflow;
}
@@ -50,16 +49,18 @@ class WorkflowService {
workflow
);
- const definition = toWorkflowDefinition(
- nodes,
- edges
- );
+ if (updatedWorkflow.status !== "PUBLISHED") {
+ const definition = toWorkflowDefinition(
+ nodes,
+ edges
+ );
+
+ await updateWorkflowDefinition(
+ id,
+ definition
+ );
+ }
- await updateWorkflowDefinition(
- id,
- definition
- );
-
return updatedWorkflow;
}
@@ -75,14 +76,16 @@ class WorkflowService {
return await deleteWorkflow(id);
}
- async getDefinition(workflowId: string) {}
+ async getDefinition(workflowId: string) { }
async updateDefinition(
workflowId: string,
definition: WorkflowDefinition
- ) {}
+ ) { }
- async publish(workflowId: string) {}
+ async publish(workflowId: string) {
+ return await publishWorkflow(workflowId);
+ }
async loadWorkflow(id: string) {
const workflow = await getWorkflowById(id);
@@ -96,6 +99,7 @@ class WorkflowService {
id: workflow.id,
name: workflow.name,
description: workflow.description,
+ status: workflow.status,
nodes,
edges,
diff --git a/frontend/types/dashboard.ts b/frontend/types/dashboard.ts
index c5ba0ba..285a41d 100644
--- a/frontend/types/dashboard.ts
+++ b/frontend/types/dashboard.ts
@@ -1,6 +1,6 @@
export interface DashboardStats {
totalWorkflows: number;
- workflowGrowth: number;
+ createdThisWeek: number;
runningExecutions: number;
@@ -8,9 +8,9 @@ export interface DashboardStats {
successRate: number;
failedExecutions24h: number;
- failureChange: number;
+ failureChange?: number;
- pendingApprovals: number;
+ totalExecutions: number;
}
export type ExecutionStatus = "RUNNING" | "WAITING";
@@ -40,10 +40,8 @@ export interface Activity {
export interface SystemHealth {
successRate: number;
failureRate: number;
- retryCount: number;
activeInstances: number;
averageExecutionTime: number;
- resourceUsage: "OPTIMAL" | "MODERATE" | "HIGH";
}
export interface DashboardData {
@@ -51,4 +49,4 @@ export interface DashboardData {
runningExecutions: RunningExecution[];
activities: Activity[];
systemHealth: SystemHealth;
-}
\ No newline at end of file
+}
diff --git a/frontend/types/execution-details.ts b/frontend/types/execution-details.ts
index d1d934e..649558f 100644
--- a/frontend/types/execution-details.ts
+++ b/frontend/types/execution-details.ts
@@ -1,5 +1,6 @@
export type ExecutionNodeStatus =
| "IDLE"
+ | "PENDING"
| "RUNNING"
| "SUCCESS"
| "FAILED"
@@ -26,7 +27,8 @@ export interface ExecutionTimelineItem {
title: string;
description: string;
timestamp: string;
- status: "SUCCESS" | "WAITING" | "FAILED";
+ status: "SUCCESS" | "WAITING" | "FAILED" | "PENDING" | "RUNNING";
+ errorMessage?: string | null;
}
export interface ExecutionLog {
@@ -39,6 +41,8 @@ export interface ExecutionLog {
export interface ExecutionDetails {
id: string;
+ workflowId?: string;
+
workflowName: string;
duration: string;
diff --git a/frontend/types/execution.ts b/frontend/types/execution.ts
index 687550e..15c2555 100644
--- a/frontend/types/execution.ts
+++ b/frontend/types/execution.ts
@@ -1,53 +1,68 @@
-export type ExecutionStatus =
+export type BackendExecutionStatus =
+ | "PENDING"
| "RUNNING"
- | "FAILED"
| "COMPLETED"
- | "WAITING_APPROVAL"
- | "RETRYING";
+ | "FAILED";
+
+export type ExecutionStatus = BackendExecutionStatus | string;
+
+export interface StartExecutionResponse {
+ workflowId: string;
+ status: BackendExecutionStatus | string;
+}
+
+export interface GetExecutionResponse {
+ executionId: string;
+ workflowId: string;
+ status: BackendExecutionStatus | string;
+ startedAt: string;
+ completedAt: string | null;
+}
+
+export interface TaskExecutionResponse {
+ id?: string;
+ taskExecutionId?: string;
+ nodeId: string;
+ nodeType: string;
+ status: string;
+ startedAt: string;
+ completedAt: string | null;
+ errorMessage: string | null;
+}
export interface Execution {
id: string;
-
workflow: {
id: string;
name: string;
- category: string;
- source: string;
+ category?: string;
+ source?: string;
};
-
status: ExecutionStatus;
-
- progress: {
- completedNodes: number;
- totalNodes: number;
- };
-
startedAt: string;
-
+ completedAt?: string | null;
duration: number | null;
-
- currentNode: string | null;
-
- retryCount: number;
+ progress?: {
+ completedNodes: number;
+ totalNodes: number;
+ } | null;
+ currentNode?: string | null;
+ retryCount?: number;
}
export interface ExecutionMetrics {
activeThreads: number;
activeThreadsChange: number;
-
successRate: number;
-
averageLatency: number;
latencyChange: number;
-
systemHealth: "OPTIMAL" | "DEGRADED" | "CRITICAL";
}
export interface ExecutionsData {
executions: Execution[];
- metrics: ExecutionMetrics;
-
- pagination: {
+ metrics?: ExecutionMetrics;
+ pagination?: {
page: number;
pageSize: number;
totalItems: number;
diff --git a/frontend/types/workflow-definition.ts b/frontend/types/workflow-definition.ts
index 58bd6e8..676a3ea 100644
--- a/frontend/types/workflow-definition.ts
+++ b/frontend/types/workflow-definition.ts
@@ -7,11 +7,30 @@ export interface WorkflowNode {
configuration: Record;
}
+export interface WorkflowNodeResponse {
+ id: string;
+ nodeKey: string;
+ nodeType: string;
+ positionX: number;
+ positionY: number;
+ configuration: Record;
+}
+
+export interface WorkflowDefinitionResponse {
+ nodes: WorkflowNodeResponse[];
+ edges: WorkflowEdgeResponse[];
+}
+
export interface WorkflowEdge {
sourceClientId: string;
targetClientId: string;
}
+export interface WorkflowEdgeResponse {
+ sourceNodeId: string;
+ targetNodeId: string;
+}
+
export interface WorkflowDefinition {
nodes: WorkflowNode[];
edges: WorkflowEdge[];
@@ -19,5 +38,6 @@ export interface WorkflowDefinition {
export type WorkflowNodeType =
| "start"
+ | "end"
| "httpRequest"
| "condition";
\ No newline at end of file
diff --git a/frontend/types/workflow.ts b/frontend/types/workflow.ts
index 5cf63b3..ba9ab3b 100644
--- a/frontend/types/workflow.ts
+++ b/frontend/types/workflow.ts
@@ -1,3 +1,5 @@
+export type { WorkflowNodeType } from "./workflow-definition";
+
export interface Workflow {
id: string;
organizationId: string;
diff --git a/worker-service/src/main/java/com/flowforge/worker_service/adapter/out/http/HttpConfig.java b/worker-service/src/main/java/com/flowforge/worker_service/adapter/out/http/HttpConfig.java
index 7db7f0f..474f75a 100644
--- a/worker-service/src/main/java/com/flowforge/worker_service/adapter/out/http/HttpConfig.java
+++ b/worker-service/src/main/java/com/flowforge/worker_service/adapter/out/http/HttpConfig.java
@@ -1,10 +1,18 @@
package com.flowforge.worker_service.adapter.out.http;
+import com.fasterxml.jackson.annotation.JsonAlias;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
import java.util.Map;
-public record HttpConfig(String url,
- String method,
- Map headers,
- String body) {
+public record HttpConfig(
+ @JsonProperty("endpoint")
+ @JsonAlias("url")
+ String endpoint,
+ String method,
+ Map headers,
+ String body
+) {
}
+
diff --git a/worker-service/src/main/java/com/flowforge/worker_service/adapter/out/http/HttpWorker.java b/worker-service/src/main/java/com/flowforge/worker_service/adapter/out/http/HttpWorker.java
index 4adef63..3fe8cca 100644
--- a/worker-service/src/main/java/com/flowforge/worker_service/adapter/out/http/HttpWorker.java
+++ b/worker-service/src/main/java/com/flowforge/worker_service/adapter/out/http/HttpWorker.java
@@ -23,7 +23,7 @@ public class HttpWorker implements WorkerHandler {
@Override
public String getType() {
- return "HTTP";
+ return "HTTP_REQUEST";
}
@Override
@@ -51,7 +51,7 @@ private WorkerResult doExecute(WorkerTask task) {
HttpMethod method = HttpMethod.valueOf(config.method().toUpperCase());
ResponseEntity response = restTemplate.exchange(
- config.url(), method, requestEntity, String.class
+ config.endpoint(), method, requestEntity, String.class
);
if (response.getStatusCode().is2xxSuccessful()) {
diff --git a/worker-service/src/main/resources/application.yaml b/worker-service/src/main/resources/application.yaml
index b200752..76030d2 100644
--- a/worker-service/src/main/resources/application.yaml
+++ b/worker-service/src/main/resources/application.yaml
@@ -31,7 +31,7 @@ spring:
json:
add:
type:
- headers: false
+ headers: true
consumer:
group-id: worker-group
@@ -90,6 +90,12 @@ resilience4j:
max-concurrent-calls: 10
max-wait-duration: 500ms
+ timelimiter:
+ instances:
+ http:
+ timeoutDuration: 10s
+ cancelRunningFuture: true
+
management:
endpoints:
web:
diff --git a/workflow-service/src/main/resources/application.properties b/workflow-service/src/main/resources/application.properties
index 20e84d9..75e9365 100644
--- a/workflow-service/src/main/resources/application.properties
+++ b/workflow-service/src/main/resources/application.properties
@@ -51,7 +51,7 @@ spring.kafka.producer.retries=3
spring.kafka.producer.properties.enable.idempotence=true
spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer
spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer
-spring.kafka.producer.properties.spring.json.add.type.headers=false
+spring.kafka.producer.properties.spring.json.add.type.headers=true
# Consumer
spring.kafka.consumer.auto-offset-reset=earliest
@@ -60,8 +60,8 @@ spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.Str
spring.kafka.consumer.value-deserializer=org.springframework.kafka.support.serializer.JsonDeserializer
spring.kafka.consumer.properties.spring.json.trusted.packages=*
spring.kafka.consumer.properties.spring.deserializer.value.delegate.class=org.springframework.kafka.support.serializer.JsonDeserializer
-spring.kafka.consumer.properties.spring.json.use.type.headers=false
-spring.kafka.consumer.properties.spring.json.value.default.type=com.flowforge.workflowservice.application.execution.event.TaskFailedEvent
+spring.kafka.consumer.properties.spring.json.use.type.headers=true
+spring.kafka.consumer.properties.spring.json.type.mapping=com.flowforge.worker_service.adapter.out.kafka.TaskSucceededEvent:com.flowforge.workflowservice.application.execution.event.TaskSucceededEvent,com.flowforge.worker_service.adapter.out.kafka.TaskFailedEvent:com.flowforge.workflowservice.application.execution.event.TaskFailedEvent
# Listener
spring.kafka.listener.ack-mode=record