{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pipeline-1",
  "title": "Pipeline 1: deploy pipeline",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "One run of a deploy pipeline: the commit, each stage as a step with its status written out and duration, logs one click away, an approval gate that says who can approve, and a failed stage that offers to run again from there.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/pipeline-1/pipeline-1.tsx",
      "content": "// Ballmac UI: Pipeline 1. https://ui.ballmac.com/blocks/pipeline-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { Check, ChevronRight, CircleDashed, CircleX, GitCommitHorizontal, Hand, Loader2, Minus, RotateCw, type LucideIcon } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Pipeline1StageStatus = \"passed\" | \"running\" | \"failed\" | \"waiting\" | \"pending\" | \"skipped\"\n\ntype Pipeline1Stage = {\n  id: string\n  /** For example \"Unit tests\". */\n  name: string\n  status: Pipeline1StageStatus\n  /** How long it took, already formatted, such as \"1 min 12 s\". */\n  duration?: string\n  /** Lines of output, shown when the stage is opened. */\n  log?: string[]\n  /** For a waiting stage: who needs to approve it. */\n  approver?: string\n}\n\ntype Pipeline1Run = {\n  /** The branch, for example \"main\". */\n  branch: string\n  /** A short commit id. */\n  commit: string\n  /** The commit message. */\n  message: string\n  author: string\n  stages: Pipeline1Stage[]\n}\n\ntype Pipeline1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    run?: Pipeline1Run | null\n    title?: string\n    /** The stage whose log is open at first. */\n    defaultOpenId?: string\n    /** Called when the approval gate is approved. Reject and the stage stays waiting with the message. */\n    onApprove?: (stageId: string) => void | Promise<void>\n    /** Called to run a failed stage and the ones after it again. */\n    onRerun?: (stageId: string) => void | Promise<void>\n  }\n\nconst STATUS: Record<Pipeline1StageStatus, { icon: LucideIcon; label: string; tone: string }> = {\n  passed: { icon: Check, label: \"Passed\", tone: \"bg-foreground text-background border-foreground\" },\n  running: { icon: Loader2, label: \"Running\", tone: \"border-foreground\" },\n  failed: { icon: CircleX, label: \"Failed\", tone: \"bg-destructive text-white border-destructive\" },\n  waiting: { icon: Hand, label: \"Waiting for approval\", tone: \"border-chart-3 border-dashed\" },\n  pending: { icon: CircleDashed, label: \"Not started\", tone: \"text-muted-foreground\" },\n  skipped: { icon: Minus, label: \"Skipped\", tone: \"text-muted-foreground\" },\n}\n\nconst sample: Pipeline1Run = {\n  branch: \"main\",\n  commit: \"a41f9c2\",\n  message: \"Retry the invoice export with backoff\",\n  author: \"Priya Shah\",\n  stages: [\n    { id: \"build\", name: \"Build\", status: \"passed\", duration: \"1 min 48 s\", log: [\"$ pnpm install --frozen-lockfile\", \"Lockfile is up to date, resolution step is skipped\", \"$ pnpm build\", \"✓ Compiled successfully in 41.2s\", \"✓ 214 pages generated\"] },\n    { id: \"test\", name: \"Tests\", status: \"passed\", duration: \"2 min 05 s\", log: [\"$ pnpm test\", \"Test Files  84 passed (84)\", \"Tests  612 passed (612)\"] },\n    { id: \"staging\", name: \"Deploy to staging\", status: \"passed\", duration: \"38 s\", log: [\"Uploading 1,204 files\", \"Deployed to https://staging.acme.example\", \"Health check passed (200)\"] },\n    { id: \"gate\", name: \"Approve production\", status: \"waiting\", approver: \"Priya Shah or Noor Haddad\" },\n    { id: \"prod\", name: \"Deploy to production\", status: \"pending\" },\n  ],\n}\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-4\">\n      <Skeleton className=\"h-20 rounded-xl\" />\n      <div className=\"grid gap-3 md:grid-cols-5\">\n        {[0, 1, 2, 3, 4].map((i) => (\n          <Skeleton key={i} className=\"h-24 rounded-xl\" />\n        ))}\n      </div>\n    </div>\n  )\n}\n\n/**\n * One run of a deploy pipeline: the commit, then each stage as a step with its status written out, how long it took and its log one click away. A stage that\n * needs a person shows who and an Approve button; a failed stage offers to run again from there. The log is code, so it stays left to right.\n */\nfunction Pipeline1({ run: runProp = sample, title = \"Deploy pipeline\", defaultOpenId, onApprove, onRerun, state = \"ready\", onRetry, emptyState, errorState, className, ...props }: Pipeline1Props) {\n  const id = React.useId()\n  const [run, setRun] = React.useState<Pipeline1Run | null>(runProp)\n  React.useEffect(() => setRun(runProp), [runProp])\n  const [open, setOpen] = React.useState<string | null>(defaultOpenId ?? null)\n  const [busy, setBusy] = React.useState<string | null>(null)\n  const [error, setError] = React.useState<{ id: string; text: string } | null>(null)\n  const view = state === \"ready\" && !run ? \"empty\" : state\n  const opened = run?.stages.find((s) => s.id === open)\n\n  async function act(stage: Pipeline1Stage, kind: \"approve\" | \"rerun\") {\n    setBusy(stage.id)\n    setError(null)\n    try {\n      await (kind === \"approve\" ? onApprove?.(stage.id) : onRerun?.(stage.id))\n      setRun((r) => {\n        if (!r) return r\n        const at = r.stages.findIndex((s) => s.id === stage.id)\n        return { ...r, stages: r.stages.map((s, i) => (i === at ? { ...s, status: kind === \"approve\" ? \"passed\" : \"running\" } : kind === \"rerun\" && i > at ? { ...s, status: \"pending\" } : kind === \"approve\" && i === at + 1 ? { ...s, status: \"running\" } : s)) }\n      })\n    } catch (e) {\n      setError({ id: stage.id, text: e instanceof Error ? e.message : `${stage.name} could not be ${kind === \"approve\" ? \"approved\" : \"run again\"}. It is as it was.` })\n    } finally {\n      setBusy(null)\n    }\n  }\n\n  return (\n    <section data-slot=\"pipeline-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-5xl px-4 py-10 sm:px-6\", className)} {...frameProps(props)}>\n      <header className=\"mb-8\">\n        <h1 id={`${id}-title`} className=\"text-3xl font-semibold tracking-[-0.035em] text-balance\">\n          {title}\n        </h1>\n        <p className=\"text-muted-foreground mt-2 max-w-xl text-pretty\">Every stage between a commit and production, with its log one click away.</p>\n      </header>\n      <StateView state={view} onRetry={onRetry} label=\"Loading the pipeline\" loading={<Skel />} empty={{ title: \"No runs yet\", description: \"Push a commit and its pipeline will appear here, stage by stage.\", ...emptyState }} error={{ title: \"The pipeline could not be loaded\", description: \"Nothing was deployed or changed. Check your connection and try again.\", ...errorState }}>\n        {run && (\n          <>\n            <div className=\"bg-card flex flex-wrap items-center gap-x-4 gap-y-2 rounded-xl border p-4\">\n              <GitCommitHorizontal aria-hidden=\"true\" className=\"text-muted-foreground size-5 shrink-0\" />\n              <div className=\"min-w-0 flex-1\">\n                <p className=\"truncate font-medium\">{run.message}</p>\n                <p className=\"text-muted-foreground text-xs\"><span className=\"font-mono\" dir=\"ltr\">{run.branch} · {run.commit}</span> · {run.author}</p>\n              </div>\n            </div>\n\n            <ol aria-label=\"Stages\" className=\"mt-4 grid gap-3 md:grid-cols-[repeat(var(--n),minmax(0,1fr))]\" style={{ \"--n\": run.stages.length } as React.CSSProperties}>\n              {run.stages.map((s, i) => {\n                const st = STATUS[s.status]\n                const Icon = st.icon\n                const on = open === s.id\n                return (\n                  <li key={s.id} data-status={s.status} className=\"relative flex flex-col\">\n                    {(() => {\n                      const inner = (\n                        <>\n                          <span className=\"flex items-center gap-2\">\n                            <span aria-hidden=\"true\" className={cn(\"flex size-6 shrink-0 items-center justify-center rounded-full border\", st.tone)}>\n                              <Icon className={cn(\"size-3.5\", s.status === \"running\" && \"motion-safe:animate-spin\")} style={s.status === \"running\" ? { animationPlayState: \"var(--bm-ambient-state, running)\" } : undefined} />\n                            </span>\n                            <span className=\"text-muted-foreground font-mono text-[11px] tabular-nums\">{i + 1}</span>\n                          </span>\n                          <span className=\"text-sm leading-snug font-medium\">{s.name}</span>\n                          <span className={cn(\"text-xs\", s.status === \"failed\" ? \"text-destructive font-medium\" : \"text-muted-foreground\")}>\n                            {st.label}\n                            {s.duration && <span className=\"tabular-nums\"> · {s.duration}</span>}\n                          </span>\n                        </>\n                      )\n                      const box = \"bg-card flex w-full flex-1 flex-col gap-2.5 rounded-xl border p-4 text-start\"\n                      // Only a stage with a log is a button; the others are plain boxes, so nothing is focusable that does nothing.\n                      return s.log?.length ? (\n                        <button type=\"button\" aria-expanded={on} aria-controls={`${id}-log`} onClick={() => setOpen(on ? null : s.id)} className={cn(box, \"hover:bg-accent/40 focus-visible:ring-ring/50 outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none\", on && \"border-foreground bg-accent/40\")}>\n                          {inner}\n                        </button>\n                      ) : (\n                        <div className={box}>{inner}</div>\n                      )\n                    })()}\n                    {s.status === \"waiting\" && (\n                      <div className=\"mt-2 grid gap-1.5\">\n                        {s.approver && <p className=\"text-muted-foreground text-xs\">Needs {s.approver}.</p>}\n                        <Button size=\"sm\" disabled={busy === s.id} onClick={() => void act(s, \"approve\")}>\n                          {busy === s.id && <Loader2 aria-hidden=\"true\" className=\"animate-spin\" />} Approve {s.name.replace(/^Approve /i, \"\").toLowerCase()}\n                        </Button>\n                      </div>\n                    )}\n                    {s.status === \"failed\" && (\n                      <Button size=\"sm\" variant=\"outline\" className=\"mt-2\" disabled={busy === s.id} onClick={() => void act(s, \"rerun\")}>\n                        <RotateCw aria-hidden=\"true\" /> Run again from here\n                      </Button>\n                    )}\n                    {error?.id === s.id && <p role=\"alert\" className=\"text-destructive mt-2 text-xs font-medium\">{error.text}</p>}\n                    {i < run.stages.length - 1 && <ChevronRight aria-hidden=\"true\" className=\"text-muted-foreground absolute top-9 -end-[1.15rem] hidden size-4 md:block rtl:rotate-180\" />}\n                  </li>\n                )\n              })}\n            </ol>\n\n            <div id={`${id}-log`} aria-live=\"polite\" className=\"mt-4\">\n              {opened?.log?.length ? (\n                <div className=\"overflow-hidden rounded-xl border\">\n                  <p className=\"bg-muted/50 text-muted-foreground border-b px-4 py-2 text-xs font-medium\">{opened.name}: log</p>\n                  <pre dir=\"ltr\" className=\"bg-card max-h-72 overflow-auto px-4 py-3 font-mono text-xs leading-relaxed\" tabIndex={0} aria-label={`${opened.name} log`}>\n                    {opened.log.join(\"\\n\")}\n                  </pre>\n                </div>\n              ) : (\n                <p className=\"text-muted-foreground text-sm\">Select a stage to read its log.</p>\n              )}\n            </div>\n          </>\n        )}\n      </StateView>\n    </section>\n  )\n}\n\nexport { Pipeline1, type Pipeline1Props, type Pipeline1Run, type Pipeline1Stage, type Pipeline1StageStatus }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/pipeline-1/pipeline-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "ci",
      "cd",
      "deploy",
      "pipeline",
      "release",
      "build",
      "approval",
      "devops"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "A deploy pipeline run. <Pipeline1 run={{ branch, commit, message, author, stages: [{ id, name, status: 'passed' | 'running' | 'failed' | 'waiting' | 'pending' | 'skipped', duration?, log?: string[], approver? }] }} defaultOpenId='build' onApprove={(stageId) => Promise} onRerun={(stageId) => Promise} />. A waiting stage shows its approver and an Approve button; a failed stage offers Run again from here. Only stages with a log are buttons.",
      "whenToUse": [
        "A release page showing where a deploy is and what is blocking it",
        "An approval step before production"
      ],
      "whenNotToUse": [
        "A list of many runs; use a table",
        "Server logs in real time; use log-stream"
      ],
      "composesWith": [
        "incident-1",
        "audit-1",
        "status-1",
        "dev-1"
      ],
      "a11y": [
        {
          "keys": "Stages",
          "action": "An ordered list named 'Stages'; each status is written ('Passed · 1 min 48 s', 'Waiting for approval'), never colour alone"
        },
        {
          "keys": "Stage with a log",
          "action": "A button with aria-expanded that controls the log region; a stage with no log is not focusable"
        },
        {
          "keys": "Log",
          "action": "A focusable, labelled, scrollable region that stays left to right"
        },
        {
          "keys": "Approve and run again",
          "action": "Real buttons; a failure is an alert beside the stage and the stage stays as it was"
        }
      ],
      "customization": [
        "run and defaultOpenId",
        "onApprove and onRerun; reject and the message says why",
        "The number of stages is not fixed"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "pipeline-1-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton for the commit and the stages under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No runs yet",
        "example": "pipeline-1-empty",
        "trigger": "state=\"empty\"",
        "note": "Says no runs yet; emptyState sets the copy."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "pipeline-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying nothing was deployed or changed, with Try again (onRetry). A failed approval keeps the gate waiting and says why."
      }
    ],
    "examples": [
      "pipeline-1-demo",
      "pipeline-1-failed",
      "pipeline-1-loading",
      "pipeline-1-empty",
      "pipeline-1-error"
    ],
    "url": "https://ui.ballmac.com/components/pipeline-1"
  },
  "categories": [
    "blocks",
    "pipeline"
  ],
  "type": "registry:block"
}