Files
app/scripts/seed_example_chart.py
T
stroblmeandClaude Opus 5 e8a818a50b Hold a new dashboard back until someone publishes it
A dashboard went live the moment it was created — an empty document straight to
the panels — while a new flow starts as a draft. It now works the way flows do:
published means `dashboard.json` exists, so every dashboard on every running
installation is already published and nothing needs migrating. Only the ones
created from here on start as drafts.

Mirroring FlowStore turned up a latent 500: discarding the draft of a dashboard
that had never been published unlinked its only file, and the read that followed
raised out of a 200 handler. It answers 400 now, the way a flow does.

Publishing all of them was 2N requests, because a publish has to name the
version it expects and the summaries did not carry one. They do now — and so do
the flow summaries, which had the same defect nobody had written down.

A panel had no way to hear about any of this. A publish, or a change to which
dashboards a panel carries, now puts one event on the bus and the screen
refetches what changed: no reload, so a wall display never blanks or asks for
its credential again. The subtle half is that a socket's message allowlist was
computed once at handshake — a reassigned panel would have fetched its new
document and then shown tiles that never updated.

The panels dialog logged non-superusers out. Every write in it needs a
superuser, not only the checkboxes the report mentioned, so the dialog is
read-only for everyone else. The logout itself was `main.tsx` treating 403 as a
dead session, against the contract deps.py spells out: only a 401 ends a
session, and a 403 now says so rather than silently signing someone out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
2026-08-21 14:32:57 +02:00

341 lines
11 KiB
Python

#!/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):
# 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):
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):
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}?draft=true")
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())