Particular.Tech
Blocksblock

Deployments

Filterable deployment list with a build-log sheet.

Preview

Open full screen

Installation

npx shadcn@latest add @particular/deployments

Usage

deployments-demo.tsx
"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

blocks/deployments/deployments.tsx
"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 }