"use client"; import { CheckIcon, CopyIcon, ExternalLinkIcon, GitBranchIcon, GlobeIcon, type LucideIcon, PackageIcon, RotateCcwIcon, XIcon, } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { Fragment, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; export type StageStatus = "queued" | "running" | "success" | "failed" | "skipped"; export interface PipelineStage { id: string; name: string; status: StageStatus; icon?: LucideIcon; logs: string[]; startedAt?: number; durationMs?: number; error?: string; } export interface DeployPipelineProps { stages: PipelineStage[]; commit: { message: string; sha: string; branch: string; author?: string }; url?: string; onRetry?: (stageId: string) => void; className?: string; } const ease = [0.23, 1, 0.32, 1] as const; function useNow(active: boolean) { const [now, setNow] = useState(() => Date.now()); useEffect(() => { if (!active) return; setNow(Date.now()); const t = setInterval(() => setNow(Date.now()), 250); return () => clearInterval(t); }, [active]); return now; } const secs = (ms: number) => { const s = Math.max(0, Math.floor(ms / 1000)); return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`; }; function lineTone(line: string) { if (/^(✗|×|error|ERR|FAIL)/i.test(line.trim())) return "text-destructive-foreground"; if (/^(✓|✔|PASS|ready)/i.test(line.trim())) return "text-success-foreground"; if (/^(⚠|warn)/i.test(line.trim())) return "text-warning-foreground"; if (/^(>|\$)/.test(line.trim())) return "text-foreground"; return "text-muted-foreground"; } function Node({ stage, selected, onSelect, elapsed, indicator }: { stage: PipelineStage; selected: boolean; onSelect: () => void; elapsed: number; indicator: string }) { const reduce = useReducedMotion(); const Icon = stage.icon ?? PackageIcon; const s = stage.status; return ( {s === "running" && ( )} {s === "success" ? ( ) : s === "failed" ? ( ) : ( )} {stage.name} {s === "queued" ? "—" : s === "skipped" ? "skipped" : secs(elapsed)} {selected && ( )} ); } export function DeployPipeline({ stages, commit, url, onRetry, className }: DeployPipelineProps) { const reduce = useReducedMotion(); const indicator = useId(); const running = stages.find((s) => s.status === "running"); const failed = stages.find((s) => s.status === "failed"); const ready = stages.every((s) => s.status === "success" || s.status === "skipped"); const now = useNow(Boolean(running)); const [picked, setPicked] = useState(null); const [copied, setCopied] = useState(false); const logRef = useRef(null); const follow = failed?.id ?? running?.id ?? [...stages].reverse().find((s) => s.status !== "queued")?.id ?? stages[0].id; const selectedId = picked ?? follow; const selected = stages.find((s) => s.id === selectedId) ?? stages[0]; useEffect(() => { if (running && picked && picked !== running.id) { const pickedStage = stages.find((s) => s.id === picked); if (pickedStage?.status === "queued") setPicked(null); } }, [running, picked, stages]); useEffect(() => { if (!copied) return; const t = setTimeout(() => setCopied(false), 1400); return () => clearTimeout(t); }, [copied]); useLayoutEffect(() => { const el = logRef.current; if (el) el.scrollTop = el.scrollHeight; }, [selected.logs.length, selectedId]); const elapsedOf = (s: PipelineStage) => (s.status === "running" && s.startedAt ? now - s.startedAt : (s.durationMs ?? 0)); const total = stages.reduce((sum, s) => sum + elapsedOf(s), 0); const overall = failed ? "failed" : ready ? "ready" : running ? "building" : "queued"; return ( {commit.message} {commit.branch} · {commit.sha.slice(0, 7)} {commit.author && ( <> · {commit.author} > )} {overall === "ready" ? "Ready" : overall === "failed" ? "Failed" : overall === "building" ? "Building" : "Queued"} {secs(total)} {stages.map((stage, i) => { const next = stages[i + 1]; return ( setPicked(stage.id === follow ? null : stage.id)} selected={stage.id === selectedId} stage={stage} /> {next && ( )} ); })} {selected.name} {selected.logs.length} {selected.logs.length === 1 ? "line" : "lines"} {picked && ( setPicked(null)} type="button" > Follow live )} {selected.status === "queued" ? ( Waiting for earlier stages to finish. ) : ( selected.logs.map((line, i) => ( {line || " "} )) )} {selected.status === "running" && } {failed ? ( {failed.name} failed {failed.error ?? "See the logs above."} { setPicked(null); onRetry?.(failed.id); }} size="sm" > Retry {failed.name.toLowerCase()} ) : ready && url ? ( {url.replace(/^https?:\/\//, "")} { try { await navigator.clipboard.writeText(url); } catch {} setCopied(true); }} type="button" > {copied ? : } } size="sm" variant="outline"> Visit ) : url ? ( Preview URL appears once {stages[stages.length - 1]?.name ?? "deploy"} passes ) : null} ); }
{commit.message}
{commit.branch} · {commit.sha.slice(0, 7)} {commit.author && ( <> · {commit.author} > )}
Waiting for earlier stages to finish.
{failed.name} failed
{failed.error ?? "See the logs above."}