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 1c09b8209d
5 changed files with 61 additions and 16 deletions
+4 -1
View File
@@ -3,7 +3,7 @@
# The workspace root delegates to these (see ../Makefile).
.PHONY: dev-utils dev dev-local up down update install dev-backend dev-frontend \
generate-client test test-backend test-frontend soak lint lint-backend \
generate-client seed-example test test-backend test-frontend soak lint lint-backend \
lint-frontend clean help
COMPOSE_ROOT := $(CURDIR)
@@ -65,6 +65,9 @@ dev-frontend: ## Start the Vite dev server (local)
generate-client: ## Regenerate the frontend SDK from the backend's OpenAPI schema
bash scripts/generate-client.sh
seed-example: ## Seed the querying-chart example (needs a running stack + InfluxDB)
cd backend && uv run python ../scripts/seed_example_chart.py
# ── Testing ───────────────────────────────────────────────────────
test: test-backend test-frontend ## Run all tests (backend + frontend)
+3
View File
@@ -13,6 +13,8 @@ should reopen it.
### To be sorted
- BUG/UI when a dashboard widget is selectd, the border does not cleanly draw on the left side of the widget
- BUG/UI there is currently no option to discard changes (in dashboard or flow viewport); we should make add a "X" button to discard and replace "Publish" by the already existing checkmark button which is either clickable when there are unpublished changes or unclickable when the changes have been applied (but the icon stays). The discard button should only show when there are changes (hidden else)
- INFRA: ensure that all the packages/ dependencies needed to run fluksio are available on arm to make this software runnable on e.g. raspbian
- INFRA: merge the philosophy statement at the beginning of vision.md into the rest of the document. Dissolve the decision dates and fold the decisions into a clean structure
- BUG/UI mobile friendly support is degraded: 1) toolbar in the "Flows" viewport extend mobile viewport width 2) position of nodes should never be static (holds true for desktop as well); always adjust such that there are as few as possible overlaps (of nodes and edge labels) and direction is left to right (desktop) or top to bottom (mobile) with a minimal (but clean) overall edge length. This should also remove the ability to drag nodes around; their position is fixed by an algorithm. This design choice is what enforces small atomic flows (different from nodered) 3) Dashboard view is not mobile friendly at all; as dashboard design is infeasible on mobile, render all widgets in a vertically stacked order. This allows to inspect each widget and make changes. Layout changes are not a feature on mobile 4) the home view is not responsive; all items shown there should re-order on mobile such that no scrollbars appear. Make sure the mobile support is anchored in the design such that future work does not break it
@@ -110,6 +112,7 @@ Decisions taken up front, because most items below depend on them:
an InfluxDB node behind a build/parse pair, with the panel's own range picker
governing the window. The pieces are in and verified against a real bucket;
what is missing is a dashboard someone would actually hang.
- CHORE/UI: `MarkdownWidget`'s docstring claims "headings, bold, code, links, list items"; only headings and bullets are implemented. Either the inline spans or the docstring.
- CHORE/UI: identical in-flight chart requests are deduplicated per browser tab,
so two wall panels showing the same tile still run the query twice. An
`interval` on the request port is the backstop, and it belongs to the flow
+23 -3
View File
@@ -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)
+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">