diff --git a/dev_server.log b/dev_server.log index 9f3401e..52cfd00 100644 --- a/dev_server.log +++ b/dev_server.log @@ -1,6 +1,6 @@ $ vite --host 0.0.0.0 --port 3000 - VITE v5.4.21 ready in 312 ms + VITE v5.4.21 ready in 306 ms ➜ Local: http://localhost:3000/ ➜ Network: http://192.168.0.2:3000/ diff --git a/frontend/src/flow/RunComparisonModal.tsx b/frontend/src/flow/RunComparisonModal.tsx new file mode 100644 index 0000000..bd94a13 --- /dev/null +++ b/frontend/src/flow/RunComparisonModal.tsx @@ -0,0 +1,341 @@ +import { useMemo, useState } from "react"; +import type { RunRecord } from "./runHistory"; + +interface RunComparisonModalProps { + runs: RunRecord[]; + initialRunAId?: string; + initialRunBId?: string; + onClose: () => void; +} + +export function RunComparisonModal({ + runs, + initialRunAId, + initialRunBId, + onClose, +}: RunComparisonModalProps) { + const [runAId, setRunAId] = useState( + initialRunAId || (runs.length > 0 ? runs[0].id : "") + ); + const [runBId, setRunBId] = useState( + initialRunBId || (runs.length > 1 ? runs[1].id : runs[0]?.id || "") + ); + + const runA = useMemo(() => runs.find((r) => r.id === runAId) || null, [runs, runAId]); + const runB = useMemo(() => runs.find((r) => r.id === runBId) || null, [runs, runBId]); + + const [expandedStepIndex, setExpandedStepIndex] = useState(null); + + const durationDiff = useMemo(() => { + if (!runA || !runB) return null; + const diff = runB.durationMs - runA.durationMs; + const pct = runA.durationMs > 0 ? ((diff / runA.durationMs) * 100).toFixed(1) : "0"; + return { ms: diff, pct }; + }, [runA, runB]); + + const maxSteps = useMemo(() => { + const countA = runA?.logs.length || 0; + const countB = runB?.logs.length || 0; + return Math.max(countA, countB); + }, [runA, runB]); + + return ( +
+
+ {/* Header */} +
+
+
+ analytics & debugging +
+

+ Side-by-Side Run Comparison +

+
+ +
+ +
+ {runs.length === 0 ? ( +
+ No historical run records available to compare. +
+ ) : ( + <> + {/* Run Selectors */} +
+ {/* Run A Selection */} +
+
+ + Run A (Baseline) + + {runA && ( + + {runA.status === "error" ? "ERROR ✗" : "PASS ✓"} + + )} +
+ +
+ + {/* Run B Selection */} +
+
+ + Run B (Target) + + {runB && ( + + {runB.status === "error" ? "ERROR ✗" : "PASS ✓"} + + )} +
+ +
+
+ + {/* High-Level Comparison Summary */} + {runA && runB && ( +
+
+
+ Total Duration +
+
+ {runA.durationMs}ms vs {runB.durationMs}ms +
+ {durationDiff && ( +
+ {durationDiff.ms <= 0 + ? `Faster by ${Math.abs(durationDiff.ms)}ms (${Math.abs(Number(durationDiff.pct))}%)` + : `Slower by ${durationDiff.ms}ms (+${durationDiff.pct}%)`} +
+ )} +
+ +
+
+ Step Count +
+
+ {runA.stepCount} steps vs {runB.stepCount} steps +
+
+ Diff: {runB.stepCount - runA.stepCount} steps +
+
+ +
+
+ Status Match +
+
+ {runA.status === runB.status ? "IDENTICAL" : "DIVERGED"} +
+
+ {runA.status.toUpperCase()} ➔ {runB.status.toUpperCase()} +
+
+
+ )} + + {/* Side-by-Side Final Output Comparison */} + {runA && runB && ( +
+

+ Final Output Comparison +

+
+
+
+ Run A Final Output +
+
+                        {JSON.stringify(
+                          runA.finalState?.last_output ?? runA.logs[runA.logs.length - 1]?.output ?? null,
+                          null,
+                          2
+                        )}
+                      
+
+ +
+
+ Run B Final Output +
+
+                        {JSON.stringify(
+                          runB.finalState?.last_output ?? runB.logs[runB.logs.length - 1]?.output ?? null,
+                          null,
+                          2
+                        )}
+                      
+
+
+
+ )} + + {/* Step-by-Step Execution Timeline Table */} + {runA && runB && ( +
+

+ Per-Step Execution Timeline +

+
+ + + + + + + + + + + + {Array.from({ length: maxSteps }).map((_, idx) => { + const logA = runA.logs[idx]; + const logB = runB.logs[idx]; + const diff = (logB?.ms || 0) - (logA?.ms || 0); + const isExpanded = expandedStepIndex === idx; + + return ( + + + + + + + + ); + })} + +
StepRun A Node & TimeRun B Node & TimeLatency DiffAction
+ #{idx + 1} + + {logA ? ( +
+ {logA.name}{" "} + + ({logA.kind}) + +
{logA.ms}ms
+
+ ) : ( + - + )} +
+ {logB ? ( +
+ {logB.name}{" "} + + ({logB.kind}) + +
{logB.ms}ms
+
+ ) : ( + - + )} +
+ {logA && logB ? ( + + {diff > 0 ? `+${diff}ms` : `${diff}ms`} + + ) : ( + "-" + )} + + +
+
+ + {/* Expanded Step Output Details */} + {expandedStepIndex !== null && ( +
+
+ Step #{expandedStepIndex + 1} Output Inspection +
+
+
+
+ Run A Step Output +
+
+                            {JSON.stringify(runA.logs[expandedStepIndex]?.output ?? "N/A", null, 2)}
+                          
+
+
+
+ Run B Step Output +
+
+                            {JSON.stringify(runB.logs[expandedStepIndex]?.output ?? "N/A", null, 2)}
+                          
+
+
+
+ )} +
+ )} + + )} +
+
+
+ ); +} diff --git a/frontend/src/flow/WorkflowAnalyticsModal.tsx b/frontend/src/flow/WorkflowAnalyticsModal.tsx new file mode 100644 index 0000000..5c4ab69 --- /dev/null +++ b/frontend/src/flow/WorkflowAnalyticsModal.tsx @@ -0,0 +1,390 @@ +import { useMemo, useState } from "react"; +import type { RunLog } from "./runFlow"; +import type { RunRecord } from "./runHistory"; +import { toast } from "sonner"; + +interface WorkflowAnalyticsModalProps { + currentLogs: RunLog[] | null; + historicalRuns: RunRecord[]; + onClose: () => void; +} + +export function WorkflowAnalyticsModal({ + currentLogs, + historicalRuns, + onClose, +}: WorkflowAnalyticsModalProps) { + // Option 'current' or run ID + const [selectedRunSource, setSelectedRunSource] = useState( + currentLogs && currentLogs.length > 0 + ? "current" + : historicalRuns.length > 0 + ? historicalRuns[0].id + : "current" + ); + + const logsToAnalyze = useMemo(() => { + if (selectedRunSource === "current") { + return currentLogs || []; + } + const match = historicalRuns.find((r) => r.id === selectedRunSource); + return match ? match.logs : []; + }, [selectedRunSource, currentLogs, historicalRuns]); + + // Per-node performance stats + const totalMs = useMemo(() => { + return logsToAnalyze.reduce((acc, l) => acc + l.ms, 0); + }, [logsToAnalyze]); + + const avgMs = useMemo(() => { + if (logsToAnalyze.length === 0) return 0; + return Math.round(totalMs / logsToAnalyze.length); + }, [logsToAnalyze, totalMs]); + + // Token & Cost Estimations + const tokenMetrics = useMemo(() => { + let totalEstTokens = 0; + + logsToAnalyze.forEach((l) => { + // Estimate token count from output string / stateSnapshot or prompts + const strVal = typeof l.output === "string" ? l.output : JSON.stringify(l.output ?? ""); + const estTokens = Math.ceil(strVal.length / 4); + totalEstTokens += estTokens; + }); + + // Approximate cost estimate using average ~$0.003 / 1k tokens + const estCost = (totalEstTokens / 1000) * 0.003; + return { + totalEstTokens, + estCostFormatted: estCost < 0.0001 ? "<$0.0001" : `$${estCost.toFixed(4)}`, + }; + }, [logsToAnalyze]); + + // Slow Node Bottlenecks + const bottlenecks = useMemo(() => { + if (logsToAnalyze.length === 0 || totalMs === 0) return []; + return logsToAnalyze + .map((l) => ({ + ...l, + pct: Math.round((l.ms / totalMs) * 100), + })) + .filter((l) => l.pct >= 25 || l.ms >= 800) + .sort((a, b) => b.ms - a.ms); + }, [logsToAnalyze, totalMs]); + + // Table search & sort + const [searchFilter, setSearchFilter] = useState(""); + const [sortKey, setSortKey] = useState<"step" | "ms" | "name">("ms"); + const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); + + const tableLogs = useMemo(() => { + const q = searchFilter.trim().toLowerCase(); + let filtered = logsToAnalyze; + if (q) { + filtered = filtered.filter( + (l) => + l.name.toLowerCase().includes(q) || + l.kind.toLowerCase().includes(q) || + String(l.step).includes(q) + ); + } + return [...filtered].sort((a, b) => { + let valA: number | string = a[sortKey]; + let valB: number | string = b[sortKey]; + if (typeof valA === "string") valA = valA.toLowerCase(); + if (typeof valB === "string") valB = valB.toLowerCase(); + + if (valA < valB) return sortOrder === "asc" ? -1 : 1; + if (valA > valB) return sortOrder === "asc" ? 1 : -1; + return 0; + }); + }, [logsToAnalyze, searchFilter, sortKey, sortOrder]); + + const toggleSort = (key: "step" | "ms" | "name") => { + if (sortKey === key) { + setSortOrder((prev) => (prev === "asc" ? "desc" : "asc")); + } else { + setSortKey(key); + setSortOrder("desc"); + } + }; + + const copyReportToClipboard = () => { + if (logsToAnalyze.length === 0) { + toast.error("No log data to copy"); + return; + } + const report = `=== AGENT FLOW PERFORMANCE REPORT === +Run Source: ${selectedRunSource} +Total Steps: ${logsToAnalyze.length} +Total Duration: ${totalMs}ms +Avg Step Latency: ${avgMs}ms +Estimated Tokens: ${tokenMetrics.totalEstTokens} +Estimated Cost: ${tokenMetrics.estCostFormatted} + +NODE BREAKDOWN: +${logsToAnalyze + .map( + (l) => + `#${l.step} [${l.kind.toUpperCase()}] ${l.name} - ${l.ms}ms (${ + totalMs > 0 ? Math.round((l.ms / totalMs) * 100) : 0 + }%)` + ) + .join("\n")} +`; + navigator.clipboard.writeText(report).then(() => { + toast.success("Performance report copied to clipboard"); + }); + }; + + const exportCSV = () => { + if (logsToAnalyze.length === 0) { + toast.error("No log data to export"); + return; + } + const header = "Step,Node Name,Kind,Latency (ms),Percentage (%),Error\n"; + const rows = logsToAnalyze + .map( + (l) => + `${l.step},"${l.name.replace(/"/g, '""')}",${l.kind},${l.ms},${ + totalMs > 0 ? Math.round((l.ms / totalMs) * 100) : 0 + },"${l.error ? l.error.replace(/"/g, '""') : ""}"` + ) + .join("\n"); + + const blob = new Blob([header + rows], { type: "text/csv;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "workflow_performance_report.csv"; + a.click(); + URL.revokeObjectURL(url); + toast.success("CSV performance report exported"); + }; + + return ( +
+
+ {/* Header */} +
+
+
+ execution intelligence +
+

+ Workflow Performance Profiler & Analytics +

+
+ +
+ +
+ {/* Run Source Dropdown */} +
+ + Selected Run Log: + + +
+ + {logsToAnalyze.length === 0 ? ( +
+ No log data available for profiling. Execute a workflow to view analytics. +
+ ) : ( + <> + {/* Top KPI Cards */} +
+
+
+ Total Duration +
+
+ {totalMs} ms +
+
+ +
+
+ Total Steps +
+
+ {logsToAnalyze.length} +
+
+ +
+
+ Avg Step Latency +
+
+ {avgMs} ms +
+
+ +
+
+ Est. Tokens & Cost +
+
+ ~{tokenMetrics.totalEstTokens} tok ({tokenMetrics.estCostFormatted}) +
+
+
+ + {/* Bottleneck Alert Panel */} + {bottlenecks.length > 0 && ( +
+
+ + ⚠ Bottleneck Detected ({bottlenecks.length} Slow Node{bottlenecks.length > 1 ? "s" : ""}) + +
+
+ {bottlenecks.map((b) => ( +
+
+ Step #{b.step} ({b.name}): consumed{" "} + {b.ms}ms ({b.pct}% of total execution) +
+ + {b.kind === "llm" ? "Consider prompt tuning / temperature" : "Optimize node execution"} + +
+ ))} +
+
+ )} + + {/* Search, Export & Sort Bar */} +
+ setSearchFilter(e.target.value)} + placeholder="Filter by node name, kind, or step..." + className="flex-1 min-w-[200px] bg-[hsl(var(--paper))] border border-dashed border-[hsl(var(--ink))] p-1.5 outline-none font-mono text-[10px]" + /> + + +
+ + {/* Per-Node Performance Table */} +
+ + + + + + + + + + + + + {tableLogs.map((l) => { + const pct = totalMs > 0 ? Math.round((l.ms / totalMs) * 100) : 0; + return ( + + + + + + + + + ); + })} + +
toggleSort("step")} + className="p-2 border-r border-dashed border-[hsl(var(--grid-line))] cursor-pointer hover:bg-[hsl(var(--ink)/0.08)] select-none" + > + Step {sortKey === "step" ? (sortOrder === "asc" ? "▲" : "▼") : ""} + toggleSort("name")} + className="p-2 border-r border-dashed border-[hsl(var(--grid-line))] cursor-pointer hover:bg-[hsl(var(--ink)/0.08)] select-none" + > + Node Name {sortKey === "name" ? (sortOrder === "asc" ? "▲" : "▼") : ""} + + Kind + toggleSort("ms")} + className="p-2 border-r border-dashed border-[hsl(var(--grid-line))] cursor-pointer hover:bg-[hsl(var(--ink)/0.08)] select-none" + > + Latency {sortKey === "ms" ? (sortOrder === "asc" ? "▲" : "▼") : ""} + + % Total + Status
+ #{l.step} + + {l.name} + + {l.kind} + + {l.ms} ms + +
+
+
+
+ + {pct}% + +
+
+ {l.error ? ( + ERROR + ) : ( + OK + )} +
+
+ + )} +
+
+
+ ); +} diff --git a/frontend/src/flow/WorkspaceManager.tsx b/frontend/src/flow/WorkspaceManager.tsx new file mode 100644 index 0000000..6170ce7 --- /dev/null +++ b/frontend/src/flow/WorkspaceManager.tsx @@ -0,0 +1,284 @@ +import { useState, useRef } from "react"; +import type { Workflow } from "./workflows"; +import type { GlobalVar, SecretVar } from "./globals"; +import type { Gateway } from "./gateways"; +import { + exportWorkspaceBundle, + validateWorkspaceBundle, + importWorkspaceBundle, +} from "./workspace"; +import { toast } from "sonner"; + +interface WorkspaceManagerProps { + workflows: Workflow[]; + globals: GlobalVar[]; + secrets: SecretVar[]; + gateways: Gateway[]; + onWorkspaceRestored: (data: { + workflows: Workflow[]; + globals: GlobalVar[]; + secrets: SecretVar[]; + gateways: Gateway[]; + }) => void; + onClose: () => void; +} + +export function WorkspaceManager({ + workflows, + globals, + secrets, + gateways, + onWorkspaceRestored, + onClose, +}: WorkspaceManagerProps) { + const [activeTab, setActiveTab] = useState<"export" | "import">("export"); + const [importMode, setImportMode] = useState<"merge" | "replace">("merge"); + const [pastedJson, setPastedJson] = useState(""); + const fileInputRef = useRef(null); + + const handleDownloadBackup = () => { + const bundle = exportWorkspaceBundle(workflows, globals, secrets, gateways); + const data = JSON.stringify(bundle, null, 2); + const blob = new Blob([data], { type: "application/json;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `agent_flow_workspace_backup_${new Date().toISOString().slice(0, 10)}.json`; + a.click(); + URL.revokeObjectURL(url); + toast.success("Workspace backup downloaded"); + }; + + const handleCopyBackup = () => { + const bundle = exportWorkspaceBundle(workflows, globals, secrets, gateways); + const data = JSON.stringify(bundle, null, 2); + navigator.clipboard.writeText(data).then(() => { + toast.success("Workspace backup JSON copied to clipboard"); + }); + }; + + const processImportText = (text: string) => { + try { + const parsed = JSON.parse(text); + const validation = validateWorkspaceBundle(parsed); + if (!validation.valid) { + toast.error(`Invalid workspace bundle: ${validation.error}`); + return; + } + + if ( + importMode === "replace" && + !confirm("WARNING: Replace Mode will overwrite your current workspace components. Proceed?") + ) { + return; + } + + const restored = importWorkspaceBundle( + parsed, + importMode, + workflows, + globals, + secrets, + gateways + ); + + onWorkspaceRestored(restored); + toast.success( + `Workspace ${importMode === "merge" ? "merged" : "replaced"} successfully!` + ); + onClose(); + } catch (e) { + toast.error(`Import failed: ${e instanceof Error ? e.message : "Invalid JSON"}`); + } + }; + + const handleFileUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (event) => { + const text = event.target?.result; + if (typeof text === "string") { + processImportText(text); + } + e.target.value = ""; + }; + reader.readAsText(file); + }; + + return ( +
+
+ {/* Header */} +
+
+
+ portability & backup +
+

+ Workspace Backup & Restore Manager +

+
+ +
+ + {/* Tab Switcher */} +
+ + +
+ +
+ {activeTab === "export" ? ( +
+
+ + Current Workspace Overview: + +
+
+
Workflows
+
{workflows.length}
+
+
+
Globals
+
{globals.length}
+
+
+
Secrets
+
{secrets.length}
+
+
+
Gateways
+
{gateways.length}
+
+
+
+ +
+ + +
+
+ ) : ( +
+ {/* Import Mode Radio */} +
+ + Select Import Mode: + +
+ + + +
+
+ + {/* Upload File Zone */} +
+ + Option 1: Upload Workspace File + + + +
+ + {/* Paste Text Zone */} +
+ + Option 2: Paste Workspace JSON Text + +