A container image tag list with digest, size, pushed-at, a copy-pull action, and severity-colored vulnerability badges.
npx shadcn@latest add @paragon/container-registry"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>
);
}