summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/directives/StepDiagram.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/components/directives/StepDiagram.tsx')
-rw-r--r--makima/frontend/src/components/directives/StepDiagram.tsx152
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">
+ &rarr;
+ </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>
+ );
+}