Infrastructure & DevOps

Container Registry

A container image tag list with digest, size, pushed-at, a copy-pull action, and severity-colored vulnerability badges.

Install

npx shadcn@latest add @paragon/container-registry

container-registry.tsx

"use client";

import * as React from "react";
import { Copy, Check, ShieldAlert, ShieldCheck, Tag } from "lucide-react";
import { cn } from "@/lib/utils";

export interface ContainerImage {
  tag: string;
  digest: string;
  /** Compressed size label, e.g. "84.2 MB". */
  size: string;
  pushedAt: string;
  /** Vulnerability counts by severity. */
  vulns?: { critical: number; high: number; medium: number };
  latest?: boolean;
}

export interface ContainerRegistryProps extends React.ComponentProps<"div"> {
  repository?: string;
  images?: ContainerImage[];
}

const DEFAULT_IMAGES: ContainerImage[] = [
  {
    tag: "v2.14.0",
    digest: "sha256:9c1f2a",
    size: "84.2 MB",
    pushedAt: "2h ago",
    vulns: { critical: 0, high: 0, medium: 2 },
    latest: true,
  },
  {
    tag: "v2.13.4",
    digest: "sha256:3ba77d",
    size: "84.0 MB",
    pushedAt: "1d ago",
    vulns: { critical: 0, high: 1, medium: 3 },
  },
  {
    tag: "v2.13.3",
    digest: "sha256:f0e912",
    size: "83.8 MB",
    pushedAt: "3d ago",
    vulns: { critical: 2, high: 4, medium: 6 },
  },
  {
    tag: "nightly",
    digest: "sha256:aa41c8",
    size: "91.6 MB",
    pushedAt: "6h ago",
    vulns: { critical: 0, high: 0, medium: 0 },
  },
];

function VulnBadge({ vulns }: { vulns: ContainerImage["vulns"] }) {
  if (!vulns) return null;
  const { critical, high, medium } = vulns;
  const clean = critical + high + medium === 0;
  if (clean) {
    return (
      <span className="inline-flex items-center gap-1 rounded-md bg-secondary px-1.5 py-0.5 text-[11px] font-medium text-success">
        <ShieldCheck className="size-3" />
        Clean
      </span>
    );
  }
  const worst =
    critical > 0
      ? "var(--color-destructive)"
      : high > 0
        ? "var(--color-warning)"
        : "var(--color-muted-foreground)";
  const label =
    critical > 0 ? `${critical} critical` : high > 0 ? `${high} high` : `${medium} med`;
  return (
    <span
      className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-medium"
      style={{
        color: worst,
        background: `color-mix(in oklch, ${worst} 12%, transparent)`,
      }}
    >
      <ShieldAlert className="size-3" />
      <span className="tabular-nums">{label}</span>
    </span>
  );
}

export function ContainerRegistry({
  repository = "acme/web-app",
  images = DEFAULT_IMAGES,
  className,
  ...props
}: ContainerRegistryProps) {
  const [copied, setCopied] = React.useState<string | null>(null);
  const timeout = React.useRef<ReturnType<typeof setTimeout>>(null);
  React.useEffect(
    () => () => {
      if (timeout.current) clearTimeout(timeout.current);
    },
    [],
  );
  const copy = (tag: string) => {
    navigator.clipboard?.writeText(`${repository}:${tag}`);
    setCopied(tag);
    if (timeout.current) clearTimeout(timeout.current);
    timeout.current = setTimeout(() => setCopied(null), 1400);
  };

  return (
    <div
      className={cn(
        "w-full max-w-2xl overflow-hidden rounded-xl bg-card text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      <div className="flex items-center gap-2 border-b border-border px-3 py-2.5">
        <Tag className="size-4 text-muted-foreground" />
        <span className="font-mono text-sm font-medium">{repository}</span>
        <span className="ml-auto text-xs tabular-nums text-muted-foreground">
          {images.length} tags
        </span>
      </div>
      <ul>
        {images.map((img) => (
          <li
            key={img.tag}
            className="group flex items-center gap-3 border-b border-border px-3 py-2.5 last:border-b-0"
          >
            <div className="min-w-0 flex-1">
              <div className="flex items-center gap-1.5">
                <span className="truncate font-mono text-sm font-medium">
                  {img.tag}
                </span>
                {img.latest && (
                  <span className="rounded bg-primary px-1 py-px text-[9px] font-semibold uppercase text-primary-foreground">
                    latest
                  </span>
                )}
                <button
                  type="button"
                  aria-label={`Copy ${repository}:${img.tag}`}
                  onClick={() => copy(img.tag)}
                  className="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-0 outline-none transition-[opacity,background-color,color] duration-150 hover:bg-secondary hover:text-foreground focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring group-hover:opacity-100"
                >
                  {copied === img.tag ? (
                    <Check className="size-3 text-success" />
                  ) : (
                    <Copy className="size-3" />
                  )}
                </button>
              </div>
              <span className="font-mono text-[11px] text-muted-foreground">
                {img.digest}
              </span>
            </div>
            <VulnBadge vulns={img.vulns} />
            <span className="w-16 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
              {img.size}
            </span>
            <span className="w-14 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
              {img.pushedAt}
            </span>
          </li>
        ))}
      </ul>
    </div>
  );
}