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.
Related
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.
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.
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.
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.
Energy flows in the Regional scenario September 11, 2026Tableau Public embed view; workbook published by Chia Yu Linmedium · 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.
Sankey and pathBand widths show quantity across three sets of nodes.
Sankey and pathConversion losses have an explicit destination.
Ratio and rateSupply and demand totals let readers check the overall quantities.
Demo dashboard September 10, 2026Home 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.
Query / HEATMAP(duration_ms) September 10, 2026Honeycomb 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.
HeatmapThe linear scale leaves most of the chart above the dense lower band.
Dashboard builderThe query controls define which requests appear in the result.
Tabs as genresTabs provide different ways to investigate the same result.
Metrics / System September 10, 2026Netdata 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.
Sidebar and canvasThe rail organizes collected metrics into a hierarchy.
Metrics / System › Compute › CPU September 10, 2026Netdata 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.
Collapsible rowNested headings use leading dashes to show depth.
Gauge and dialThe gauges mix bounded percentages with rates whose limits need explanation.