Skip to contentVibraUI
Feedback & status

Incident list

A feed of incidents with a severity badge, a status pill, when each started, and how long it ran.

No hooks of its own, so it renders on the server. Every severity ships a differently shaped icon beside its word, so it survives without colour. The duration is worked out from startedAt and resolvedAt alone, and the "started" stamp is a frozen RelativeTime — an incident row is a record of when something happened, not a clock — so nothing here differs between the server render and the browser.

Install

npx shadcn@latest add @vibra/incident-list

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
incidentsIncident[]—One row each, newest first; an empty array renders emptyMessage instead.
incidents[].idstring—React key and your own reference.
incidents[].titleReact.ReactNode—What broke, in the reader's terms.
incidents[].severity"critical" | "major" | "minor" | "info"—Sets the badge's tone and its icon.
incidents[].status"investigating" | "identified" | "monitoring" | "resolved"—Where the response has got to, shown as an outline pill.
incidents[].startedAtDate | string—When it began; read out as a relative time.
incidents[].resolvedAtDate | string—Set once it is over; the row then reads "Resolved in 1h 12m".
incidents[].affectedstring[]—The services it took with it, listed at the end of the meta row.
incidents[].hrefstring—Turns the row into a link to the incident's own write-up.
emptyMessageReact.ReactNodeNo incidents in this period.Shown in place of the rows when there is nothing to report.

Dependencies

Source

components/ui/incident-list.tsx
import * as React from "react"
import { CircleAlertIcon, InfoIcon, OctagonAlertIcon, TriangleAlertIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { formatDuration } from "@/lib/format"
import { Badge } from "@/components/ui/badge"
import { RelativeTime } from "@/components/ui/relative-time"

/** How much of the product an incident took with it. */
export type IncidentSeverity = "critical" | "major" | "minor" | "info"

/** Where an incident has got to in the response. */
export type IncidentStatus = "investigating" | "identified" | "monitoring" | "resolved"

const SEVERITY_LABELS: Record<IncidentSeverity, string> = {
  critical: "Critical",
  major: "Major",
  minor: "Minor",
  info: "Info",
}

// Four different outlines, so severity still reads at a glance in greyscale.
const SEVERITY_ICONS: Record<
  IncidentSeverity,
  React.ComponentType<React.ComponentProps<"svg">>
> = {
  critical: OctagonAlertIcon,
  major: TriangleAlertIcon,
  minor: CircleAlertIcon,
  info: InfoIcon,
}

// COUPLED TO registry/vibra/ui/badge.tsx: outline is the one variant with no
// background of its own, which is what lets a tone supply one. Each class here
// cancels one the variant sets — border-border, and text-foreground. If a
// future upstream refresh gives outline a background, add the reset here;
// incident-list.test.tsx asserts the tone classes land, so it will fail rather
// than quietly rendering a grey badge.
const SEVERITY_TONES: Record<IncidentSeverity, string> = {
  critical: "border-transparent bg-danger-muted text-danger",
  major: "border-transparent bg-warning-muted text-warning",
  minor: "border-transparent bg-info-muted text-info",
  info: "border-transparent bg-muted text-muted-foreground",
}

const STATUS_LABELS: Record<IncidentStatus, string> = {
  investigating: "Investigating",
  identified: "Identified",
  monitoring: "Monitoring",
  resolved: "Resolved",
}

export type Incident = {
  id: string
  title: React.ReactNode
  severity: IncidentSeverity
  status: IncidentStatus
  startedAt: Date | string
  /** Set once the incident is over; the row then reads how long it ran. */
  resolvedAt?: Date | string
  /** The services it took with it, listed under the title. */
  affected?: string[]
  /** Turns the row into a link to the incident's own write-up. */
  href?: string
}

export type IncidentListProps = React.ComponentProps<"div"> & {
  incidents: Incident[]
  emptyMessage?: React.ReactNode
}

function IncidentList({
  className,
  incidents,
  emptyMessage = "No incidents in this period.",
  ...props
}: IncidentListProps) {
  return (
    <div
      data-slot="incident-list"
      className={cn("flex w-full flex-col divide-y rounded-lg border", className)}
      {...props}
    >
      {incidents.length === 0 ? (
        <p
          data-slot="incident-list-empty"
          className="px-3 py-8 text-center text-sm text-muted-foreground"
        >
          {emptyMessage}
        </p>
      ) : (
        incidents.map((incident) => {
          const SeverityIcon = SEVERITY_ICONS[incident.severity]
          // Both moments come from props, so the duration is the same figure on
          // the server and in the browser — no clock is read during a render.
          const duration = incident.resolvedAt
            ? formatDuration(
                Math.max(
                  0,
                  new Date(incident.resolvedAt).getTime() - new Date(incident.startedAt).getTime()
                )
              )
            : null

          const rowClassName = cn(
            "flex flex-col gap-2 px-3 py-3",
            incident.href &&
              "transition-colors first:rounded-t-lg last:rounded-b-lg hover:bg-muted/50 focus-ring"
          )

          const row = (
            <>
              <span className="flex items-start gap-2">
                <Badge
                  data-slot="incident-list-severity"
                  variant="outline"
                  className={cn("shrink-0", SEVERITY_TONES[incident.severity])}
                >
                  <SeverityIcon aria-hidden="true" />
                  {SEVERITY_LABELS[incident.severity]}
                </Badge>
                <span
                  data-slot="incident-list-title"
                  className="min-w-0 flex-1 text-sm font-medium text-pretty"
                >
                  {incident.title}
                </span>
                <Badge
                  data-slot="incident-list-status"
                  variant="outline"
                  className="shrink-0 text-muted-foreground"
                >
                  {STATUS_LABELS[incident.status]}
                </Badge>
              </span>

              <span
                data-slot="incident-list-meta"
                className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground"
              >
                <span data-slot="incident-list-started">
                  {"Started "}
                  {/* Frozen: an incident row is a record of when something
                      happened, not a clock, and RelativeTime already renders the
                      same text on the server and on the client. */}
                  <RelativeTime date={incident.startedAt} updateInterval={null} />
                </span>
                {duration ? (
                  <span data-slot="incident-list-duration">{`Resolved in ${duration}`}</span>
                ) : null}
                {incident.affected?.length ? (
                  <span data-slot="incident-list-affected" className="min-w-0 truncate">
                    <span className="sr-only">{"Affected: "}</span>
                    {incident.affected.join(", ")}
                  </span>
                ) : null}
              </span>
            </>
          )

          // Two tags rather than a computed one, so the href stays typed as an
          // anchor's own prop.
          return incident.href ? (
            <a
              key={incident.id}
              data-slot="incident-list-item"
              data-severity={incident.severity}
              data-status={incident.status}
              href={incident.href}
              className={rowClassName}
            >
              {row}
            </a>
          ) : (
            <div
              key={incident.id}
              data-slot="incident-list-item"
              data-severity={incident.severity}
              data-status={incident.status}
              className={rowClassName}
            >
              {row}
            </div>
          )
        })
      )}
    </div>
  )
}

export { IncidentList }