Skip to content
KONIGI Patterns

Dashboards / Charts and data displays / Status history

‹ 19 of 22 ›

Status history

People need to see when a state changed and how long it lasted.

Updated September 10, 2026

Problem

People need to see when a state changed and how long it lasted across a period too long for a detailed value chart.

Solution

Show each item on a row with time running horizontally. Represent its states as labeled or clearly keyed regions.

Use continuous regions when the data supports a state lasting between observations. Use separate marks when individual checks are the evidence. Keep unknown periods distinct from healthy ones.

Make brief changes discoverable through zoom or detail even when they are too short to see at the current scale. Show the rule that converts measurements into states when one is used.

Use when

The data has a small set of meaningful states and people need to review their duration or timing.

Don’t use when

The underlying value is continuous and the magnitude matters. Reducing latency to green/amber/red loses the difference between 210ms and 900ms, and both are amber.

Trade-offs

Short events can disappear at wide time ranges. Merging repeated states can imply more continuous observation than actually occurred. Many rows need useful ordering or grouping to remain readable.

Checklist

  • What turns the underlying data into a state, and where does that rule live?
  • How many distinct states, and is that few enough to read as colour alone?
  • What does a gap mean: healthy, unknown, or not-yet-checked?
  • Are consecutive equal values merged, and does that hide the sampling interval?
  • At the widest range shown, how short an outage becomes invisible?
  • Is there a hover giving exact start, end and duration?
  • Is the row order meaningful, and is it stable between visits?
  • Would a colourblind viewer distinguish degraded from down?
  • Can the viewer get from a red region to what caused it?
  • Does the strip agree with the incident record, and if not, which is wrong?

Compare

Grafana ships two panels for this and the choice between them is the design decision: state timeline for continuous regions with merging, status history for one mark per discrete sample. Public status pages in the Statuspage mould reduce it to the minimum honest form—one bar per component per day, ninety days across—which is the most-read version of this pattern anywhere and the one most tuned for readers with no context. Datadog ties the strip to monitor state rather than to raw metrics, so the history you read is the history of what actually paged someone. Netdata keeps alarm transitions per node in a log-shaped view, which trades the instant scan for exact times and reasons.

Threshold line can show the rule behind a state. Semantic status color defines consistent colors. Error and stale state handles unknown periods.

Time series preserves numeric detail. Annotation links events to a state change.

Status history anatomy Eight services, thirty days, one row each, colour for state. With equal consecutive samples merged, a month of health is one long bar and the two-day outage is an unmissable notch. With merging off, every sample is drawn, which matters when the sampling interval is itself the story. Thirty days in a strip checkout search cart user auth media billing jobs Merged checkout search cart user auth media billing jobs Every sample 1 2 1 THE THRESHOLDING IS DONE A time series asks the viewer to read a value and threshold it in their head. This has already done it and shows the conclusion, which is what makes a month fit in a strip. 2 MERGE, OR DON'T Merged, a month of "up" is one bar and the notches are unmissable. Unmerged, you see every sample, which is what you want when the checks run on a schedule and the gaps between them are real. Value mappings turn raw numbers into named states, so a CPU series becomes a health strip without a second query.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

Ninety days of state as one row of cells. The value is the shape over time, which is why every cell is the same size and no incident gets a bigger box than another.

shadcn
npx shadcn@latest add switch
Tokens
--card--muted-foreground--border--status-nominal--status-warn--status-critical--status-unknown
checkout
search
cart
user
auth
media
billing
jobs

StatusHistory.tsxSamples arrive already thresholded to the closed status set. Merge joins equal runs into regions; every region carries its exact start, end and sample count.

import { cn } from "@/lib/utils";
import { STATUS_META, type Status } from "../semantic-status-color/status";

/**
 * Thirty days in a strip. One row per thing, time left to right, colour for
 * state. The thresholding happened upstream: a sample arrives as one of the
 * closed status set, and this draws the conclusion rather than a value.
 *
 * `merge` joins equal consecutive samples into one region, so a month of
 * nominal is one bar and the outage is a notch. Off, every sample is its
 * own cell, which is what you want when the checks run on a schedule and
 * the gaps between them are real. Either way every cell is the same width;
 * no incident gets a bigger box than another.
 */
export type Row = { key: string; samples: Status[] };

export type Region = { row: string; status: Status; from: Date; to: Date; samples: number };

type Props = {
  rows: Row[];
  /** When the first sample was taken, and how far apart they are. */
  start: Date;
  intervalMs: number;
  merge: boolean;
  onSelect?: (region: Region) => void;
};

const FILL: Record<Status, string> = {
  nominal: "bg-status-nominal",
  warn: "bg-status-warn",
  critical: "bg-status-critical",
  unknown: "bg-status-unknown",
};

const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const day = (d: Date) => `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`;

/** Runs of equal status, or one run per sample when not merging. */
function regions(row: Row, start: Date, intervalMs: number, merge: boolean): Region[] {
  const out: Region[] = [];
  row.samples.forEach((status, i) => {
    const last = out[out.length - 1];
    if (merge && last && last.status === status) {
      last.samples++;
      last.to = new Date(start.getTime() + (i + 1) * intervalMs);
    } else {
      out.push({ row: row.key, status, samples: 1, from: new Date(start.getTime() + i * intervalMs), to: new Date(start.getTime() + (i + 1) * intervalMs) });
    }
  });
  return out;
}

export function StatusHistory({ rows, start, intervalMs, merge, onSelect }: Props) {
  const n = rows[0]?.samples.length ?? 0;
  const end = new Date(start.getTime() + n * intervalMs);

  return (
    <div className="flex flex-col gap-1.5">
      {rows.map((row) => (
        <div key={row.key} className="flex items-center gap-2">
          <span className="w-14 shrink-0 text-right text-[10px] text-muted-foreground">{row.key}</span>
          <div
            role="group"
            aria-label={`${row.key}, ${day(start)} to ${day(end)}`}
            className="grid h-3.5 flex-1 gap-px overflow-hidden rounded-[2px]"
            style={{ gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))` }}
          >
            {regions(row, start, intervalMs, merge).map((r) => {
              const label = `${STATUS_META[r.status].label}, ${day(r.from)} to ${day(r.to)} (${r.samples} ${r.samples === 1 ? "sample" : "samples"})`;
              return (
                <button
                  key={r.from.getTime()}
                  type="button"
                  title={label}
                  aria-label={label}
                  onClick={() => onSelect?.(r)}
                  className={cn("block h-full w-full", FILL[r.status])}
                  style={{ gridColumn: `span ${r.samples}` }}
                />
              );
            })}
          </div>
        </div>
      ))}
    </div>
  );
}

demo.tsxHow it is called: eight services, thirty daily checks, merged first, with a switch to show every sample.

import { useState } from "react";
import { Switch } from "@/components/ui/switch";
import { StatusHistory, type Row } from "./StatusHistory";
import type { Status } from "../semantic-status-color/status";

/**
 * Eight services, one daily check each, the thirty days ending 15 September.
 * auth had a six-day incident with two days at critical in the middle;
 * search and billing each degraded for a day. Merged first, because that is
 * how the notch reads from across the room; the switch shows every sample.
 */
const code: Record<string, Status> = { n: "nominal", w: "warn", c: "critical", u: "unknown" };
const row = (key: string, s: string): Row => ({ key, samples: [...s].map((ch) => code[ch]) });

const ROWS: Row[] = [
  row("checkout", "nnnnnnnnnnnnnnnnnnnnnnnnnnnnnn"),
  row("search", "nnnnnnnnwnnnnnnnnnnnnnnnnnnnnn"),
  row("cart", "nnnnnnnnnnnnnnnnnnnnnnnnnnnnnn"),
  row("user", "nnnnnnnnnnnnnnnnnnnnnnnnnnnnnn"),
  row("auth", "nnnnnnnnnnnnnnnwwccwwnnnnnnnnn"),
  row("media", "nnnnnnnnnnnnnnnnnnnnnnnnnnnnnn"),
  row("billing", "nnnnnnnnnnnnnnnnnnnnnnnnwnnnnn"),
  row("jobs", "nnnnnnnnnnnnnnnnnnnnnnnnnnnnnn"),
];

const START = new Date("2026-08-16T00:00:00Z");
const DAY = 24 * 60 * 60 * 1000;

export default function Demo() {
  const [merge, setMerge] = useState(true);

  return (
    <div className="w-[360px] rounded-lg border bg-card p-4">
      <StatusHistory
        rows={ROWS}
        start={START}
        intervalMs={DAY}
        merge={merge}
        onSelect={(r) => { location.hash = `#incidents/${r.row}/${r.from.toISOString().slice(0, 10)}`; }}
      />
      <label className="mt-4 flex items-center gap-2 text-[11px] text-muted-foreground">
        <Switch checked={merge} onCheckedChange={setMerge} className="scale-75" />
        Merge equal consecutive values
      </label>
    </div>
  );
}
What it renders. Identical markup in all three panes, with only the token values changing.

Examples

Screenshots are grouped by product and include the capture date. Dashed boxes highlight this pattern. Notes appear below each screenshot and when you hover over a box.

Grafana

Examples of panel grids, shared selectors, stat tiles, and chart configuration.

Examples / State timeline and Status history September 10, 2026 Grafana Play (signed out; no version string exposed) medium · dark · desktop-web
This page places a state timeline above a time-series view of the same query. The timeline converts numeric ranges into named states, making the periods easier to scan. The line chart keeps the numeric variation available for closer reading. Labels inside the colored regions explain their meaning, reducing reliance on color alone.