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
+4 -1
View File
@@ -3,7 +3,7 @@
# The workspace root delegates to these (see ../Makefile). # The workspace root delegates to these (see ../Makefile).
.PHONY: dev-utils dev dev-local up down update install dev-backend dev-frontend \ .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 lint-frontend clean help
COMPOSE_ROOT := $(CURDIR) 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 generate-client: ## Regenerate the frontend SDK from the backend's OpenAPI schema
bash scripts/generate-client.sh 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 ─────────────────────────────────────────────────────── # ── Testing ───────────────────────────────────────────────────────
test: test-backend test-frontend ## Run all tests (backend + frontend) test: test-backend test-frontend ## Run all tests (backend + frontend)
+3
View File
@@ -13,6 +13,8 @@ should reopen it.
### To be sorted ### 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: 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 - 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 - 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 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; governing the window. The pieces are in and verified against a real bucket;
what is missing is a dashboard someone would actually hang. 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, - 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 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 `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}`) 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. */ /** The series joined onto one x axis, which is what uPlot draws. */
function table(plots: HistoryPoint[][]): uPlot.AlignedData { function table(plots: HistoryPoint[][]): uPlot.AlignedData {
return uPlot.join( return uPlot.join(
@@ -160,7 +176,9 @@ export function UplotChart({
// The gutter has a fixed width, so a grouped "15,000" would be // The gutter has a fixed width, so a grouped "15,000" would be
// clipped to something that reads as a different number entirely. // clipped to something that reads as a different number entirely.
values: (_self: uPlot, ticks: number[]) => values: (_self: uPlot, ticks: number[]) =>
ticks.map((value) => (unit ? `${si(value)} ${unit}` : si(value))), tickLabels(ticks).map((label) =>
unit ? `${label} ${unit}` : label,
),
}, },
], ],
series: [ series: [
@@ -172,9 +190,11 @@ export function UplotChart({
// rebuilt chart. // rebuilt chart.
stroke: () => seriesColor(index), stroke: () => seriesColor(index),
// The cursor readout is what decides how wide the legend gets, so // 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) => 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 // Series arrive on their own clocks; a joined table is mostly
// holes, and a line with a hole per point is not a line. // holes, and a line with a hole per point is not a line.
spanGaps: true, spanGaps: true,
@@ -20,12 +20,16 @@ export { MAX_SERIES }
const DEFAULT_POINTS = 300 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 * A window is drawn in buckets, and nothing the chart can show changes until
* allowed to set it to nothing. * 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. */ /** How long an unanswered request blocks an identical one. */
const INFLIGHT_MS = 15_000 const INFLIGHT_MS = 15_000
@@ -197,8 +201,6 @@ function QueryChart({ widget, dashboard }: WidgetProps) {
const cfg = config(widget) const cfg = config(widget)
const request = String(cfg.request ?? "") const request = String(cfg.request ?? "")
const message = String(cfg.message ?? "") const message = String(cfg.message ?? "")
const refreshS = Math.max(MIN_REFRESH_S, Number(cfg.refresh_s) || 60)
const [range, setRange] = useState<Range>( const [range, setRange] = useState<Range>(
() => () =>
RANGES.find((r) => r.hours * 3600 === Number(cfg.range_s)) ?? 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 rangeS = range.hours * 3600
const intervalS = range.bucketS 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 publish = usePublishMessage()
const live = useLiveValue(message || undefined) const live = useLiveValue(message || undefined)
+20 -6
View File
@@ -28,7 +28,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { MAX_SERIES, MIN_REFRESH_S } from "./ChartWidget" import { MAX_SERIES, refreshFor } from "./ChartWidget"
import { import {
CANVAS_PRESETS, CANVAS_PRESETS,
COLUMN_CHOICES, COLUMN_CHOICES,
@@ -184,6 +184,13 @@ export function WidgetPanel({
const series = seriesOf(widget) const series = seriesOf(widget)
const setSeries = (next: Series[]) => set({ series: next }) const setSeries = (next: Series[]) => set({ series: next })
const querying = cfg.source === "query" 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 ( return (
<SidePanel <SidePanel
@@ -364,15 +371,22 @@ export function WidgetPanel({
<Label className="text-sm font-normal">Refresh, seconds</Label> <Label className="text-sm font-normal">Refresh, seconds</Label>
<Input <Input
type="number" type="number"
min={MIN_REFRESH_S} min={paced}
value={str(cfg.refresh_s ?? 60)} placeholder={str(paced)}
value={str(cfg.refresh_s ?? "")}
onChange={(event) => 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"> <p className="text-xs text-muted-foreground">
How often it asks again. {MIN_REFRESH_S} seconds is the floor — Empty follows the window — {paced} seconds at this range, since
someone has to run the query. nothing changes until the bucket closes. A slower one is kept, a
faster one only asks for the same picture twice.
</p> </p>
</div> </div>
<div className="grid gap-1.5"> <div className="grid gap-1.5">
+340
View File
@@ -0,0 +1,340 @@
#!/usr/bin/env python
"""Seed the querying-chart example: two flows, a dashboard, and data to draw.
What it builds, and why it is split the way it is:
climate_log inject -> sample -> influxdb writes the measurements
climate_chart build -> influxdb -> parse answers a chart's request
climate a dashboard with one querying chart
The reading half is the point. A chart publishes ``{range_s, interval_s}`` and
draws the ``series`` that comes back; between the two sit a Python node that
turns the window into Flux and another that turns rows into lines. The database
node only holds the credentials and runs what it is handed, so the widget never
learns it was InfluxDB — swapping in Postgres means rewriting those two Python
nodes and nothing else.
The writing half exists so the chart has something to show. It samples every
30 seconds; delete ``climate_log`` when you are done evaluating.
Run it against a stack that is already up::
make -C app seed-example
Environment (the Makefile passes these):
API_URL, FIRST_SUPERUSER, FIRST_SUPERUSER_PASSWORD
INFLUX_URL, INFLUX_ORG, INFLUX_BUCKET, INFLUX_TOKEN
"""
from __future__ import annotations
import os
import sys
from typing import Any
import httpx
API = os.environ.get("API_URL", "http://api.localhost")
EMAIL = os.environ.get("FIRST_SUPERUSER", "")
PASSWORD = os.environ.get("FIRST_SUPERUSER_PASSWORD", "")
INFLUX_URL = os.environ.get("INFLUX_URL", "http://influxdb:8086")
INFLUX_ORG = os.environ.get("INFLUX_ORG", "fluksio")
INFLUX_BUCKET = os.environ.get("INFLUX_BUCKET", "fluksio")
INFLUX_TOKEN = os.environ.get("INFLUX_TOKEN", "")
SECRET = "influx_eval_token"
LOG_FLOW = "climate_log"
CHART_FLOW = "climate_chart"
PANEL = "climate"
MEASUREMENT = "climate"
FIELD = "temperature"
SAMPLE_SOURCE = '''"""A plausible indoor temperature, so the example has a curve to draw."""
import math
import time
def process(tick, params):
# A slow daily swing plus a faster one, so any window shows some shape.
now = time.time()
daily = 3.0 * math.sin(now / 86400.0 * 2 * math.pi)
churn = 0.4 * math.sin(now / 900.0 * 2 * math.pi)
return {"temperature": round(20.5 + daily + churn, 2)}
'''
BUILD_SOURCE = f'''"""Turn a chart's window into Flux. This is the database-specific half."""
def process(chart_request, params):
span = int(chart_request["range_s"])
every = int(chart_request["interval_s"])
flux = "\\n".join(
[
'from(bucket: "{INFLUX_BUCKET}")',
f" |> range(start: -{{span}}s)",
' |> filter(fn: (r) => r["_measurement"] == "{MEASUREMENT}")',
' |> filter(fn: (r) => r["_field"] == "{FIELD}")',
f" |> aggregateWindow(every: {{every}}s, fn: mean, createEmpty: false)",
]
)
# Everything beside "flux" is echoed back by the node, and the widget
# checks it against what it asked for — so it has to travel with the query.
return {{
"query": {{
"flux": flux,
"range_s": chart_request["range_s"],
"interval_s": chart_request["interval_s"],
}}
}}
'''
PARSE_SOURCE = '''"""Turn rows into the series a chart draws. Nothing here is InfluxDB-specific."""
def process(rows, params):
points = [
[row["ts"], float(row["value"])]
for row in rows["rows"]
if row.get("ts") is not None and row.get("value") is not None
]
return {
"temperature_series": {
# The echo the widget matches against its own request.
"range_s": rows["range_s"],
"interval_s": rows["interval_s"],
"lines": [{"label": "Indoor", "points": points}],
}
}
'''
def influx_params() -> dict[str, Any]:
"""Credentials for a database node, with the token kept out of the flow."""
return {
"url": INFLUX_URL,
"token": {"$secret": SECRET},
"org": INFLUX_ORG,
"bucket": INFLUX_BUCKET,
}
LOG_NODES = [
{
"id": "every_30s",
"type": "inject",
"title": "Every 30 seconds",
"position": {"x": 40, "y": 80},
"params": {"interval": 30, "at_start": True, "payload": 1},
"requires": [],
"provides": [{"name": "tick", "dtype": "float"}],
},
{
"id": "sample",
"type": "python",
"title": "Read the room",
"position": {"x": 340, "y": 80},
"requires": [{"name": "tick", "dtype": "float"}],
"provides": [{"name": "temperature", "dtype": "float"}],
},
{
"id": "store",
"type": "influxdb",
"title": "Write to InfluxDB",
"position": {"x": 640, "y": 80},
"params": {
**influx_params(),
"writes": {
"temperature": {"measurement": MEASUREMENT, "field": FIELD},
},
},
"requires": [{"name": "temperature", "dtype": "float"}],
"provides": [],
},
]
CHART_NODES = [
{
"id": "build",
"type": "python",
"title": "Window to Flux",
"position": {"x": 320, "y": 80},
"requires": [{"name": "chart_request", "dtype": "record"}],
"provides": [{"name": "query", "dtype": "record"}],
},
{
"id": "read",
"type": "influxdb",
"title": "Run the query",
"position": {"x": 680, "y": 80},
"params": influx_params(),
"requires": [{"name": "query", "dtype": "record"}],
"provides": [{"name": "rows", "dtype": "json"}],
},
{
"id": "parse",
"type": "python",
"title": "Rows to a series",
"position": {"x": 1040, "y": 80},
"requires": [{"name": "rows", "dtype": "json"}],
"provides": [{"name": "temperature_series", "dtype": "series"}],
},
]
WIDGETS = [
{
"id": "indoor",
"type": "chart",
"title": "Indoor temperature",
"layout": {"lg": {"x": 0, "y": 0, "w": 8, "h": 5}},
"config": {
"source": "query",
"request": f"{CHART_FLOW}.chart_request",
"request_dtype": "record",
"message": f"{CHART_FLOW}.temperature_series",
"dtype": "series",
"range_s": 3600,
"unit": "°C",
},
},
{
"id": "how",
"type": "markdown",
"title": "",
"layout": {"lg": {"x": 8, "y": 0, "w": 4, "h": 5}},
"config": {
# The widget renders headings and bullets, nothing inline.
"content": (
"## How this works\n"
"- The chart publishes a request: the window and the"
" resolution it wants.\n"
"- climate_chart turns that into Flux, runs it, and answers"
" with a series.\n"
"- The answer says which window it was computed for, and the"
" chart ignores one that does not match.\n"
"- Nothing in the widget knows it was InfluxDB. Swapping the"
" database means rewriting two Python nodes.\n"
"- climate_log writes a sample every 30 s. Delete that flow"
" when you are done evaluating."
)
},
},
]
class Api:
def __init__(self) -> None:
self.http = httpx.Client(base_url=f"{API}/api/v1", timeout=30)
token = self.http.post(
"/login/access-token",
data={"username": EMAIL, "password": PASSWORD},
).json()["access_token"]
self.http.headers["Authorization"] = f"Bearer {token}"
def __call__(self, method: str, path: str, body: Any = None) -> Any:
response = self.http.request(method, path, json=body)
response.raise_for_status()
return response.json() if response.content else None
def seed_flow(
api: Api,
name: str,
title: str,
nodes: list,
sources: dict,
inputs: list | None = None,
) -> None:
"""Write a flow and publish it, replacing whatever was there before."""
try:
api("DELETE", f"/flows/{name}")
except httpx.HTTPStatusError:
pass
api(
"PUT",
f"/flows/{name}",
{"name": name, "title": title, "nodes": nodes, "inputs": inputs or []},
)
for node_id, code in sources.items():
api("PUT", f"/flows/{name}/nodes/{node_id}/source", {"code": code})
version = api("GET", f"/flows/{name}?draft=true")["definition"]["version"]
api("POST", f"/flows/{name}/publish", {"version": version})
print(f" {name}: {len(nodes)} nodes, published")
def main() -> int:
if not EMAIL or not PASSWORD:
print("FIRST_SUPERUSER / FIRST_SUPERUSER_PASSWORD are unset.", file=sys.stderr)
return 1
if not INFLUX_TOKEN:
print(
"INFLUX_TOKEN is unset — set it to a token that can read and write "
f"the '{INFLUX_BUCKET}' bucket.",
file=sys.stderr,
)
return 1
api = Api()
# The flows reference the token by name, so it never sits in the document.
api("PUT", f"/secrets/{SECRET}", {"value": INFLUX_TOKEN})
print(f" secret '{SECRET}' set")
seed_flow(
api,
LOG_FLOW,
"Climate log (evaluation)",
LOG_NODES,
{"sample": SAMPLE_SOURCE},
)
seed_flow(
api,
CHART_FLOW,
"Climate chart (evaluation)",
CHART_NODES,
{"build": BUILD_SOURCE, "parse": PARSE_SOURCE},
# The request arrives from the panel, not from a node upstream. Saying
# so is what stops the canvas reporting `build` as waiting on something
# nothing provides — a flow declares what reaches it from outside. The
# initial value is the widget's own default window, so the flow has an
# answer ready before anyone opens the dashboard.
inputs=[
{
"spec": {"name": "chart_request", "dtype": "record"},
"initial": {"range_s": 3600, "interval_s": 60},
}
],
)
try:
api("DELETE", f"/dashboards/{PANEL}")
except httpx.HTTPStatusError:
pass
api("POST", f"/dashboards/{PANEL}", {"name": PANEL, "title": "Climate"})
current = api("GET", f"/dashboards/{PANEL}")
api(
"PUT",
f"/dashboards/{PANEL}",
{
**current,
"pages": [
{
"id": "main",
"title": "Overview",
"sections": [{"id": "main", "widgets": WIDGETS}],
}
],
},
)
version = api("GET", f"/dashboards/{PANEL}?draft=true")["version"]
api("POST", f"/dashboards/{PANEL}/publish", {"version": version})
print(f" dashboard '{PANEL}': published")
print(f"\nOpen it at /view/{PANEL} — the first samples land within 30 s.")
return 0
if __name__ == "__main__":
raise SystemExit(main())