Infrastructure & DevOps

Build Matrix

A CI build matrix grid of OS by runtime version, with pass, fail, running, and skipped cells and per-cell durations.

Install

npx shadcn@latest add @paragon/build-matrix

build-matrix.tsx

"use client";

import * as React from "react";
import { Check, Loader2, Minus, X } from "lucide-react";
import { cn } from "@/lib/utils";

export type CellStatus = "pass" | "fail" | "running" | "skipped";

export interface BuildMatrixProps extends React.ComponentProps<"div"> {
  /** Row headers, e.g. OS. */
  rows?: string[];
  /** Column headers, e.g. runtime versions. */
  columns?: string[];
  /** status[row][col]. */
  cells?: CellStatus[][];
  /** Duration label per cell (optional), same shape as cells. */
  durations?: (string | undefined)[][];
}

const DEFAULT_ROWS = ["ubuntu-24.04", "macos-14", "windows-2022"];
const DEFAULT_COLS = ["18", "20", "22"];
const DEFAULT_CELLS: CellStatus[][] = [
  ["pass", "pass", "pass"],
  ["pass", "pass", "running"],
  ["pass", "fail", "skipped"],
];
const DEFAULT_DURATIONS: (string | undefined)[][] = [
  ["1m 12s", "1m 08s", "1m 20s"],
  ["2m 04s", "1m 58s", undefined],
  ["2m 41s", "2m 55s", undefined],
];

const CELL: Record<
  CellStatus,
  { color: string; label: string }
> = {
  pass: { color: "var(--color-success)", label: "Passed" },
  fail: { color: "var(--color-destructive)", label: "Failed" },
  running: { color: "var(--color-warning)", label: "Running" },
  skipped: { color: "var(--color-muted-foreground)", label: "Skipped" },
};

function CellIcon({ status }: { status: CellStatus }) {
  const cls = "size-4";
  const color = CELL[status].color;
  if (status === "pass") return <Check className={cls} style={{ color }} />;
  if (status === "fail") return <X className={cls} style={{ color }} />;
  if (status === "running")
    return (
      <Loader2
        className={cn(cls, "animate-spin motion-reduce:animate-none")}
        style={{ color }}
      />
    );
  return <Minus className={cls} style={{ color }} />;
}

export function BuildMatrix({
  rows = DEFAULT_ROWS,
  columns = DEFAULT_COLS,
  cells = DEFAULT_CELLS,
  durations = DEFAULT_DURATIONS,
  className,
  ...props
}: BuildMatrixProps) {
  const gridCols = `minmax(120px,auto) repeat(${columns.length}, minmax(72px,1fr))`;
  return (
    <div
      className={cn(
        "w-full max-w-lg overflow-x-auto rounded-xl bg-card p-4 text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      <div className="mb-2 flex items-center justify-between">
        <span className="text-sm font-medium">CI build matrix</span>
        <span className="text-[11px] text-muted-foreground">node version</span>
      </div>
      <div style={{ minWidth: 360 }}>
        <div
          className="grid items-center gap-1.5"
          style={{ gridTemplateColumns: gridCols }}
        >
          <span />
          {columns.map((c) => (
            <span
              key={c}
              className="text-center font-mono text-xs font-medium text-muted-foreground"
            >
              {c}
            </span>
          ))}
          {rows.map((r, ri) => (
            <React.Fragment key={r}>
              <span className="truncate pr-2 font-mono text-xs font-medium">
                {r}
              </span>
              {columns.map((c, ci) => {
                const status = cells[ri]?.[ci] ?? "skipped";
                const dur = durations?.[ri]?.[ci];
                const meta = CELL[status];
                return (
                  <div
                    key={c}
                    title={`${r} · node ${c} — ${meta.label}${dur ? ` (${dur})` : ""}`}
                    className="flex aspect-[3/2] flex-col items-center justify-center gap-0.5 rounded-md"
                    style={{
                      background: `color-mix(in oklch, ${meta.color} 10%, var(--color-secondary))`,
                      boxShadow: `inset 0 0 0 1px color-mix(in oklch, ${meta.color} 30%, transparent)`,
                    }}
                  >
                    <CellIcon status={status} />
                    {dur && (
                      <span className="text-[9px] tabular-nums text-muted-foreground">
                        {dur}
                      </span>
                    )}
                  </div>
                );
              })}
            </React.Fragment>
          ))}
        </div>
      </div>
    </div>
  );
}