Skip to content
KONIGI

Dashboards / Interaction / Saved view

5 of 8

Saved view

The team re-selects the same five filters every morning.

Updated September 10, 2026

Problem

Every morning the same person sets the same three filters, the same time range and the same sort, then reads the result for ninety seconds. They have done this two hundred times.

Solution

Let a configured state be named and kept. Filters, range, sort, column selection, expansion—captured as an object with a name, reachable in one click.

Shneiderman lists history as one of the seven tasks, alongside overview, zoom, filter, details-on-demand, relate and extract, and it is the one dashboards most consistently omit. A tool that supports exploration and forgets everything the moment you leave has made every session start from zero.

The first design question is what a saved view actually captures, and getting it wrong in either direction hurts. Capture too little and it does not restore the thing the person had. Capture too much—including the absolute time range—and a view saved during an incident reopens next month showing that incident, which is the single most common bug in this pattern. Relative ranges usually should be saved; absolute ones usually should not, or should be saved with a prompt.

The second is ownership, and there are three tiers that behave differently. Personal views are cheap, safe and proliferate quietly. Shared views become organisational furniture and need an owner. Default views—the state a page opens in for everybody—are the most valuable and the most political, because whoever controls the default controls what everyone sees first.

The third is drift. A saved view references dimensions, filters and panels that change underneath it. A view filtered to a service that no longer exists should say so rather than silently returning nothing, which is indistinguishable from a healthy quiet system.

Use when

The state takes more than a few actions to reconstruct, people return to the same state repeatedly, and roles differ enough that one default cannot serve everyone.

Don’t use when

The state is trivially reconstructed, or the URL already carries everything. If a link restores the view completely, a bookmark is a saved view and the product does not need its own feature.

Trade-offs

Saved views accumulate and are never pruned, so a year in there are four hundred and nobody knows which are current. They fossilise assumptions, keeping a filter that made sense during a reorganisation two years ago. Shared views create an expectation of maintenance nobody agreed to. And they fragment the shared picture: when everyone has a personal view, an incident call has six people looking at six different filtered realities and describing them as “the dashboard”.

Checklist

  • What exactly is captured—filters, range, sort, columns, expansion?
  • Is an absolute time range saved, and should it be?
  • Can a view be personal, shared or default, and is the difference obvious?
  • Who owns a shared view, and what happens when they leave?
  • What does a view referencing a dimension that no longer exists do?
  • Is a stale view distinguishable from a legitimately empty result?
  • Can views be found and searched once there are hundreds?
  • Is there a way to see what a view actually captured before opening it?
  • Does the URL already do this, making the feature redundant?
  • Is anything pruning views nobody has opened in a year?

Compare

Grafana splits the job across saved dashboards, starred dashboards and variable state carried in the URL, so “saved view” is assembled from three mechanisms rather than existing as one object, and the absolute-versus-relative range trap sits squarely in the middle of it. Sentry makes saved searches first-class with personal and team scoping, which fits a triage product where different roles genuinely need different queues. Honeycomb saves queries and collects them into boards, so the saved unit is a question rather than a page state, which ages better because a question stays meaningful when the data changes. Datadog leans on saved views across several products with team sharing, and shows the accumulation problem clearly at scale.

Filter bar is what a saved view is mostly capturing. Template variable is the Grafana mechanism that holds most of the state. Time-range picker is the part most often saved incorrectly. Share and embed is the adjacent pattern for sending a state to someone else rather than keeping it. Multi-page dashboard is the alternative when the views are stable enough to be pages.

Saved view anatomy A named view listing everything it captured: filters, sort, columns and a relative range, with the absolute range marked as the thing not to keep. Beside it the three ownership tiers, and below, a view whose filter points at a service that no longer exists. What a saved view actually holds Morning triage filters env=prod, sev>=2 sort last seen, desc columns 7 of 14 expanded rows yes range last 24h absolute range not kept 1 Who owns it Personal Cheap, safe, proliferate quietly. Shared Organisational furniture. Needs a name against it. Default What everyone sees first. 2 filters service=legacy-cart no longer exists · 0 rows 3 1 CAPTURE THE RELATIVE ONE A view saved during an incident, with its absolute range, reopens next month still showing that incident. 2 THE DEFAULT IS POLITICAL Whoever controls the state a page opens in controls what everybody sees first. 3 DRIFT A view referencing a service that no longer exists should say so. Returning nothing is indistinguishable from a healthy quiet system.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

The same five filters, every morning, as one click. The part teams get wrong is the unsaved-changes state: a view that silently drifts from what its name says stops being trustworthy within a week.

shadcn
npx shadcn@latest add button toggle-group
Tokens
--card--card-foreground--foreground--muted-foreground--border--status-warn

Morning triage

filters
env=prod, sev>=2
sort
last seen, desc
columns
7 of 14
expanded rows
yes
range
last 24h
absolute range
not kept

Who owns it

Owned by Priya.

Legacy cart errors

filters
service=legacy-cart
sort
count, desc
columns
3 of 14
expanded rows
no
range
last 7d

service=legacy-cart no longer exists · 0 rows

Who owns it

Only you see it.

SavedViewCard.tsxThe view type has no field for an absolute range. The card lists what it captured, checks each filter still resolves, and holds the ownership tier as a control.

import { Button } from "@/components/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { cn } from "@/lib/utils";

/**
 * What a saved view holds, shown before it is opened.
 *
 * The type is the decision about what gets captured. Filters, sort, columns,
 * expansion and a relative range are in it. An absolute range is not, and
 * there is no field to put one in: a view saved during an incident with its
 * absolute range reopens next month still showing that incident. The card
 * says "not kept" on the page so the person opening it knows what it will
 * not restore.
 */
export type Scope = "personal" | "shared" | "default";

export type SavedView = {
  name: string;
  scope: Scope;
  /** A shared or default view needs a name against it. */
  owner: string;
  /** Dimension to value, in the operator syntax the page uses: { sev: ">=2" }. */
  filters: Record<string, string>;
  sort: { by: string; dir: "asc" | "desc" };
  columns: { shown: string[]; of: number };
  expandedRows: boolean;
  /** Relative only. "last 24h" reopens as the last 24 hours from whenever it is opened. */
  range: `last ${string}`;
};

const SCOPES: Record<Scope, { label: string; hint: (v: SavedView) => string }> = {
  personal: { label: "Personal", hint: () => "Only you see it." },
  shared: { label: "Shared", hint: (v) => `Owned by ${v.owner}.` },
  default: { label: "Default", hint: () => "What everyone sees first." },
};

type Props = {
  view: SavedView;
  /** Whether a filter's dimension value still exists in the source. A view
   *  pointed at a service that was deleted should say so; returning nothing
   *  is indistinguishable from a healthy quiet system. */
  exists: (dimension: string, value: string) => boolean;
  /** What the view returns right now. */
  rows: number;
  onOpen: (view: SavedView) => void;
  onScopeChange: (scope: Scope) => void;
};

export function SavedViewCard({ view, exists, rows, onOpen, onScopeChange }: Props) {
  const missing = Object.entries(view.filters).filter(([k, v]) => !exists(k, v));
  const drifted = missing.length > 0;
  const filters = Object.entries(view.filters).map(([k, v]) => `${k}${/^[<>=!]/.test(v) ? v : `=${v}`}`).join(", ");

  const row = (label: string, value: string, flag = false) => (
    <div className="contents">
      <dt className={cn("text-muted-foreground", flag && "text-status-warn")}>{label}</dt>
      <dd className={cn("m-0 text-right text-card-foreground", flag && "text-status-warn")}>{value}</dd>
    </div>
  );

  return (
    <div className={cn("rounded-lg border bg-card p-4", drifted && "border-status-warn")}>
      <div className="flex items-center justify-between border-b pb-2">
        <p className="text-sm text-card-foreground">{view.name}</p>
        <Button size="sm" variant="outline" className="h-6 px-2.5 text-xs" onClick={() => onOpen(view)}>open</Button>
      </div>

      <dl className="mt-3 grid grid-cols-[100px_1fr] gap-x-3 gap-y-2 text-xs">
        {row("filters", filters, drifted)}
        {row("sort", `${view.sort.by}, ${view.sort.dir}`)}
        {row("columns", `${view.columns.shown.length} of ${view.columns.of}`)}
        {row("expanded rows", view.expandedRows ? "yes" : "no")}
        {row("range", view.range)}
      </dl>
      {drifted ? (
        <p className="mt-2 text-[11px] text-status-warn">
          {missing.map(([k, v]) => `${k}=${v}`).join(", ")} no longer exists · {rows} rows
        </p>
      ) : (
        <dl className="mt-2 grid grid-cols-[100px_1fr] gap-x-3 border-t border-dashed pt-2 text-xs">
          {row("absolute range", "not kept", true)}
        </dl>
      )}

      <div className="mt-3 border-t pt-3">
        <p className="text-[11px] text-muted-foreground">Who owns it</p>
        <ToggleGroup
          type="single"
          value={view.scope}
          onValueChange={(s) => s && onScopeChange(s as Scope)}
          aria-label="Who owns it"
          className="mt-1.5 justify-start gap-1"
        >
          {(Object.keys(SCOPES) as Scope[]).map((s) => (
            <ToggleGroupItem key={s} value={s} className="h-6 rounded-md border px-2 text-[11px] data-[state=on]:border-foreground">
              {SCOPES[s].label}
            </ToggleGroupItem>
          ))}
        </ToggleGroup>
        <p className="mt-1.5 text-[11px] text-muted-foreground">{SCOPES[view.scope].hint(view)}</p>
      </div>
    </div>
  );
}

demo.tsxHow it is called: a shared morning view that resolves, and a personal one pointed at a service that no longer exists.

import { useState } from "react";
import { SavedViewCard, type SavedView, type Scope } from "./SavedViewCard";

/**
 * Two views. Morning triage is a shared view that still resolves. The second
 * filters on a service that was decommissioned, and says so instead of
 * quietly returning an empty table.
 */
const SERVICES = ["cart", "checkout", "search", "auth"];
const exists = (dimension: string, value: string) => dimension !== "service" || SERVICES.includes(value);

const MORNING: SavedView = {
  name: "Morning triage",
  scope: "shared",
  owner: "Priya",
  filters: { env: "prod", sev: ">=2" },
  sort: { by: "last seen", dir: "desc" },
  columns: { shown: ["title", "service", "sev", "count", "first seen", "last seen", "owner"], of: 14 },
  expandedRows: true,
  range: "last 24h",
};

const LEGACY: SavedView = {
  name: "Legacy cart errors",
  scope: "personal",
  owner: "Priya",
  filters: { service: "legacy-cart" },
  sort: { by: "count", dir: "desc" },
  columns: { shown: ["title", "count", "last seen"], of: 14 },
  expandedRows: false,
  range: "last 7d",
};

export default function Demo() {
  const [scope, setScope] = useState<Scope>(MORNING.scope);
  const open = (v: SavedView) => { location.hash = `#view/${encodeURIComponent(v.name)}`; };

  return (
    <div className="flex w-[360px] flex-col gap-4">
      <SavedViewCard view={{ ...MORNING, scope }} exists={exists} rows={38} onOpen={open} onScopeChange={setScope} />
      <SavedViewCard view={LEGACY} exists={exists} rows={0} onOpen={open} onScopeChange={() => {}} />
    </div>
  );
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

No captures reference this pattern yet. Captures arrive product by product; see Products for what's in the gallery so far.