Skip to content
KONIGI Patterns

Dashboards / Interaction / Search across panels

‹ 6 of 8 ›

Search across panels

People need to find a panel or dashboard without knowing where it is stored.

Updated September 10, 2026

Problem

People may remember a metric or panel name without knowing which dashboard contains it.

Solution

Provide search across useful titles, descriptions, and tags, with a keyboard shortcut and a visible entry point. Include panel names so results are not limited to dashboard titles.

Show why each result matched and link to the relevant panel where possible. Include enough context to distinguish similarly named dashboards, such as their folder or owner.

Keep browsing available for people who do not know the vocabulary. Make maintained or authoritative views identifiable rather than relying only on recent edits for ranking.

Use when

There are too many dashboards to navigate from memory and people often arrive with a search term.

Don’t use when

There are eight dashboards. Search over a small set adds an interaction where a visible list would have been faster, and it hides the shape of what exists from anyone new.

Trade-offs

Search depends on knowing relevant words and can favor experienced users. Duplicate dashboards make results confusing. Search also needs a maintained index and clear ranking to avoid surfacing outdated copies.

Checklist

  • What does search cover: dashboard titles, panel titles, descriptions, tags, query text?
  • Does each result say why it matched?
  • Is there a keyboard shortcut, and does it work from every page?
  • What ranks results, and does that favour authoritative over recent?
  • Are duplicates and forks distinguishable in the results?
  • Is there any signal of which dashboard a team actually uses?
  • What does a zero-result search offer next?
  • Can results be filtered by tag, folder or owner?
  • Does search reach dashboards the viewer cannot access, and how is that handled?
  • Is anything encouraging structure, or has search made sprawl free?

Compare

Grafana treats dashboard search as the real navigation at scale, with tag and folder filtering layered on, which is an honest acknowledgement that a folder tree stops working somewhere in the low hundreds. Datadog runs a global search across dashboards, monitors, logs and traces from one field, so the unit found is a resource of any type rather than a page. Sentry has a smaller estate and pushes search into the issue stream instead, where the query language does the narrowing that navigation does elsewhere. Command palettes in developer tools generally are worth studying here, because they solved the same problem—many destinations, keyboard-first, results that explain themselves—and set the expectation people now bring to dashboards.

Sidebar and canvas supports browsing. Filter bar narrows data rather than destinations. Saved view makes a found configuration easy to revisit.

Multi-page dashboard supplies the page structure. Explain this metric helps people learn the terms they may search for.

Search across panels anatomy A command palette over a dimmed page. Each result says what it matched on—a panel title, a tag, a query—rather than only naming the dashboard. At the foot, six results all called API Overview, with nothing on screen saying which one the team uses. Says what it matched, not just what it found checkout p95 Payments overview panel: Checkout p95 by region Service SLOs tag: checkout Edge latency query: histogram_quantile(0.95, checkout_...) cmd K 1 2 "API overview" API Overview edited 2 years ago API Overview edited 4 months ago API Overview edited yesterday API Overview (old) edited 3 years ago 2 more 3 1 REACHABLE WITHOUT A MOUSE A palette on a shortcut collapses navigate- to-anything into two seconds, and once it exists people stop using the nav at all. 2 WHY IT MATCHED The word someone remembers is usually a panel title or a tag, not the dashboard's name. Say where the hit was. 3 THE PART NOBODY SOLVES Dashboards accumulate duplicates, and nothing on screen says which of the six is the one the team actually uses.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

Sixty panels and the reader knows the name of the one they want. Results have to say which dashboard and which row each hit lives on, because half the panel names in an organisation are the same three words.

shadcn
npx shadcn@latest add command button
npm
cmdk lucide-react
Tokens
--popover--popover-foreground--muted-foreground--border--accent
cmd K

Payments overview Payments

panel: Checkout p95 by region

412 opens · 30d

edited 3 days ago

Service SLOs Platform

tag: checkout

268 opens · 30d

edited 11 days ago

Edge latency Network

query: histogram_quantile(0.95, sum by (le, pop) (rate(checkout_edge_ttfb_seconds_bucket[5m])))

57 opens · 30d

edited 2 days ago

SearchAcrossPanels.tsxThe palette on ⌘K. Every row says which field it matched and how much the dashboard is used, and the empty state says what search covers.

import { useEffect, useState } from "react";
import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { ago, search, type Dashboard, type Hit } from "./search";

export function SearchAcrossPanels({ dashboards, open, onOpenChange, defaultQuery = "", onOpen, now }: {
  dashboards: Dashboard[];
  open: boolean;
  onOpenChange: (open: boolean) => void;
  defaultQuery?: string;
  onOpen: (hit: Hit) => void;
  /** The clock to age "edited" against. Pass one to render on a server. */
  now?: Date;
}) {
  const [query, setQuery] = useState(defaultQuery);
  const hits = search(query, dashboards);
  const at = now ?? new Date();

  // ⌘K from anywhere on the page, and the same key closes it.
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if ((e.metaKey || e.ctrlKey) && e.key === "k") { e.preventDefault(); onOpenChange(!open); }
      if (e.key === "Escape" && open) onOpenChange(false);
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [open, onOpenChange]);

  if (!open) return null;

  return (
    // In a page this sits in CommandDialog over the dimmed content; the box
    // itself is the same.
    <Command shouldFilter={false} className="relative rounded-lg border shadow-md">
      <CommandInput value={query} onValueChange={setQuery} placeholder="Search dashboards, panels, tags and queries" autoFocus />
      <kbd className="absolute right-3 top-3 rounded border px-1.5 py-0.5 text-[10px] text-muted-foreground">cmd K</kbd>
      <CommandList>
        <CommandEmpty className="px-3 py-6 text-xs text-muted-foreground">
          Nothing in a title, panel, tag or query mentions "{query}". Try one word of it, or a tag.
        </CommandEmpty>
        {hits.map((h) => (
          <CommandItem key={h.dashboard.id} value={h.dashboard.id} onSelect={() => onOpen(h)} className="flex items-start gap-3 px-3 py-2.5">
            <div className="min-w-0 flex-1">
              <p className="text-sm text-popover-foreground">{h.dashboard.title} <span className="text-[11px] text-muted-foreground">{h.dashboard.folder}</span></p>
              {/* Why it matched. The word someone remembers is usually a
                  panel or a tag, and this is where it was. */}
              <p className={`mt-0.5 truncate text-[11px] text-muted-foreground ${h.on === "query" ? "font-mono text-[10px]" : ""}`}>{h.on}: {h.text}</p>
            </div>
            <div className="shrink-0 text-right text-[10px] tabular-nums text-muted-foreground">
              <p>{h.dashboard.opens} opens · 30d</p>
              <p>edited {ago(h.dashboard.edited, at)}</p>
            </div>
          </CommandItem>
        ))}
      </CommandList>
    </Command>
  );
}

search.tsReaches titles, panels, tags and query text; a hit carries the field it was found in; ranks by terms matched, then use, then recency.

/** One dashboard as the index sees it. Everything search reaches is here,
 *  and nothing else is searchable. */
export type Dashboard = {
  id: string;
  title: string;
  folder: string;
  tags: string[];
  panels: { title: string; query: string }[];
  edited: Date;
  /** Opens in the last thirty days. The only signal of which copy a team
   *  actually uses, so it ranks above recency. */
  opens: number;
};

/** Where the hit was. Closed, and a hit cannot exist without one: a result
 *  that only names the dashboard makes the reader open it and hunt. */
export type Field = "title" | "panel" | "tag" | "query";
const ORDER: Field[] = ["title", "panel", "tag", "query"];

export type Hit = { dashboard: Dashboard; on: Field; text: string; terms: number };

const fields = (d: Dashboard): [Field, string][] => [
  ["title", d.title],
  ...d.panels.map(({ title }) => ["panel", title] as [Field, string]),
  ...d.tags.map((t) => ["tag", t] as [Field, string]),
  ...d.panels.map(({ query }) => ["query", query] as [Field, string]),
];

/** Every term is tried against every field. The field that holds the most
 *  terms is the reason shown; ties go to the field a person would remember. */
export function search(q: string, dashboards: Dashboard[]): Hit[] {
  const terms = q.toLowerCase().split(/\s+/).filter(Boolean);
  if (!terms.length) return [];
  const hits: Hit[] = [];
  for (const d of dashboards) {
    let best: Hit | null = null;
    for (const [on, text] of fields(d)) {
      const n = terms.filter((t) => text.toLowerCase().includes(t)).length;
      if (n && (!best || n > best.terms || (n === best.terms && ORDER.indexOf(on) < ORDER.indexOf(best.on))))
        best = { dashboard: d, on, text, terms: n };
    }
    if (best) hits.push(best);
  }
  // Terms matched, then use, then recency. Recency last on purpose: it
  // surfaces whatever somebody edited, which is rarely the authoritative copy.
  return hits.sort((a, b) => b.terms - a.terms || b.dashboard.opens - a.dashboard.opens || b.dashboard.edited.getTime() - a.dashboard.edited.getTime());
}

export function ago(then: Date, now: Date) {
  const days = Math.floor((now.getTime() - then.getTime()) / 86_400_000);
  if (days < 1) return "today";
  if (days === 1) return "yesterday";
  if (days < 30) return `${days} days ago`;
  if (days < 365) return `${Math.floor(days / 30)} months ago`;
  const y = Math.floor(days / 365);
  return y === 1 ? "a year ago" : `${y} years ago`;
}

demo.tsxHow it is called: five dashboards, opened on "checkout p95". Type "api" for the two copies of API Overview and the opens count that separates them.

import { useState } from "react";
import { Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SearchAcrossPanels } from "./SearchAcrossPanels";
import type { Dashboard } from "./search";

const NOW = new Date("2026-09-15T09:00:00Z");
const daysAgo = (n: number) => new Date(NOW.getTime() - n * 86_400_000);

/** A slice of the estate. Two are called API Overview; the opens column is
 *  what tells them apart. */
const DASHBOARDS: Dashboard[] = [
  { id: "pay", title: "Payments overview", folder: "Payments", tags: ["payments"], edited: daysAgo(3), opens: 412,
    panels: [{ title: "Checkout p95 by region", query: "histogram_quantile(0.95, sum by (le, region) (rate(checkout_latency_seconds_bucket[5m])))" }, { title: "Authorisations", query: "sum(rate(payment_auth_total[5m]))" }] },
  { id: "slo", title: "Service SLOs", folder: "Platform", tags: ["checkout", "slo"], edited: daysAgo(11), opens: 268,
    panels: [{ title: "Availability", query: "1 - (sum(rate(http_5xx_total[30d])) / sum(rate(http_requests_total[30d])))" }, { title: "Latency burn rate", query: "slo:latency_burn_rate:1h" }] },
  { id: "edge", title: "Edge latency", folder: "Network", tags: ["edge", "cdn"], edited: daysAgo(2), opens: 57,
    panels: [{ title: "TTFB by POP", query: "histogram_quantile(0.95, sum by (le, pop) (rate(checkout_edge_ttfb_seconds_bucket[5m])))" }] },
  { id: "api", title: "API Overview", folder: "Platform", tags: ["api"], edited: daysAgo(120), opens: 390,
    panels: [{ title: "Requests", query: "sum(rate(http_requests_total[5m]))" }] },
  { id: "api-old", title: "API Overview", folder: "Archive", tags: ["api"], edited: daysAgo(730), opens: 2,
    panels: [{ title: "Requests", query: "sum(rate(http_requests_total[5m]))" }] },
];

/** Open on arrival with a search already typed. ⌘K closes and opens it;
 *  picking a result would navigate. Type "api" to see the duplicates. */
export default function Demo() {
  const [open, setOpen] = useState(true);
  const [went, setWent] = useState<string | null>(null);
  return (
    <div className="min-h-[220px]">
      {!open && (
        <Button variant="outline" size="sm" onClick={() => setOpen(true)}><Search className="size-3.5" /> Search <kbd className="ml-1 text-[10px] text-muted-foreground">⌘K</kbd></Button>
      )}
      <SearchAcrossPanels
        dashboards={DASHBOARDS}
        open={open}
        onOpenChange={setOpen}
        defaultQuery="checkout p95"
        onOpen={(h) => { setWent(`${h.dashboard.title}, ${h.on} ${h.text}`); setOpen(false); }}
        now={NOW}
      />
      {went && <p className="mt-2 text-xs text-muted-foreground">opened {went}</p>}
    </div>
  );
}
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.

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.
Show 3 more examples Hide the rest

Kibana

A query bar and filtering controls define the data shown in charts and document tables.

Dashboards / list September 10, 2026 Elastic demo environment (guest session) medium · light · desktop-web
The dashboard list has fourteen pages, with many similarly prefixed Kubernetes entries on the first page. Search helps people find a specific destination without browsing the full collection. Descriptions beneath titles provide useful context, while prefixes and tags group related entries. The list would also benefit from a clear indication of which dashboards are maintained or recommended.
Discover / filebeat logs September 10, 2026 Elastic demo environment (guest session; no version string exposed) dense · light · desktop-web
The view places a searchable field list beside a large table of documents, with a volume chart above. Search is useful for navigating the hundreds of available fields. The default Summary column combines many fields into each row, making quick comparisons difficult until people choose more focused columns. Pagination and sorting are visible, but the scope of each operation needs to be clear.

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.