summaryrefslogblamecommitdiff
path: root/makima/frontend/src/components/directives/StepDiagram.tsx
blob: 5c65ae14819330acabc8b24b9a49fc3c20d0bc40 (plain) (tree)























































































































































                                                                                            
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>
  );
}