dashboard: charts that query
A chart can now ask for what it draws instead of reading the ring the engine keeps. It publishes a request — the window and the resolution — exactly as a slider publishes a value, and draws the series a flow answers with. What serves the request is the flow's business, so the widget never learns which database was behind it. The answer says what it was computed for and one computed for another window is ignored, so two charts on one node cost a duplicate query rather than overwriting each other's picture. Identical requests still in flight are asked once per tab, and the refresh has a floor under it. The panel gains the presentation the document could already hold: the per-series label, a unit, and a y axis that can be pinned. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { Plus, X } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
import type { MessageInfo, WidgetDef } from "@/client"
|
||||
import { DEFAULT_RANGE, RANGES } from "@/components/Common/RangePicker"
|
||||
import {
|
||||
PANEL_SECTION,
|
||||
PanelTitle,
|
||||
@@ -26,7 +27,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { MAX_SERIES } from "./ChartWidget"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { MAX_SERIES, MIN_REFRESH_S } from "./ChartWidget"
|
||||
import {
|
||||
CANVAS_PRESETS,
|
||||
COLUMN_CHOICES,
|
||||
@@ -67,16 +69,21 @@ function MessagePicker({
|
||||
value,
|
||||
label,
|
||||
testId,
|
||||
filter,
|
||||
onPick,
|
||||
}: {
|
||||
kind: WidgetKind
|
||||
value: string
|
||||
label: string
|
||||
testId?: string
|
||||
/** What this slot takes, when the widget's own type does not decide it —
|
||||
* a querying chart asks with one shape and draws another. */
|
||||
filter?: (message: MessageInfo) => boolean
|
||||
onPick: (message: string, dtype: string) => void
|
||||
}) {
|
||||
const { data } = useQuery(messageCatalogQueryOptions())
|
||||
const choices = choicesFor(kind, data?.data ?? [])
|
||||
const catalog = data?.data ?? []
|
||||
const choices = filter ? catalog.filter(filter) : choicesFor(kind, catalog)
|
||||
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
@@ -105,6 +112,50 @@ function MessagePicker({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a chart's lines come from: what the engine kept, or what it asks for.
|
||||
*
|
||||
* The one segmented shape — a single border pill, transparent segments,
|
||||
* bg-accent on the selected one (root DESIGN-GUIDELINES.md).
|
||||
*/
|
||||
function ModePicker({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: "live" | "query"
|
||||
onChange: (mode: "live" | "query") => void
|
||||
}) {
|
||||
return (
|
||||
<fieldset
|
||||
data-testid="chart-source"
|
||||
className="flex w-fit items-center gap-1 rounded-full border border-border p-1"
|
||||
>
|
||||
<legend className="sr-only">Where the chart's data comes from</legend>
|
||||
{(
|
||||
[
|
||||
["live", "Live"],
|
||||
["query", "Query"],
|
||||
] as const
|
||||
).map(([mode, label]) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
aria-pressed={value === mode}
|
||||
onClick={() => onChange(mode)}
|
||||
className={cn(
|
||||
"rounded-full px-2.5 py-1 text-xs transition-colors",
|
||||
value === mode
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What one widget shows or does.
|
||||
*
|
||||
@@ -132,6 +183,7 @@ export function WidgetPanel({
|
||||
|
||||
const series = seriesOf(widget)
|
||||
const setSeries = (next: Series[]) => set({ series: next })
|
||||
const querying = cfg.source === "query"
|
||||
|
||||
return (
|
||||
<SidePanel
|
||||
@@ -179,53 +231,101 @@ export function WidgetPanel({
|
||||
/>
|
||||
</div>
|
||||
) : widget.type === "chart" ? (
|
||||
<div className="grid gap-2">
|
||||
{series.map((entry, index) => (
|
||||
<div
|
||||
// Position is the only identity a series row has.
|
||||
key={`series-${index}`}
|
||||
className="flex items-end gap-1.5"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<MessagePicker
|
||||
kind="chart"
|
||||
value={entry.message ?? ""}
|
||||
label={index === 0 ? "Draws" : ""}
|
||||
testId={index === 0 ? "widget-message" : undefined}
|
||||
onPick={(message, dtype) =>
|
||||
setSeries(
|
||||
series.map((other, at) =>
|
||||
at === index ? { ...other, message, dtype } : other,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
aria-label="Remove series"
|
||||
onClick={() =>
|
||||
setSeries(series.filter((_, at) => at !== index))
|
||||
<div className="grid gap-3">
|
||||
<ModePicker
|
||||
value={querying ? "query" : "live"}
|
||||
onChange={(source) => set({ source })}
|
||||
/>
|
||||
{querying ? (
|
||||
<div className="grid gap-2">
|
||||
<MessagePicker
|
||||
kind="chart"
|
||||
value={str(cfg.request)}
|
||||
label="Asks"
|
||||
testId="widget-request"
|
||||
filter={(message) =>
|
||||
message.dtype === "record" && message.writable !== false
|
||||
}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
onPick={(request, request_dtype) =>
|
||||
set({ request, request_dtype })
|
||||
}
|
||||
/>
|
||||
<MessagePicker
|
||||
kind="chart"
|
||||
value={str(cfg.message)}
|
||||
label="Draws"
|
||||
testId="widget-message"
|
||||
filter={(message) => message.dtype === "series"}
|
||||
onPick={(message, dtype) => set({ message, dtype })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{series.length < MAX_SERIES ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 justify-self-start"
|
||||
onClick={() => setSeries([...series, {}])}
|
||||
data-testid="add-series"
|
||||
>
|
||||
<Plus />
|
||||
Add series
|
||||
</Button>
|
||||
) : null}
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{series.map((entry, index) => (
|
||||
<div
|
||||
// Position is the only identity a series row has.
|
||||
key={`series-${index}`}
|
||||
className="flex items-end gap-1.5"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<MessagePicker
|
||||
kind="chart"
|
||||
value={entry.message ?? ""}
|
||||
label={index === 0 ? "Draws" : ""}
|
||||
testId={index === 0 ? "widget-message" : undefined}
|
||||
onPick={(message, dtype) =>
|
||||
setSeries(
|
||||
series.map((other, at) =>
|
||||
at === index
|
||||
? { ...other, message, dtype }
|
||||
: other,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
className="w-28"
|
||||
value={entry.label ?? ""}
|
||||
placeholder="Label"
|
||||
aria-label="Series label"
|
||||
onChange={(event) =>
|
||||
setSeries(
|
||||
series.map((other, at) =>
|
||||
at === index
|
||||
? { ...other, label: event.target.value }
|
||||
: other,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
aria-label="Remove series"
|
||||
onClick={() =>
|
||||
setSeries(series.filter((_, at) => at !== index))
|
||||
}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{series.length < MAX_SERIES ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 justify-self-start"
|
||||
onClick={() => setSeries([...series, {}])}
|
||||
data-testid="add-series"
|
||||
>
|
||||
<Plus />
|
||||
Add series
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<MessagePicker
|
||||
@@ -240,7 +340,7 @@ export function WidgetPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{widget.type === "chart" ? (
|
||||
{widget.type === "chart" && !querying ? (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm font-normal">Points kept</Label>
|
||||
<Input
|
||||
@@ -258,7 +358,105 @@ export function WidgetPanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{widget.type === "stat" || widget.type === "gauge" ? (
|
||||
{widget.type === "chart" && querying ? (
|
||||
<>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm font-normal">Refresh, seconds</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={MIN_REFRESH_S}
|
||||
value={str(cfg.refresh_s ?? 60)}
|
||||
onChange={(event) =>
|
||||
set({ refresh_s: Number(event.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
How often it asks again. {MIN_REFRESH_S} seconds is the floor —
|
||||
someone has to run the query.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm font-normal">Range shown first</Label>
|
||||
<Select
|
||||
value={str(cfg.range_s ?? DEFAULT_RANGE.hours * 3600)}
|
||||
onValueChange={(value) => set({ range_s: Number(value) })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{RANGES.map((range) => (
|
||||
<SelectItem
|
||||
key={range.label}
|
||||
value={String(range.hours * 3600)}
|
||||
>
|
||||
{range.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A viewer can pick another on the widget itself.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{widget.type === "agenda" ? (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm font-normal">Items shown</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={str(cfg.count ?? 5)}
|
||||
onChange={(event) =>
|
||||
set({ count: Number(event.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{widget.type === "chart" ? (
|
||||
<div className="flex gap-2">
|
||||
<div className="grid flex-1 gap-1.5">
|
||||
<Label className="text-sm font-normal">Y minimum</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={str(cfg.y_min ?? "")}
|
||||
placeholder="auto"
|
||||
onChange={(event) =>
|
||||
set({
|
||||
y_min:
|
||||
event.target.value === ""
|
||||
? undefined
|
||||
: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid flex-1 gap-1.5">
|
||||
<Label className="text-sm font-normal">Y maximum</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={str(cfg.y_max ?? "")}
|
||||
placeholder="auto"
|
||||
onChange={(event) =>
|
||||
set({
|
||||
y_max:
|
||||
event.target.value === ""
|
||||
? undefined
|
||||
: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{widget.type === "stat" ||
|
||||
widget.type === "gauge" ||
|
||||
widget.type === "chart" ||
|
||||
widget.type === "slider" ? (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm font-normal">Unit</Label>
|
||||
<Input
|
||||
@@ -291,6 +489,20 @@ export function WidgetPanel({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{widget.type === "slider" ? (
|
||||
<div className="grid flex-1 gap-1.5">
|
||||
<Label className="text-sm font-normal">Step</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={str(cfg.step ?? 1)}
|
||||
onChange={(event) =>
|
||||
set({ step: Number(event.target.value) || 1 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user