Blocksblock
Deployments
Filterable deployment list with a build-log sheet.
Preview
Open full screenInstallation
npx shadcn@latest add @particular/deploymentsnpm dependencies
- lucide-react@^1.51.0
Usage
"use client"
import { Deployments, type Deployment } from "@/components/deployments"
import { buildLogs, minutesAgo } from "@/registry/particular/examples/demo-data"
const deployments: Deployment[] = [
{ id: "dpl_3Kq9ZrT1vW8mN2xY", url: "acme-web.particular.app", environment: "production", status: "ready", branch: "main", commit: { sha: "3f2a91c7d0", message: "fix: cache headers for /r/*" }, author: "ada", createdAt: minutesAgo(4) },
{ id: "dpl_7Hd2LqP0sX4cV9bN", url: "acme-web-git-feat-billing.particular.app", environment: "preview", status: "building", branch: "feat/billing-v2", commit: { sha: "a81c0e44f1", message: "feat: yearly billing toggle" }, author: "grace", createdAt: minutesAgo(1) },
{ id: "dpl_1Zx8MnB5vC3qW7eR", url: "acme-web-git-fix-og.particular.app", environment: "preview", status: "error", branch: "fix/og-image", commit: { sha: "c90de2b318", message: "fix: og image font loading" }, author: "linus", createdAt: minutesAgo(48) },
{ id: "dpl_9Pk4JtY6uI2oL1aS", url: "acme-web.particular.app", environment: "production", status: "canceled", branch: "main", commit: { sha: "5be11f02a9", message: "chore: bump next to 16.3.8" }, author: "ada", createdAt: minutesAgo(190) },
{ id: "dpl_2Wq7ErT3yU8iO0pA", url: "acme-web-git-docs.particular.app", environment: "preview", status: "queued", branch: "docs/registry", commit: { sha: "e4f8a1c6b2", message: "docs: registry setup" }, author: "margaret", createdAt: minutesAgo(0.5) },
]
function wait(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms))
}
export function Demo() {
return (
<div className="mx-auto w-full max-w-5xl p-6">
<Deployments
deployments={deployments}
loadLogs={async (id) => {
await wait(600)
if (id === "dpl_1Zx8MnB5vC3qW7eR") throw new Error("Logs for this deployment have expired.")
return buildLogs
}}
/>
</div>
)
}Source
"use client"
import * as React from "react"
import { GitBranchIcon, GitCommitHorizontalIcon, RocketIcon, SearchIcon } from "lucide-react"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { CopyableId } from "@/components/ui/copyable-id"
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"
import { LogViewer, type LogLine } from "@/components/ui/log-viewer"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { RelativeTime } from "@/components/ui/relative-time"
import { ResourceList, ResourceRow } from "@/components/ui/resource-row"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import { StatusIndicator, type Status } from "@/components/ui/status-indicator"
export type Deployment = {
id: string
url: string
environment: "production" | "preview"
status: Extract<Status, "ready" | "building" | "queued" | "error" | "canceled">
branch: string
commit: { sha: string; message: string }
author: string
createdAt: Date | string
}
export type DeploymentsProps = {
deployments: Deployment[]
/** Fetch build logs when a deployment is opened. */
loadLogs: (deploymentId: string) => Promise<LogLine[]>
}
type LogsState = { status: "loading" } | { status: "error"; message: string } | { status: "ready"; lines: LogLine[] }
function Deployments({ deployments, loadLogs }: DeploymentsProps) {
const [query, setQuery] = React.useState("")
const [environment, setEnvironment] = React.useState<"all" | Deployment["environment"]>("all")
const [selected, setSelected] = React.useState<Deployment | null>(null)
const [logs, setLogs] = React.useState<LogsState>({ status: "loading" })
const request = React.useRef(0)
const open = async (deployment: Deployment) => {
const id = ++request.current
setSelected(deployment)
setLogs({ status: "loading" })
try {
const lines = await loadLogs(deployment.id)
if (request.current === id) setLogs({ status: "ready", lines })
} catch (caught) {
if (request.current === id) {
setLogs({ status: "error", message: caught instanceof Error ? caught.message : "Could not load logs." })
}
}
}
const needle = query.trim().toLowerCase()
const visible = deployments.filter(
(deployment) =>
(environment === "all" || deployment.environment === environment) &&
(needle === "" ||
[deployment.url, deployment.branch, deployment.commit.message, deployment.author].some((field) =>
field.toLowerCase().includes(needle)
))
)
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<InputGroup className="w-full max-w-72">
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput
aria-label="Search deployments"
placeholder="Branch, commit, author…"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</InputGroup>
<NativeSelect
aria-label="Environment"
value={environment}
onChange={(event) => {
const value = event.target.value
if (value === "all" || value === "production" || value === "preview") setEnvironment(value)
}}
className="w-40"
>
<NativeSelectOption value="all">All environments</NativeSelectOption>
<NativeSelectOption value="production">Production</NativeSelectOption>
<NativeSelectOption value="preview">Preview</NativeSelectOption>
</NativeSelect>
</div>
{visible.length === 0 ? (
<Empty className="border">
<EmptyHeader>
<EmptyMedia variant="icon">
<RocketIcon />
</EmptyMedia>
<EmptyTitle>{deployments.length === 0 ? "No deployments yet" : "No matching deployments"}</EmptyTitle>
<EmptyDescription>
{deployments.length === 0 ? "Push to a connected branch to create one." : "Try a different search or environment."}
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<ResourceList>
{visible.map((deployment) => (
<ResourceRow
key={deployment.id}
title={
<span className="inline-flex items-center gap-2">
{deployment.url}
{deployment.environment === "production" && <Badge variant="brand">Production</Badge>}
</span>
}
description={deployment.commit.message}
status={deployment.status}
meta={[
<>
<GitBranchIcon aria-hidden />
<span className="max-w-32 truncate">{deployment.branch}</span>
</>,
<>
<GitCommitHorizontalIcon aria-hidden />
<span className="font-mono">{deployment.commit.sha.slice(0, 7)}</span>
</>,
<RelativeTime key="time" date={deployment.createdAt} />,
]}
actions={
<Button variant="outline" size="sm" onClick={() => void open(deployment)}>
Logs
</Button>
}
/>
))}
</ResourceList>
)}
<Sheet open={selected !== null} onOpenChange={(next) => !next && setSelected(null)}>
<SheetContent className="gap-0 data-[side=right]:w-full data-[side=right]:sm:max-w-2xl">
{selected && (
<>
<SheetHeader className="border-b">
<SheetTitle className="flex items-center gap-2">
{selected.url}
<StatusIndicator status={selected.status} className="text-xs font-normal" />
</SheetTitle>
<SheetDescription className="flex flex-wrap items-center gap-2">
<CopyableId id={selected.id} />
<span>
{selected.author} on {selected.branch}
</span>
</SheetDescription>
</SheetHeader>
<div className="min-h-0 flex-1 p-4">
{logs.status === "loading" && (
<div className="flex flex-col gap-2" aria-busy>
{Array.from({ length: 8 }, (_, i) => (
<Skeleton key={i} className="h-4" style={{ width: `${55 + ((i * 17) % 40)}%` }} />
))}
</div>
)}
{logs.status === "error" && (
<Alert variant="destructive">
<AlertDescription>{logs.message}</AlertDescription>
</Alert>
)}
{logs.status === "ready" && <LogViewer lines={logs.lines} className="h-full" />}
</div>
</>
)}
</SheetContent>
</Sheet>
</div>
)
}
export { Deployments }