Skip to content
KONIGI

Dashboards / Visual representation / Host map

11 of 22

Host map

Hundreds of hosts; the viewer needs to spot the hot ones without a table of hundreds of rows.

Updated September 10, 2026

Problem

Four hundred hosts. Three of them are pinned at 100% CPU. A time series with four hundred lines is a solid colour block, and a table of four hundred rows requires the viewer to already know which column to sort by.

Solution

Give every machine one cell, colour the cell by a metric, and arrange the cells so position carries meaning. The viewer scans for the odd colour, then clicks it.

Datadog’s host map is the version everyone else is compared to. Hosts are hexagonal cells; the colour of each object represents CPU usage by default, ranging from green at 0% utilised to orange at 100%. A Fill by control swaps that for another metric, memory or error logs among them, and cells can also be sized by a metric.

The part that turns a pretty picture into a diagnostic tool is grouping. Datadog lets you group by multiple tags at once—group by availability zone and then instance type and the cells arrange first by zone, then subdivide by type. That nesting is what converts “one machine is hot” into “every machine in one availability zone is hot”, which is a different incident with a different cause. Filtering supports AND, NOT, OR and wildcards over the same tags.

Hexagons rather than squares is a real choice, not a stylistic one. Hexagons tile with every neighbour sharing an edge, so a cluster of unhealthy cells reads as a blob rather than as a diagonal that the eye has to assemble. It also makes cell count changes less visually disruptive than a grid that reflows into rows.

Use when

The fleet is large, roughly homogeneous, and the question is “which ones are unusual right now”. Especially good the moment somebody says “is it all of them or just one”.

Don’t use when

There are twelve hosts, or the machines are so heterogeneous that one metric doesn’t mean the same thing across them. Also a poor fit for anything you need history on: the map is emphatically now.

Trade-offs

One cell carries one metric, so a host that is fine on CPU and drowning on disk looks healthy. Colour is doing all the encoding, which puts the pattern at the mercy of the ramp and of the viewer’s colour vision. Position is meaningful only inside a grouping, so a viewer who doesn’t know the grouping reads an arbitrary arrangement as if it were a floor plan. And at genuinely large scale cells shrink below the size at which colour is readable, which is where grouping stops being an enhancement and becomes mandatory.

Checklist

  • What metric fills the cell, and does it mean the same thing on every host here?
  • Is the grouping visible and labelled, or is the arrangement effectively random to the viewer?
  • Can the viewer group by more than one dimension, and does nesting read clearly?
  • Does the colour ramp encode magnitude or state, and is the difference obvious?
  • What happens at the fleet size this team actually runs?
  • Can a cell be too small to read, and what does the view do about it?
  • Does clicking a cell go somewhere scoped to that host?
  • Is a host with no data distinguishable from a healthy one?
  • Would a colourblind viewer find the same outliers?
  • Is there any way to see whether this pattern of colour is new or has been like this all week?

Compare

Datadog owns this pattern: hexagon tiling, a default CPU fill from green to orange, arbitrary Fill by metrics, size by metric, and nested grouping over tags with boolean filtering. Netdata answers the same question with a node list carrying live per-second charts, trading the instant spatial scan for the ability to see whether a host has been like this for ten minutes or ten seconds. Grafana has no first-class equivalent, so teams build it from a stat panel grid or a geomap, which works and puts the burden of layout and grouping on whoever assembles the dashboard. Honeycomb rejects the framing outright, on the argument that the interesting dimension is rarely “host” and that pre-choosing it is what makes an unknown-unknown invisible.

Service map is the same spatial idea over calls instead of machines. Semantic status color governs the fill. Ranked list is the non-spatial answer to the same question and is often the better one. Drill-down is what a cell click owes the viewer. Grayscale with alerts is the discipline that keeps a four-hundred-cell map from being four hundred coloured things.

Host map anatomy Ninety hosts as hexagonal cells, coloured by utilisation and grouped first by availability zone. One whole zone is hot, which is a different incident from one hot machine and only visible because the cells are grouped. Grouping is what makes it diagnostic eu-west-1a eu-west-1b eu-west-1c 1 2 Idle Saturated 3 1 ONE CELL PER MACHINE Colour by a metric, and scan for the odd one rather than reading three hundred table rows. 2 NESTED GROUPING Group by zone, then subdivide by instance type. That's what turns "one machine is hot" into "one whole zone is hot", which is a different incident with a different cause. 3 WHY HEXAGONS Every neighbour shares an edge, so a cluster of unhealthy cells reads as a blob rather than a diagonal the eye has to assemble. Not a stylistic choice.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

One cell per machine, sized so a fleet fits on a screen. It answers how many and where, and it stops working past the point where a cell is smaller than a comfortable target.

Tokens
--card--muted-foreground--border--scale-seq-1--scale-seq-2--scale-seq-3--scale-seq-4--scale-seq-5

eu-west-1a40% mean

eu-west-1a-01: 37% CPUeu-west-1a-02: 54% CPUeu-west-1a-03: 33% CPUeu-west-1a-04: 50% CPUeu-west-1a-05: 28% CPUeu-west-1a-06: 45% CPUeu-west-1a-07: 23% CPUeu-west-1a-08: 57% CPUeu-west-1a-09: 35% CPUeu-west-1a-10: 53% CPUeu-west-1a-11: 31% CPUeu-west-1a-12: 48% CPUeu-west-1a-13: 27% CPUeu-west-1a-14: 44% CPUeu-west-1a-15: 22% CPUeu-west-1a-16: 56% CPUeu-west-1a-17: 34% CPUeu-west-1a-18: 51% CPUeu-west-1a-19: 29% CPUeu-west-1a-20: 47% CPUeu-west-1a-21: 25% CPUeu-west-1a-22: 42% CPUeu-west-1a-23: 37% CPUeu-west-1a-24: 54% CPUeu-west-1a-25: 33% CPUeu-west-1a-26: 50% CPUeu-west-1a-27: 28% CPUeu-west-1a-28: 45% CPUeu-west-1a-29: 23% CPUeu-west-1a-30: 57% CPUeu-west-1a-31: 35% CPUeu-west-1a-32: 53% CPUeu-west-1a-33: 31% CPUeu-west-1a-34: 48% CPUeu-west-1a-35: 27% CPU

eu-west-1b40% mean

eu-west-1b-01: 37% CPUeu-west-1b-02: 54% CPUeu-west-1b-03: 33% CPUeu-west-1b-04: 50% CPUeu-west-1b-05: 28% CPUeu-west-1b-06: 45% CPUeu-west-1b-07: 23% CPUeu-west-1b-08: 57% CPUeu-west-1b-09: 35% CPUeu-west-1b-10: 53% CPUeu-west-1b-11: 31% CPUeu-west-1b-12: 48% CPUeu-west-1b-13: 27% CPUeu-west-1b-14: 44% CPUeu-west-1b-15: 22% CPUeu-west-1b-16: 56% CPUeu-west-1b-17: 34% CPUeu-west-1b-18: 51% CPUeu-west-1b-19: 29% CPUeu-west-1b-20: 47% CPUeu-west-1b-21: 25% CPUeu-west-1b-22: 42% CPUeu-west-1b-23: 37% CPUeu-west-1b-24: 54% CPUeu-west-1b-25: 33% CPUeu-west-1b-26: 50% CPUeu-west-1b-27: 28% CPUeu-west-1b-28: 45% CPUeu-west-1b-29: 23% CPUeu-west-1b-30: 57% CPUeu-west-1b-31: 35% CPUeu-west-1b-32: 53% CPUeu-west-1b-33: 31% CPUeu-west-1b-34: 48% CPUeu-west-1b-35: 27% CPU

eu-west-1c70% mean

eu-west-1c-01: 76% CPUeu-west-1c-02: 65% CPUeu-west-1c-03: 71% CPUeu-west-1c-04: 77% CPUeu-west-1c-05: 67% CPUeu-west-1c-06: 73% CPUeu-west-1c-07: 62% CPUeu-west-1c-08: 68% CPUeu-west-1c-09: 74% CPUeu-west-1c-10: 64% CPUeu-west-1c-11: 70% CPUeu-west-1c-12: 76% CPUeu-west-1c-13: 65% CPUeu-west-1c-14: 71% CPUeu-west-1c-15: 77% CPUeu-west-1c-16: 67% CPUeu-west-1c-17: 73% CPUeu-west-1c-18: 62% CPUeu-west-1c-19: 68% CPUeu-west-1c-20: 74% CPUeu-west-1c-21: 64% CPUeu-west-1c-22: 70% CPUeu-west-1c-23: 76% CPUeu-west-1c-24: 65% CPUeu-west-1c-25: 71% CPUeu-west-1c-26: 77% CPUeu-west-1c-27: 67% CPUeu-west-1c-28: 73% CPUeu-west-1c-29: 62% CPUeu-west-1c-30: 68% CPUeu-west-1c-31: 74% CPUeu-west-1c-32: 64% CPUeu-west-1c-33: 70% CPUeu-west-1c-34: 76% CPUeu-west-1c-35: 65% CPU
IdleSaturatedno data

HostMap.tsxHexes laid out by zone, coloured by one metric on the five-step ramp, no-data drawn as its own state, a click scoped to the host.

/**
 * One cell per machine, coloured by one metric, arranged by one grouping.
 * The grouping is what makes it diagnostic: hosts are placed by zone first,
 * so "one machine is hot" and "one whole zone is hot" look different, and
 * they are different incidents.
 */
export type Host = {
  id: string;
  zone: string;
  /** 0–1. null means no sample arrived, which is drawn as its own thing
   *  rather than as idle. */
  value: number | null;
};

/** Five steps of the sequential ramp. Full class names, because Tailwind
 *  only emits a class it can see written out. */
const RAMP = ["fill-scale-seq-1", "fill-scale-seq-2", "fill-scale-seq-3", "fill-scale-seq-4", "fill-scale-seq-5"];
const SWATCH = ["bg-scale-seq-1", "bg-scale-seq-2", "bg-scale-seq-3", "bg-scale-seq-4", "bg-scale-seq-5"];
const step = (v: number) => RAMP[Math.min(4, Math.floor(v * RAMP.length))];

/** Pointy-top hexagon. Every neighbour shares an edge, so a cluster of hot
 *  cells reads as a blob rather than a diagonal the eye has to assemble. */
const hex = (cx: number, cy: number, r: number) => {
  const w = Math.sqrt(3) * r;
  return [[cx, cy - r], [cx + w / 2, cy - r / 2], [cx + w / 2, cy + r / 2], [cx, cy + r], [cx - w / 2, cy + r / 2], [cx - w / 2, cy - r / 2]]
    .map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(" ");
};

export function HostMap({ hosts, metric, onSelectHost, columns = 7, r = 13 }: {
  hosts: Host[];
  /** What the colour means, for the legend and the cell tooltip. */
  metric: string;
  /** A cell goes somewhere scoped to that host. */
  onSelectHost: (host: Host) => void;
  columns?: number;
  r?: number;
}) {
  const w = Math.sqrt(3) * r;
  const zones = [...new Set(hosts.map((h) => h.zone))];
  const rows = Math.ceil(hosts.length / zones.length / columns);
  const width = w * (columns + 0.5) + 4;
  const height = r * (1.5 * rows + 0.5) + 4;

  return (
    <div className="rounded-lg border bg-card p-4">
      <div className="grid gap-4 sm:grid-cols-3">
        {zones.map((zone) => {
          const members = hosts.filter((h) => h.zone === zone);
          const sampled = members.filter((h): h is Host & { value: number } => h.value !== null);
          const mean = sampled.reduce((a, h) => a + h.value, 0) / (sampled.length || 1);
          return (
            <div key={zone}>
              <p className="mb-2 flex justify-between text-xs uppercase tracking-wide text-muted-foreground">
                {zone}
                <span className="tabular-nums normal-case">{Math.round(mean * 100)}% mean</span>
              </p>
              <svg viewBox={`0 0 ${width} ${height}`} className="w-full" role="group" aria-label={`${members.length} hosts in ${zone}`}>
                {members.map((h, i) => {
                  const row = Math.floor(i / columns), col = i % columns;
                  const cx = 2 + w / 2 + col * w + (row % 2 ? w / 2 : 0);
                  const cy = 2 + r + row * 1.5 * r;
                  return (
                    <polygon
                      key={h.id}
                      points={hex(cx, cy, r)}
                      className={h.value === null ? "fill-none stroke-border" : step(h.value)}
                      strokeDasharray={h.value === null ? "2 2" : undefined}
                      role="button"
                      tabIndex={0}
                      onClick={() => onSelectHost(h)}
                      onKeyDown={(e) => e.key === "Enter" && onSelectHost(h)}
                    >
                      <title>{`${h.id}: ${h.value === null ? "no data" : `${Math.round(h.value * 100)}% ${metric}`}`}</title>
                    </polygon>
                  );
                })}
              </svg>
            </div>
          );
        })}
      </div>

      <div className="mt-3 flex items-center gap-1.5 text-[10px] text-muted-foreground">
        <span>Idle</span>
        {SWATCH.map((c) => <span key={c} className={`h-2 w-6 ${c}`} />)}
        <span>Saturated</span>
        <span className="ml-2 h-2 w-6 border border-dashed" />
        <span>no data</span>
      </div>
    </div>
  );
}

demo.tsxHow it is called: 105 hosts in three zones, one of them hot end to end.

import { useState } from "react";
import { HostMap, type Host } from "./HostMap";

/**
 * A hundred and five hosts across three zones, CPU as a fraction. Two zones
 * sit between a fifth and three fifths; the third is hot end to end, which is
 * the incident the grouping exists to show. Clicking a cell is where a real
 * app would open the host, scoped.
 */
const ZONES: [string, number[]][] = [
  ["eu-west-1a", [0.37, 0.54, 0.33, 0.50, 0.28, 0.45, 0.23, 0.57, 0.35, 0.53, 0.31, 0.48, 0.27, 0.44, 0.22, 0.56, 0.34, 0.51, 0.29, 0.47, 0.25, 0.42, 0.37, 0.54, 0.33, 0.50, 0.28, 0.45, 0.23, 0.57, 0.35, 0.53, 0.31, 0.48, 0.27]],
  ["eu-west-1b", [0.37, 0.54, 0.33, 0.50, 0.28, 0.45, 0.23, 0.57, 0.35, 0.53, 0.31, 0.48, 0.27, 0.44, 0.22, 0.56, 0.34, 0.51, 0.29, 0.47, 0.25, 0.42, 0.37, 0.54, 0.33, 0.50, 0.28, 0.45, 0.23, 0.57, 0.35, 0.53, 0.31, 0.48, 0.27]],
  ["eu-west-1c", [0.76, 0.65, 0.71, 0.77, 0.67, 0.73, 0.62, 0.68, 0.74, 0.64, 0.70, 0.76, 0.65, 0.71, 0.77, 0.67, 0.73, 0.62, 0.68, 0.74, 0.64, 0.70, 0.76, 0.65, 0.71, 0.77, 0.67, 0.73, 0.62, 0.68, 0.74, 0.64, 0.70, 0.76, 0.65]],
];

const HOSTS: Host[] = ZONES.flatMap(([zone, values]) =>
  values.map((value, i) => ({ id: `${zone}-${String(i + 1).padStart(2, "0")}`, zone, value })),
);

export default function Demo() {
  const [picked, setPicked] = useState<Host | null>(null);
  return (
    <div>
      <HostMap hosts={HOSTS} metric="CPU" onSelectHost={setPicked} />
      {picked && (
        <p className="mt-2 text-xs text-muted-foreground">
          open {picked.id} at {Math.round((picked.value ?? 0) * 100)}% CPU
        </p>
      )}
    </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 / Polystat Visualization Showcase September 10, 2026 Grafana Play (signed out; no version string exposed) medium · dark · desktop-web
The plugin's own description is the host map pattern stated plainly —"visualise hundreds of metric series as a grid of coloured polygons" and "spot anomalies across your entire fleet at a glance"—and the shapes gallery halfway down says all six shapes "apply the same threshold colouring; choose the one that fits your layout." The page then disproves its own claim. The hexagon panels tile with every neighbour sharing an edge, so three cells read as one block. The circle panels leave a gap between every pair, so three cells read as three things you have to compare one at a time. At six cells that difference is a preference. At three hundred it is the difference between a cluster you see and a cluster you assemble, which is why the shape is a structural choice rather than a styling one.
  • Host map Hexagons sharing edges. Adjacent unhealthy cells merge into one shape, which is the point of the tiling.
  • Host map The same data as circles: a gap around every cell, so nothing clusters.
  • Semantic status color A closed set of three—OK, warning, critical—applied as the whole cell fill.
  • Ranked list Sorted descending so the worst cell lands where the eye starts. No share of total, and no row for the rest.
  • KPI tile Name-only mode: two cells carrying a label, a colour and no number at all.