Skip to content
KONIGI Patterns

Dashboards / Dashboard features / Dashboard builder

‹ 4 of 10 ›

Dashboard builder

People need to build dashboards for their own questions without relying on someone else for every change.

Updated September 10, 2026

Problem

People who know what a dashboard should show may need help with every query and layout change.

Solution

Provide an editor for choosing data, selecting a suitable chart, and arranging panels. Keep common choices easy to reach and offer advanced options when needed.

Help authors understand what the query returns before choosing how to display it. Preview results and make units, filters, and definitions visible.

Separate viewing from editing on shared dashboards. Support review and version history where changes affect a wider audience. Give dashboards clear names and owners so people can identify the maintained version.

Use when

People understand the questions they need to answer and can work with the available data controls.

Don’t use when

Correctness matters more than throughput. Anything feeding a decision with consequences wants review, and a builder that lets anyone publish is a builder that lets anyone publish something wrong that looks official.

Trade-offs

Easy creation can produce duplicate dashboards or charts that look correct but use the wrong data. Shared editing needs clear permissions and recovery. A growing collection also needs owners to review and retire old views.

Checklist

  • Who is the intended builder, and do they know the metric names?
  • What is in the first screen of the editor, and is it what most people need?
  • Can someone produce a subtly wrong panel without any warning?
  • Is there a review step before a dashboard becomes shared?
  • Can edits to a shared dashboard happen by accident, and is there history?
  • Is there a way to tell an authoritative dashboard from a copy?
  • Are dashboards editable as files as well as in the UI?
  • What prunes dashboards nobody opens?
  • Does the builder encourage a default that is good, or one that merely works?
  • Is there a template for the common case, so people start from something?

Compare

Grafana is the reference: a panel editor with query, transform and visualisation tabs, plus dashboards as JSON that can be exported, version-controlled and provisioned, so a team can choose UI-first or code-first per dashboard. Datadog offers a similar builder over its own tag model, which makes scoping easier and the data model harder to escape. Looker puts a modelling layer in front, so exploration is safe by construction and adding a genuinely new metric is an engineering task, which is the clearest expression of the correctness-versus-reach trade. Honeycomb barely has a builder, because the primary artefact is a query rather than a page, and boards are collections of saved queries rather than composed layouts.

Panel grid arranges authored panels. Resizable card grid lets people adjust their layout. Template variable makes a dashboard reusable across selections.

Explain this metric keeps definitions accessible. Share and embed distributes the finished view.

Dashboard builder anatomy A panel editor: a preview above, a query pane below it, a visualisation picker and an options drawer down the side with most of its sections collapsed. Below, the thing no builder provides—a count of a thousand dashboards nobody curates. Anatomy of a panel editor preview Query prom-prod Visualisation Options Panel Axis Thresholds Value mappings 1 2 1 WHERE THE DIFFICULTY SITS Query first puts the data model in front. It's honest, and it blocks anyone who doesn't know the metric names. 2 WHAT'S COLLAPSED The split has to serve someone adding a fifth stat panel and someone choosing a bucketing strategy, and they want nothing in common. A builder makes adding a panel easy and cannot make anyone decide a page is finished.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

The person who needs the dashboard has to be able to make it. Live preview beside the query is the mechanism, because the alternative is a save-and-look loop that punishes every experiment.

shadcn
npx shadcn@latest add card button dropdown-menu textarea toggle-group collapsible input
npm
recharts lucide-react
Tokens
--card--card-foreground--muted--muted-foreground--border--chart-1

preview

Query

Visualisation

Options

PanelEditor.tsxPreview over query, picker over options. The visualisation set is closed, the preview redraws from the picker, and only one options section is open at a time.

import { useState } from "react";
import { Bar, BarChart, Line, LineChart, YAxis } from "recharts";
import { ChevronDown, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
  DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";

/** The picker is a closed set. A new visualisation is a code change, because
 *  each one needs a preview renderer and its own options. */
export type Viz = "timeseries" | "stat" | "bar";
const VIZ: Viz[] = ["timeseries", "stat", "bar"];

/** Options are grouped so the first screen is what most people need. Panel is
 *  open; the rest are one click away for the person choosing a bucketing
 *  strategy. Field names double as keys into the options bag. */
export type Section = "Panel" | "Axis" | "Thresholds" | "Value mappings";
const SECTIONS: Record<Section, string[]> = {
  Panel: ["Title", "Description"],
  Axis: ["Min", "Max", "Unit"],
  Thresholds: ["Warn above", "Critical above"],
  "Value mappings": ["Match", "Show as"],
};

export type Point = { t: string; value: number };

type Props = {
  sources: string[];
  source: string;
  onSourceChange: (source: string) => void;
  query: string;
  onQueryChange: (query: string) => void;
  /** What the query returns right now. The preview is live because the
   *  caller re-runs the query on change and passes the result back in. */
  result: Point[];
  viz: Viz;
  onVizChange: (viz: Viz) => void;
  options: Record<string, string>;
  onOptionChange: (field: string, value: string) => void;
};

const SERIES = "hsl(var(--chart-1))";

function Preview({ viz, result }: { viz: Viz; result: Point[] }) {
  const last = result[result.length - 1]?.value;
  if (viz === "stat") return <p className="mt-2 text-3xl font-semibold tabular-nums text-card-foreground">{last}</p>;
  const Chart = viz === "bar" ? BarChart : LineChart;
  return (
    <div className="mt-2 overflow-x-auto">
      <Chart width={340} height={74} data={result} margin={{ top: 4, right: 0, bottom: 0, left: 0 }}>
        <YAxis hide domain={[0, "dataMax + 8"]} />
        {viz === "bar"
          ? <Bar dataKey="value" fill={SERIES} isAnimationActive={false} />
          : <Line dataKey="value" stroke={SERIES} strokeWidth={2} dot={false} isAnimationActive={false} />}
      </Chart>
    </div>
  );
}

export function PanelEditor({
  sources, source, onSourceChange, query, onQueryChange, result, viz, onVizChange, options, onOptionChange,
}: Props) {
  const [open, setOpen] = useState<Section>("Panel");
  const heading = "text-[11px] uppercase tracking-wide text-muted-foreground";

  return (
    <div className="grid grid-cols-[1.5fr_1fr] gap-2.5">
      <div className="flex flex-col gap-2.5">
        <Card className="p-3">
          <p className="text-[10px] uppercase tracking-wide text-muted-foreground">preview</p>
          <Preview viz={viz} result={result} />
        </Card>
        <Card className="bg-muted p-3">
          <p className={heading}>Query</p>
          <DropdownMenu modal={false}>
            <DropdownMenuTrigger asChild>
              <Button variant="outline" size="sm" className="mt-2 h-6 gap-1 px-2 text-[10px]">{source} <ChevronDown className="size-3" /></Button>
            </DropdownMenuTrigger>
            <DropdownMenuContent align="start">
              <DropdownMenuRadioGroup value={source} onValueChange={onSourceChange}>
                {sources.map((s) => <DropdownMenuRadioItem key={s} value={s}>{s}</DropdownMenuRadioItem>)}
              </DropdownMenuRadioGroup>
            </DropdownMenuContent>
          </DropdownMenu>
          <Textarea value={query} onChange={(e) => onQueryChange(e.target.value)} rows={2} spellCheck={false}
            className="mt-2 min-h-0 resize-none bg-card font-mono text-[10px]" aria-label="Query" />
        </Card>
      </div>

      <div className="flex flex-col gap-2.5">
        <Card className="bg-muted p-3">
          <p className={heading}>Visualisation</p>
          <ToggleGroup type="single" value={viz} onValueChange={(v) => v && onVizChange(v as Viz)} className="mt-2 grid grid-cols-3 gap-1.5">
            {VIZ.map((v) => (
              <ToggleGroupItem key={v} value={v} className="h-8 border bg-card text-[10px] data-[state=on]:border-2 data-[state=on]:border-chart-1 data-[state=on]:bg-card">{v}</ToggleGroupItem>
            ))}
          </ToggleGroup>
        </Card>
        <Card className="bg-muted p-3">
          <p className={heading}>Options</p>
          <div className="mt-1 divide-y text-xs">
            {(Object.keys(SECTIONS) as Section[]).map((name) => (
              <Collapsible key={name} open={open === name} onOpenChange={(o) => setOpen(o ? name : open)}>
                <CollapsibleTrigger className="flex w-full items-center gap-2 py-1.5 text-left text-card-foreground">
                  {open === name ? <ChevronDown className="size-3 text-muted-foreground" /> : <ChevronRight className="size-3 text-muted-foreground" />}
                  {name}
                </CollapsibleTrigger>
                <CollapsibleContent className="flex flex-col gap-1.5 pb-2 pl-5">
                  {SECTIONS[name].map((field) => (
                    <Input key={field} value={options[field] ?? ""} placeholder={field} aria-label={field}
                      onChange={(e) => onOptionChange(field, e.target.value)} className="h-6 bg-card text-[10px]" />
                  ))}
                </CollapsibleContent>
              </Collapsible>
            ))}
          </div>
        </Card>
      </div>
    </div>
  );
}

demo.tsxHow it is called: a Prometheus rate query drawn as a time series, with the Panel section open.

import { useState } from "react";
import { PanelEditor, type Viz } from "./PanelEditor";

/**
 * One panel mid-edit: a Prometheus source, a rate query, the time series
 * picked, and the Panel section open. The result is fixed here; a real app
 * re-runs the query when it changes and the preview follows.
 */
const RESULT = [
  { t: "09:00", value: 18 }, { t: "09:10", value: 30 }, { t: "09:20", value: 24 }, { t: "09:30", value: 48 },
  { t: "09:40", value: 40 }, { t: "09:50", value: 60 }, { t: "10:00", value: 56 },
];

export default function Demo() {
  const [source, setSource] = useState("prom-prod");
  const [query, setQuery] = useState('sum(rate(http_requests_total{job="checkout"}[5m]))');
  const [viz, setViz] = useState<Viz>("timeseries");
  const [options, setOptions] = useState<Record<string, string>>({ Title: "Checkout requests", Description: "req/s, 5m rate" });

  return (
    <PanelEditor
      sources={["prom-prod", "prom-staging", "loki-prod"]}
      source={source}
      onSourceChange={setSource}
      query={query}
      onQueryChange={setQuery}
      result={RESULT}
      viz={viz}
      onVizChange={setViz}
      options={options}
      onOptionChange={(field, value) => setOptions({ ...options, [field]: value })}
    />
  );
}
What it renders. Identical markup in both 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.

Tableau Public

Published workbooks show how individual authors arrange charts and interactions in Tableau.

Energy flows in the Regional scenario September 11, 2026 Tableau Public embed view; workbook published by Chia Yu Lin medium · light · desktop-web
The diagram follows energy from supply to demand using band widths for quantity. It includes conversion losses as a separate destination, so the difference between the stated supply and demand has an explanation. Carrier colors stay consistent across the columns, helping readers follow a flow. Layout controls are exposed beside the chart, including a highly precise spacing value that adds little to the data reading.
Show 4 more examples Hide the rest

Home Assistant

Room-based card layouts combine household device states with controls.

Demo dashboard September 10, 2026 Home Assistant public demo (signed out) medium · light · desktop-web
Cards are grouped by rooms such as Living room, Kitchen, and Study. States are written in words, with icon color providing a second cue. Section headers add useful room summaries, such as temperature or meeting status. The page also contains controls, including lighting sliders and thermostat buttons, so people can act in the same place they check the state.

Honeycomb

Query-driven views use heatmaps and selected regions to investigate differences between requests.

Query / HEATMAP(duration_ms) September 10, 2026 Honeycomb sandbox, public dataset (signed out; no version string exposed) medium · light · desktop-web
This sandbox heatmap uses a linear vertical scale. Most requests sit near the bottom, while a separate group of slower requests extends upward later in the period. The view exposes variation that a single summary value would hide, though a different scale could give the lower band more room. The footer reports query duration and rows examined. A cookie banner and onboarding dialog were removed for the capture; the dashboard itself was left intact.

Netdata

A large collection of system charts with per-chart controls and anomaly-rate views.

Metrics / System September 10, 2026 Netdata Agent v2.10.0-686-nightly (public registry node, signed out) dense · dark · desktop-web
The navigation reports 720 charts organized by the metrics collected from the system. The main page follows the same nested structure, giving broad coverage but little indication of which charts matter most to a particular task. Search helps navigate that volume. The header also distinguishes live and stale nodes, making missing updates visible instead of presenting every node as current.
Metrics / System › Compute › CPU September 10, 2026 Netdata Agent (public registry node, signed out) dense · dark · desktop-web
This section follows several nested levels of the generated metric hierarchy. Leading dashes indicate depth, while a breadcrumb identifies the current CPU section. Each chart includes its own grouping and aggregation controls. The Anomaly Rate option is visible but switched off, so this capture demonstrates the control rather than the resulting anomaly display.