diff options
Diffstat (limited to 'makima/frontend/src/components/directives/StepDiagram.tsx')
| -rw-r--r-- | makima/frontend/src/components/directives/StepDiagram.tsx | 152 |
1 files changed, 152 insertions, 0 deletions
diff --git a/makima/frontend/src/components/directives/StepDiagram.tsx b/makima/frontend/src/components/directives/StepDiagram.tsx new file mode 100644 index 0000000..5c65ae1 --- /dev/null +++ b/makima/frontend/src/components/directives/StepDiagram.tsx @@ -0,0 +1,152 @@ +import { useNavigate } from "react-router"; +import type { ChainStep, ContractPhase } from "../../lib/api"; +import { PhaseProgressBarCompact } from "../contracts/PhaseProgressBar"; + +interface StepDiagramProps { + steps: ChainStep[]; +} + +const statusBorderColors: Record<string, string> = { + pending: "border-[#555]", + running: "border-yellow-400", + passed: "border-green-400", + failed: "border-red-400", +}; + +const statusDotColors: Record<string, string> = { + pending: "bg-[#555]", + running: "bg-yellow-400", + passed: "bg-green-400", + failed: "bg-red-400", +}; + +/** + * Assign depth to each step via topological sort. + * Steps with no dependsOn = depth 0. Steps depending only on depth-0 = depth 1. Etc. + */ +function assignDepths(steps: ChainStep[]): Map<string, number> { + const depths = new Map<string, number>(); + const stepMap = new Map(steps.map((s) => [s.id, s])); + + function getDepth(id: string): number { + if (depths.has(id)) return depths.get(id)!; + const step = stepMap.get(id); + if (!step || !step.dependsOn || step.dependsOn.length === 0) { + depths.set(id, 0); + return 0; + } + const maxParent = Math.max( + ...step.dependsOn.map((depId) => getDepth(depId)) + ); + const d = maxParent + 1; + depths.set(id, d); + return d; + } + + for (const step of steps) { + getDepth(step.id); + } + + return depths; +} + +export function StepDiagram({ steps }: StepDiagramProps) { + const navigate = useNavigate(); + + if (steps.length === 0) { + return ( + <p className="font-mono text-xs text-[#7788aa]">No steps to display.</p> + ); + } + + const depths = assignDepths(steps); + const maxDepth = Math.max(...Array.from(depths.values())); + + // Group steps by depth + const levels: ChainStep[][] = []; + for (let d = 0; d <= maxDepth; d++) { + levels.push( + steps + .filter((s) => depths.get(s.id) === d) + .sort((a, b) => a.orderIndex - b.orderIndex) + ); + } + + // Build position map for connectors + const stepPositions = new Map<string, { level: number; index: number }>(); + levels.forEach((level, li) => { + level.forEach((step, si) => { + stepPositions.set(step.id, { level: li, index: si }); + }); + }); + + return ( + <div className="space-y-3"> + {levels.map((level, li) => ( + <div key={li} className="flex items-start gap-2 flex-wrap"> + {li > 0 && ( + <div className="w-full flex justify-center mb-1"> + <div className="w-px h-3 bg-[rgba(117,170,252,0.3)]" /> + </div> + )} + {level.map((step) => { + const borderColor = + statusBorderColors[step.status] || "border-[#555]"; + const dotColor = statusDotColors[step.status] || "bg-[#555]"; + const summary = step.contractSummary; + const hasContract = !!step.contractId; + + return ( + <div + key={step.id} + className={` + border ${borderColor} bg-[rgba(0,0,0,0.2)] p-2 min-w-[180px] max-w-[220px] + ${hasContract ? "cursor-pointer hover:bg-[rgba(117,170,252,0.05)]" : ""} + transition-colors + `} + onClick={() => { + if (hasContract) navigate(`/contracts/${step.contractId}`); + }} + title={hasContract ? "View contract" : undefined} + > + <div className="flex items-center gap-1.5 mb-1"> + <div className={`w-1.5 h-1.5 rounded-full ${dotColor}`} /> + <span className="font-mono text-[11px] text-[#dbe7ff] truncate flex-1"> + {step.name} + </span> + {hasContract && ( + <span className="font-mono text-[9px] text-[#75aafc] shrink-0"> + → + </span> + )} + </div> + {summary && ( + <> + <div className="mb-1"> + <PhaseProgressBarCompact + currentPhase={summary.phase as ContractPhase} + /> + </div> + <div className="font-mono text-[9px] text-[#7788aa]"> + {summary.tasksDone}/{summary.taskCount} tasks + {summary.tasksRunning > 0 && ( + <span className="text-yellow-400 ml-1"> + {summary.tasksRunning} running + </span> + )} + {summary.tasksFailed > 0 && ( + <span className="text-red-400 ml-1"> + {summary.tasksFailed} failed + </span> + )} + </div> + </> + )} + </div> + ); + })} + </div> + ))} + </div> + ); +} |
