Skip to content
KONIGI Patterns

Dashboards / Charts and data displays / Host map

‹ 11 of 22 ›

Host map

People need to spot unusual machines within a large fleet.

Updated September 10, 2026

Problem

People need to find unusual machines in a large fleet without reading hundreds of table rows.

Solution

Show each host as a cell colored by a selected metric. Label the metric and scale, and provide another way to identify important states besides color.

Group hosts by meaningful attributes such as location or type. Keep those group boundaries clear so people can distinguish a single unusual host from a wider problem.

Let people filter the fleet and open a host’s details. At larger sizes, summarize groups rather than shrinking cells until they become unreadable.

Use when

A large fleet contains comparable machines and people need to locate current exceptions.

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 selected metric can hide problems in another. The layout may look geographic even when it is only grouped. Small cells and heavy dependence on color make exact comparison difficult.

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 shows relationships between services. Semantic status color defines state meanings. Ranked list offers an ordered alternative.

Drill-down opens host details. Grayscale with alerts keeps exceptions distinct from routine activity.

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

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 / Polystat Visualization Showcase September 10, 2026 Grafana Play (signed out; no version string exposed) medium · dark · desktop-web
This showcase displays the same threshold-colored cells in several shapes. Hexagons touch along their edges, while circles leave gaps, changing how adjacent cells form visible groups. The choice matters when scanning many items together. Other panels demonstrate named states, sorting, and label-only cells, each emphasizing a different part of the reading.
  • Host map Touching hexagons make adjacent cells read as a group.
  • Host map Circles leave gaps between neighboring cells.
  • Semantic status color Cell fills represent OK, warning, and critical states.
  • Ranked list Descending order brings the highest values first.
  • KPI tile Label-only cells show a name and color without a numeric value.