dashboard: pace a querying chart by its window, and an example to evaluate it

A chart is drawn in buckets, and nothing it can show changes until the
bucket it is drawing closes — so the resolution sets the refresh rather
than a flat five-second floor. A week at quarter-hour buckets now asks
four times an hour instead of sixty, for the same picture. Leaving the
field empty follows the window; a slower rate is still honoured.

`make seed-example` builds the thing to evaluate it with: a flow that
logs a temperature to InfluxDB, a flow that answers a chart's request by
turning the window into Flux and the rows back into a series, and a
dashboard holding the chart. The reading flow declares the request as an
input with a starting value, which is how a flow says a value reaches it
from a panel rather than from a node upstream.

Axis labels keep enough decimals to stay distinct — `si` rounds to three
figures, so every tick of a chart living inside one degree read "19".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 15:34:32 +02:00
co-authored by Claude Opus 5
parent 61bfd68f26
commit e7a1466d7b
6 changed files with 401 additions and 16 deletions
@@ -20,12 +20,16 @@ export { MAX_SERIES }
const DEFAULT_POINTS = 300
/**
* How often a chart may ask, at the fastest.
* How often a chart asks again, for the window it is showing.
*
* A refresh interval is a query someone else has to run, so the panel is not
* allowed to set it to nothing.
* A window is drawn in buckets, and nothing the chart can show changes until
* the bucket it is drawing closes — so the resolution sets the pace. A week at
* quarter-hour buckets asks four times an hour instead of twice a minute, for
* the same picture. A slower refresh than that is honoured; a faster one only
* buys the same answer again, so it is clamped.
*/
export const MIN_REFRESH_S = 5
export const refreshFor = (range: Range, configured: unknown) =>
Math.max(range.bucketS, Number(configured) || 0)
/** How long an unanswered request blocks an identical one. */
const INFLIGHT_MS = 15_000
@@ -197,8 +201,6 @@ function QueryChart({ widget, dashboard }: WidgetProps) {
const cfg = config(widget)
const request = String(cfg.request ?? "")
const message = String(cfg.message ?? "")
const refreshS = Math.max(MIN_REFRESH_S, Number(cfg.refresh_s) || 60)
const [range, setRange] = useState<Range>(
() =>
RANGES.find((r) => r.hours * 3600 === Number(cfg.range_s)) ??
@@ -206,6 +208,9 @@ function QueryChart({ widget, dashboard }: WidgetProps) {
)
const rangeS = range.hours * 3600
const intervalS = range.bucketS
// Follows the window: a wider one is drawn coarser, so it is worth asking
// about less often.
const refreshS = refreshFor(range, cfg.refresh_s)
const publish = usePublishMessage()
const live = useLiveValue(message || undefined)
+20 -6
View File
@@ -28,7 +28,7 @@ import {
SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils"
import { MAX_SERIES, MIN_REFRESH_S } from "./ChartWidget"
import { MAX_SERIES, refreshFor } from "./ChartWidget"
import {
CANVAS_PRESETS,
COLUMN_CHOICES,
@@ -184,6 +184,13 @@ export function WidgetPanel({
const series = seriesOf(widget)
const setSeries = (next: Series[]) => set({ series: next })
const querying = cfg.source === "query"
// What this chart would refresh at with nothing configured. The viewer can
// pick another window on the widget, which moves it.
const paced = refreshFor(
RANGES.find((range) => range.hours * 3600 === Number(cfg.range_s)) ??
DEFAULT_RANGE,
0,
)
return (
<SidePanel
@@ -364,15 +371,22 @@ export function WidgetPanel({
<Label className="text-sm font-normal">Refresh, seconds</Label>
<Input
type="number"
min={MIN_REFRESH_S}
value={str(cfg.refresh_s ?? 60)}
min={paced}
placeholder={str(paced)}
value={str(cfg.refresh_s ?? "")}
onChange={(event) =>
set({ refresh_s: Number(event.target.value) || 0 })
set({
refresh_s:
event.target.value === ""
? undefined
: Number(event.target.value),
})
}
/>
<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.
Empty follows the window — {paced} seconds at this range, since
nothing changes until the bucket closes. A slower one is kept, a
faster one only asks for the same picture twice.
</p>
</div>
<div className="grid gap-1.5">