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:
@@ -29,6 +29,22 @@ function token(name: string): string {
|
||||
|
||||
const seriesColor = (index: number) => token(`--chart-${(index % 5) + 1}`)
|
||||
|
||||
/**
|
||||
* Tick labels that stay distinct.
|
||||
*
|
||||
* `si` shortens to three significant figures, which is what a curve spanning
|
||||
* decades wants and the opposite of what one wobbling inside a degree does —
|
||||
* 18.9 and 19.1 both read "19", and the axis says nothing at all. When the
|
||||
* short labels would repeat, the tick spacing decides the decimals instead.
|
||||
*/
|
||||
function tickLabels(ticks: number[]): string[] {
|
||||
const short = ticks.map((value) => si(value))
|
||||
if (new Set(short).size === short.length) return short
|
||||
const step = Math.abs(ticks[1] - ticks[0]) || 1
|
||||
const decimals = Math.min(6, Math.max(0, Math.ceil(-Math.log10(step))))
|
||||
return ticks.map((value) => value.toFixed(decimals))
|
||||
}
|
||||
|
||||
/** The series joined onto one x axis, which is what uPlot draws. */
|
||||
function table(plots: HistoryPoint[][]): uPlot.AlignedData {
|
||||
return uPlot.join(
|
||||
@@ -160,7 +176,9 @@ export function UplotChart({
|
||||
// The gutter has a fixed width, so a grouped "15,000" would be
|
||||
// clipped to something that reads as a different number entirely.
|
||||
values: (_self: uPlot, ticks: number[]) =>
|
||||
ticks.map((value) => (unit ? `${si(value)} ${unit}` : si(value))),
|
||||
tickLabels(ticks).map((label) =>
|
||||
unit ? `${label} ${unit}` : label,
|
||||
),
|
||||
},
|
||||
],
|
||||
series: [
|
||||
@@ -172,9 +190,11 @@ export function UplotChart({
|
||||
// rebuilt chart.
|
||||
stroke: () => seriesColor(index),
|
||||
// The cursor readout is what decides how wide the legend gets, so
|
||||
// it is shortened here; a named unit is short enough to keep.
|
||||
// it is shortened here; a named unit is short enough to keep. Four
|
||||
// figures rather than three: this is the number someone is pointing
|
||||
// at to read, and 18.97 rounded to "19" is not an answer.
|
||||
value: (_self: uPlot, raw: number) =>
|
||||
unit ? `${si(raw)} ${unit}` : si(raw),
|
||||
unit ? `${si(raw, 4)} ${unit}` : si(raw, 4),
|
||||
// Series arrive on their own clocks; a joined table is mostly
|
||||
// holes, and a line with a hole per point is not a line.
|
||||
spanGaps: true,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user