From 719deefcd83cb11eb3aece4966378a605b560d7b Mon Sep 17 00:00:00 2001 From: Jacobcdsmith <88069592+Jacobcdsmith@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:58:57 +0000 Subject: [PATCH] feat: add run history comparison, analytics profiler, and workspace backup/restore - Implement persistent run history recording and historical run viewing - Add side-by-side run comparison modal with state snapshot diffing - Implement workflow performance profiler with bottleneck detection and cost estimation - Add workspace backup/restore manager for bundle importing and exporting - Write unit test suites and visual Playwright verification tests --- frontend/src/flow/RunComparisonModal.tsx | 247 ++++++++++++ frontend/src/flow/WorkflowAnalyticsModal.tsx | 362 ++++++++++++++++++ frontend/src/flow/WorkspaceManager.tsx | 226 +++++++++++ frontend/src/flow/runHistory.ts | 107 ++++++ frontend/src/flow/workspace.ts | 128 +++++++ frontend/src/pages/Index.tsx | 211 +++++++++- frontend/src/test/analytics.test.ts | 62 +++ .../src/test/runHistoryAndComparison.test.ts | 98 +++++ frontend/src/test/workspaceManager.test.ts | 57 +++ 9 files changed, 1493 insertions(+), 5 deletions(-) create mode 100644 frontend/src/flow/RunComparisonModal.tsx create mode 100644 frontend/src/flow/WorkflowAnalyticsModal.tsx create mode 100644 frontend/src/flow/WorkspaceManager.tsx create mode 100644 frontend/src/flow/runHistory.ts create mode 100644 frontend/src/flow/workspace.ts create mode 100644 frontend/src/test/analytics.test.ts create mode 100644 frontend/src/test/runHistoryAndComparison.test.ts create mode 100644 frontend/src/test/workspaceManager.test.ts diff --git a/frontend/src/flow/RunComparisonModal.tsx b/frontend/src/flow/RunComparisonModal.tsx new file mode 100644 index 0000000..0094529 --- /dev/null +++ b/frontend/src/flow/RunComparisonModal.tsx @@ -0,0 +1,247 @@ +import React, { useState } from "react"; +import { RunRecord, diffStateSnapshots } from "./runHistory"; + +interface RunComparisonModalProps { + runs: RunRecord[]; + onClose: () => void; +} + +export const RunComparisonModal: React.FC = ({ + runs, + onClose, +}) => { + const [runAId, setRunAId] = useState(runs[0]?.id || ""); + const [runBId, setRunBId] = useState(runs[1]?.id || runs[0]?.id || ""); + const [selectedStepIndex, setSelectedStepIndex] = useState(0); + + const runA = runs.find((r) => r.id === runAId) || runs[0]; + const runB = runs.find((r) => r.id === runBId) || runs[1] || runs[0]; + + const durationDiff = runA && runB ? runB.totalMs - runA.totalMs : 0; + const stepCountDiff = runA && runB ? runB.stepCount - runA.stepCount : 0; + + const maxSteps = Math.max( + runA?.logs.length || 0, + runB?.logs.length || 0 + ); + + const logA = runA?.logs[selectedStepIndex]; + const logB = runB?.logs[selectedStepIndex]; + + const stateDiffs = diffStateSnapshots( + logA?.stateSnapshot, + logB?.stateSnapshot + ); + + return ( +
+
+ {/* Header */} +
+
+ ⚖️ +
+

+ Run Comparison & State Diff +

+
+ Compare execution runs side-by-side +
+
+
+ +
+ + {/* Content Body */} +
+ {runs.length < 2 ? ( +
+

Need at least 2 execution runs to compare.

+

+ Run your workflow multiple times to generate run history records for side-by-side comparison. +

+
+ ) : ( + <> + {/* Selectors & Metrics Bar */} +
+ {/* Run A Selector */} +
+ + + {runA && ( +
+ Duration: {runA.totalMs}ms + Steps: {runA.stepCount} + + {runA.status.toUpperCase()} + +
+ )} +
+ + {/* Run B Selector */} +
+ + + {runB && ( +
+ + Duration: {runB.totalMs}ms{" "} + + ({durationDiff > 0 ? `+${durationDiff}` : durationDiff}ms) + + + + Steps: {runB.stepCount} ({stepCountDiff > 0 ? `+${stepCountDiff}` : stepCountDiff}) + + + {runB.status.toUpperCase()} + +
+ )} +
+
+ + {/* Side-by-side Final Outputs */} +
+ + Final Outputs Comparison + +
+
+ Run A Final Output: +
{JSON.stringify(runA?.finalOutput, null, 2)}
+
+
+ Run B Final Output: +
{JSON.stringify(runB?.finalOutput, null, 2)}
+
+
+
+ + {/* Step-by-Step State Diff Inspector */} +
+
+ + Step-by-Step State Diff + +
+ Step: + +
+
+ + {/* Step Metadata Summary */} +
+
+
Run A Step #{selectedStepIndex + 1}
+
+ Node: {logA ? `${logA.name} (${logA.kind})` : "None / Ended"} · {logA ? `${logA.ms}ms` : "-"} +
+
+
+
Run B Step #{selectedStepIndex + 1}
+
+ Node: {logB ? `${logB.name} (${logB.kind})` : "None / Ended"} · {logB ? `${logB.ms}ms` : "-"} +
+
+
+ + {/* State Keys Comparison Table */} +
+ + State Variable Diffs: + + {stateDiffs.length === 0 ? ( +
+ No state variables present for this step. +
+ ) : ( +
+ + + + + + + + + + + {stateDiffs.map((diff) => ( + + + + + + + ))} + +
KeyRun A ValueRun B ValueStatus
{diff.key} + {JSON.stringify(diff.valA)} + + {JSON.stringify(diff.valB)} + + {diff.changed ? ( + DIFFERENT + ) : ( + identical + )} +
+
+ )} +
+
+ + )} +
+
+
+ ); +}; diff --git a/frontend/src/flow/WorkflowAnalyticsModal.tsx b/frontend/src/flow/WorkflowAnalyticsModal.tsx new file mode 100644 index 0000000..7ac219d --- /dev/null +++ b/frontend/src/flow/WorkflowAnalyticsModal.tsx @@ -0,0 +1,362 @@ +import React, { useState, useMemo } from "react"; +import type { RunLog } from "./runFlow"; +import { toast } from "sonner"; + +interface WorkflowAnalyticsModalProps { + logs: RunLog[]; + onClose: () => void; +} + +export interface NodeMetrics { + nodeId: string; + name: string; + kind: string; + count: number; + totalMs: number; + avgMs: number; + pctTotal: number; + estimatedTokens: number; + estimatedCostUsd: number; +} + +export function computeWorkflowAnalytics(logs: RunLog[]) { + const totalMs = logs.reduce((acc, l) => acc + l.ms, 0); + const totalSteps = logs.length; + const errorCount = logs.filter((l) => !!l.error).length; + + const nodeMap = new Map(); + + logs.forEach((log) => { + const existing = nodeMap.get(log.nodeId) || { + nodeId: log.nodeId, + name: log.name, + kind: log.kind, + count: 0, + totalMs: 0, + textChars: 0, + }; + + existing.count += 1; + existing.totalMs += log.ms; + + // Estimate character length for token calculations + if (log.output) { + const outStr = typeof log.output === "string" ? log.output : JSON.stringify(log.output); + existing.textChars += outStr.length; + } + + nodeMap.set(log.nodeId, existing); + }); + + let totalEstimatedTokens = 0; + let totalEstimatedCostUsd = 0; + + const nodeMetrics: NodeMetrics[] = Array.from(nodeMap.values()).map((nm) => { + const avgMs = Math.round(nm.totalMs / nm.count); + const pctTotal = totalMs > 0 ? Math.round((nm.totalMs / totalMs) * 100) : 0; + + // ~4 chars per token estimate + const estimatedTokens = Math.round(nm.textChars / 4); + // Rough estimate: $0.002 per 1k tokens for standard LLM inference + const estimatedCostUsd = (estimatedTokens / 1000) * 0.002; + + totalEstimatedTokens += estimatedTokens; + totalEstimatedCostUsd += estimatedCostUsd; + + return { + nodeId: nm.nodeId, + name: nm.name, + kind: nm.kind, + count: nm.count, + totalMs: nm.totalMs, + avgMs, + pctTotal, + estimatedTokens, + estimatedCostUsd, + }; + }); + + // Bottleneck detection: find node taking highest % of runtime + const sortedByTime = [...nodeMetrics].sort((a, b) => b.totalMs - a.totalMs); + const slowestNode = sortedByTime[0] || null; + + const recommendations: string[] = []; + if (slowestNode && slowestNode.pctTotal >= 40) { + recommendations.push( + `Node "${slowestNode.name}" (${slowestNode.kind}) accounts for ${slowestNode.pctTotal}% of total execution duration (${slowestNode.totalMs}ms). Consider optimizing model selection, prompt size, or request parallelism.` + ); + } + if (errorCount > 0) { + recommendations.push( + `Execution encountered ${errorCount} error(s). Review edge conditions and retry strategy for failing nodes.` + ); + } + if (totalSteps > 15) { + recommendations.push( + `Flow executed in ${totalSteps} steps. High step count may indicate circular loops or unoptimized routing.` + ); + } + if (recommendations.length === 0) { + recommendations.push("Workflow execution profile is balanced and optimal. No major bottlenecks detected."); + } + + return { + totalMs, + totalSteps, + errorCount, + totalEstimatedTokens, + totalEstimatedCostUsd, + nodeMetrics, + slowestNode, + recommendations, + }; +} + +export function generateAnalyticsCsv(logs: RunLog[]): string { + const { nodeMetrics, totalMs, totalSteps, errorCount, totalEstimatedTokens, totalEstimatedCostUsd } = computeWorkflowAnalytics(logs); + + const lines: string[] = []; + lines.push(`Workflow Performance & Profiling Report`); + lines.push(`Generated,${new Date().toISOString()}`); + lines.push(`Total Steps,${totalSteps}`); + lines.push(`Total Duration (ms),${totalMs}`); + lines.push(`Error Count,${errorCount}`); + lines.push(`Total Estimated Tokens,${totalEstimatedTokens}`); + lines.push(`Total Estimated Cost (USD),$${totalEstimatedCostUsd.toFixed(5)}`); + lines.push(``); + lines.push(`Node ID,Node Name,Node Kind,Execution Count,Total Duration (ms),Avg Duration (ms),% of Total,Estimated Tokens,Estimated Cost (USD)`); + + nodeMetrics.forEach((nm) => { + lines.push( + `"${nm.nodeId}","${nm.name.replace(/"/g, '""')}","${nm.kind}",${nm.count},${nm.totalMs},${nm.avgMs},${nm.pctTotal}%,${nm.estimatedTokens},$${nm.estimatedCostUsd.toFixed(5)}` + ); + }); + + return lines.join("\n"); +} + +export const WorkflowAnalyticsModal: React.FC = ({ + logs, + onClose, +}) => { + const [searchTerm, setSearchTerm] = useState(""); + const [sortField, setSortField] = useState("totalMs"); + const [sortAsc, setSortAsc] = useState(false); + + const analytics = useMemo(() => computeWorkflowAnalytics(logs), [logs]); + + const filteredMetrics = useMemo(() => { + let result = analytics.nodeMetrics.filter( + (m) => + m.name.toLowerCase().includes(searchTerm.toLowerCase()) || + m.kind.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + result.sort((a, b) => { + const valA = a[sortField]; + const valB = b[sortField]; + if (typeof valA === "number" && typeof valB === "number") { + return sortAsc ? valA - valB : valB - valA; + } + return sortAsc + ? String(valA).localeCompare(String(valB)) + : String(valB).localeCompare(String(valA)); + }); + + return result; + }, [analytics.nodeMetrics, searchTerm, sortField, sortAsc]); + + const handleSort = (field: keyof NodeMetrics) => { + if (sortField === field) { + setSortAsc(!sortAsc); + } else { + setSortField(field); + setSortAsc(false); + } + }; + + const handleCopySummary = () => { + const text = [ + `📊 Agent Flow Execution Analytics Summary`, + `Total Steps: ${analytics.totalSteps}`, + `Total Duration: ${analytics.totalMs} ms`, + `Errors: ${analytics.errorCount}`, + `Estimated Tokens: ~${analytics.totalEstimatedTokens}`, + `Estimated Cost: ~$${analytics.totalEstimatedCostUsd.toFixed(5)}`, + `Bottleneck: ${analytics.slowestNode ? `${analytics.slowestNode.name} (${analytics.slowestNode.pctTotal}%)` : "None"}`, + `Recommendations: ${analytics.recommendations.join(" ")}`, + ].join("\n"); + + navigator.clipboard.writeText(text); + toast.success("Analytics summary copied to clipboard"); + }; + + const handleExportCsv = () => { + const csv = generateAnalyticsCsv(logs); + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "workflow_analytics.csv"; + a.click(); + URL.revokeObjectURL(url); + toast.success("CSV analytics report downloaded"); + }; + + return ( +
+
+ {/* Header */} +
+
+ 📊 +
+

+ Workflow Profiler & Analytics +

+
+ Execution Performance & Latency Bottlenecks +
+
+
+
+ + + +
+
+ + {/* Content Body */} +
+ {logs.length === 0 ? ( +
+ No execution log data available. Execute your workflow to generate performance metrics. +
+ ) : ( + <> + {/* Key Overview Cards */} +
+
+
Total Duration
+
{analytics.totalMs} ms
+
{analytics.totalSteps} execution steps
+
+ +
+
Slowest Node
+
+ {analytics.slowestNode ? analytics.slowestNode.name : "N/A"} +
+
+ {analytics.slowestNode ? `${analytics.slowestNode.totalMs}ms (${analytics.slowestNode.pctTotal}%)` : "-"} +
+
+ +
+
Est. LLM Tokens
+
~{analytics.totalEstimatedTokens}
+
~4 chars / token
+
+ +
+
Est. Cost (USD)
+
${analytics.totalEstimatedCostUsd.toFixed(5)}
+
Standard model rates
+
+
+ + {/* Actionable Recommendations */} +
+ + 💡 Optimization Recommendations: + +
    + {analytics.recommendations.map((rec, i) => ( +
  • {rec}
  • + ))} +
+
+ + {/* Node Breakdown Table with Search & Sorting */} +
+
+ + Per-Node Performance Breakdown + + setSearchTerm(e.target.value)} + placeholder="Search nodes by name or kind..." + className="bg-[hsl(var(--paper))] border border-dashed border-[hsl(var(--ink))] px-2 py-1 text-[10px] w-56 outline-none focus:border-[hsl(var(--edge-selected))]" + /> +
+ +
+ + + + + + + + + + + + + {filteredMetrics.map((nm) => ( + + + + + + + + + ))} + +
handleSort("name")} className="p-2 cursor-pointer hover:underline"> + Name {sortField === "name" ? (sortAsc ? "▲" : "▼") : ""} + handleSort("kind")} className="p-2 cursor-pointer hover:underline"> + Kind {sortField === "kind" ? (sortAsc ? "▲" : "▼") : ""} + handleSort("count")} className="p-2 cursor-pointer hover:underline text-right"> + Steps {sortField === "count" ? (sortAsc ? "▲" : "▼") : ""} + handleSort("totalMs")} className="p-2 cursor-pointer hover:underline text-right"> + Total (ms) {sortField === "totalMs" ? (sortAsc ? "▲" : "▼") : ""} + handleSort("avgMs")} className="p-2 cursor-pointer hover:underline text-right"> + Avg (ms) {sortField === "avgMs" ? (sortAsc ? "▲" : "▼") : ""} + handleSort("pctTotal")} className="p-2 cursor-pointer hover:underline text-right"> + % Runtime {sortField === "pctTotal" ? (sortAsc ? "▲" : "▼") : ""} +
{nm.name}{nm.kind}{nm.count}{nm.totalMs}ms{nm.avgMs}ms + = 40 ? "text-[hsl(var(--issue))] font-bold" : ""}> + {nm.pctTotal}% + +
+
+
+ + )} +
+
+
+ ); +}; diff --git a/frontend/src/flow/WorkspaceManager.tsx b/frontend/src/flow/WorkspaceManager.tsx new file mode 100644 index 0000000..729521f --- /dev/null +++ b/frontend/src/flow/WorkspaceManager.tsx @@ -0,0 +1,226 @@ +import React, { useState, useRef } from "react"; +import { exportWorkspaceBundle, importWorkspaceBundle } from "./workspace"; +import { toast } from "sonner"; + +interface WorkspaceManagerProps { + onClose: () => void; + onWorkspaceRestored: () => void; +} + +export const WorkspaceManager: React.FC = ({ + onClose, + onWorkspaceRestored, +}) => { + const [maskKeys, setMaskKeys] = useState(false); + const [importMode, setImportMode] = useState<"merge" | "replace">("merge"); + const [importJsonText, setImportJsonText] = useState(""); + const fileInputRef = useRef(null); + + const handleDownloadBackup = () => { + const bundle = exportWorkspaceBundle(maskKeys); + const text = JSON.stringify(bundle, null, 2); + const blob = new Blob([text], { type: "application/json;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "agent_flow.workspace.v1.json"; + a.click(); + URL.revokeObjectURL(url); + toast.success("Workspace backup bundle downloaded"); + }; + + const handleCopyBackup = () => { + const bundle = exportWorkspaceBundle(maskKeys); + navigator.clipboard.writeText(JSON.stringify(bundle, null, 2)); + toast.success("Workspace backup copied to clipboard"); + }; + + const handleImportText = () => { + if (!importJsonText.trim()) { + toast.error("Paste JSON content to import"); + return; + } + + try { + const parsed = JSON.parse(importJsonText); + const res = importWorkspaceBundle(parsed, importMode); + if (res.success) { + toast.success(res.message); + onWorkspaceRestored(); + onClose(); + } else { + toast.error(`Import failed: ${res.message}`); + } + } catch (e) { + toast.error("Invalid workspace JSON syntax"); + } + }; + + const handleFileUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (event) => { + try { + const text = event.target?.result; + if (typeof text !== "string") return; + const parsed = JSON.parse(text); + const res = importWorkspaceBundle(parsed, importMode); + if (res.success) { + toast.success(res.message); + onWorkspaceRestored(); + onClose(); + } else { + toast.error(`Import failed: ${res.message}`); + } + } catch (e) { + toast.error("Invalid workspace file JSON"); + } + e.target.value = ""; + }; + reader.readAsText(file); + }; + + return ( +
+
+ {/* Header */} +
+
+ 💾 +
+

+ Workspace Backup & Restore Manager +

+
+ Full Portability for Workflows, Gateways & Globals +
+
+
+ +
+ + {/* Content */} +
+ {/* Export Section */} +
+ + 1. Export Workspace Backup Bundle + +

+ Exports all custom workflows, active canvas layout, global variables, secret definitions, and gateway profiles into a single portable bundle file (agent_flow.workspace.v1.json). +

+ +
+ +
+ +
+ + +
+
+ + {/* Import Section */} +
+ + 2. Restore / Import Workspace Bundle + + + {/* Mode selection */} +
+ Import Mode: + + +
+ + {/* File Upload Option */} +
+ + +
+ + {/* Textarea Paste Option */} +
+ + Or paste JSON content directly: + +