Dashboard builder 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. npx shadcn@latest add card button dropdown-menu textarea toggle-group collapsible input npm i recharts lucide-react Tokens this needs: --card, --card-foreground, --muted, --muted-foreground, --border, --chart-1 ──────────────────────────────────────────────────────────────────────── // PanelEditor.tsx ──────────────────────────────────────────────────────────────────────── 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 = { 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; 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

{last}

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

preview

Query

{sources.map((s) => {s})}