Skip to content
KONIGI

Dashboards / Visual representation / Status history

19 of 22

Status history

The viewer needs to see when something was up, down, or degraded, over days, in one row.

Updated September 10, 2026

Problem

Was it down last Tuesday? For how long? A line chart of a binary metric is a square wave nobody can read at a month’s width, and an incident log is a list of things somebody remembered to write down.

Solution

One row per thing, time along the x-axis, colour for state. Continuous coloured regions, so a week of health is a long green bar and an outage is a red notch you can see from across the room.

The encoding change matters. A time series asks the viewer to read a value and mentally threshold it. A status history has already done the thresholding and shows the conclusion, which is what makes a month of history readable in a strip a few pixels tall.

Grafana splits this into two panels and the split is instructive. The state timeline draws state as continuous regions and carries Merge equal consecutive values, which controls whether identical adjacent values are joined into one block. With merging on, a month of “up” is a single bar and the notches are unmissable. With it off, you see every sample, which matters when the sampling interval is itself the story. Show values offers Auto, Always and Never for labelling regions in place. Value mappings turn raw values into named states with colours, and thresholds can convert an ordinary numeric series into discrete coloured regions, which is how a CPU metric becomes a health strip without a separate query.

The status history panel is the discrete-sample sibling: one mark per sample rather than a continuous region, which suits checks that run on a fixed schedule where the gaps between them are real rather than interpolated.

Use when

The state is categorical and low-cardinality, and the question spans a period longer than a chart can show usefully. Uptime, deployment state, check results, on-call coverage, batch job outcomes.

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

The whole pattern rests on a thresholding decision made somewhere else, usually invisibly, and a status strip inherits every flaw in it while looking authoritative. Brief outages disappear below one pixel at wide time ranges, so a strip showing a clean month may be hiding forty two-minute blips. Merging consecutive values makes the display readable and destroys the distinction between “we checked continuously” and “we checked twice”. And a row per entity does not scale: forty rows is a wall of colour with no ordering principle unless somebody supplies one.

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 is where the rule that produces these states usually gets drawn. Semantic status color governs the palette and is what makes the strip readable or not. Error and stale state covers the case this pattern most often gets wrong, which is a gap that means “unknown”. Time series is the continuous version this replaces. Annotation is how the cause gets attached to the notch.

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

Captures whose hotspots reference this pattern, grouped by product and dated. The dashed boxes are this pattern; hover any box for the note.

Grafana

The reference implementation for panel grids, template variables, and stat panels; most other tools are defined by how they differ from it.

Examples / State timeline and Status history September 10, 2026 Grafana Play (signed out; no version string exposed) medium · dark · desktop-web
The most useful thing on this page is an accident of layout: the third panel down the left column draws three series as state bands, and the panel directly underneath draws the identical query as an ordinary time series. Same data, stacked one above the other. The line chart is three tangled traces oscillating between 57 and 64, and reading it means picking a series, tracing it, and thresholding it in your head. The state timeline above has already done that and shows the conclusion, and the blue stretches—below fifty—are findable without being looked for. That is the whole argument for the encoding, and Grafana has put the before and after in adjacent panels. Worth noticing too that every coloured region here carries its word inside it. LOW, HIGH, NORMAL, CRITICAL, True, False. Colour is never the only carrier on this page.
  • Status history Continuous regions with the state name written inside each one.
  • Threshold line and region Thresholds turning a numeric series into discrete bands: under 50, 50, 300.
  • Time series The same query as the panel above, untranslated. Three traces you have to threshold yourself.
  • Sequential and diverging scales One mark per sample, green through red by temperature—so green here means cold, not good.
  • Semantic status color A closed set of two, both labelled. The clearest case on the page of colour plus a word.