Resolving and rendering
The resolved form a renderer draws, what its errors and warnings mean, and how to read the lines with one batch read.
GET /dashboards/{id}/resolve and POST /dashboards/resolve return the same thing: the dashboard as a renderer draws it, with iterators expanded, templates filled, the window made concrete, and every line annotated from the series catalog.
{
"id": "c1719097-71d9-468b-a5c1-30fe7ee1ba42",
"name": "Power",
"description": "Forecast against actual production, one chart per site.",
"layout": {"columns": 12},
"window": {"start": "2026-09-30T13:56:02Z", "end": "2026-10-04T13:56:02Z"},
"components": [
{
"key": "0", "type": "chart", "title": "SE3 production", "description": null,
"width": 12, "height": 2, "item": null, "chart": {}, "errors": []
},
{
"key": "1", "type": "group", "title": "Sites", "description": null,
"components": [
{
"key": "1.0", "type": "chart", "title": "Utsira", "description": null, "width": 6, "height": 1,
"item": {"id": "01a07fd7-01bc-7de7-9b63-c0310318cbac", "name": "Utsira", "object": "sites"},
"chart": {
"kind": "line", "stacked": false, "aggregation": "auto", "now_line": true,
"panes": [{"height": 1}],
"axes": [
{"key": "left", "pane": 0, "side": "left", "label": "MW", "min": 0, "max": 1.2, "zero_at": null, "align_zero": null}
],
"series": [
{
"key": "0", "record": "01a07fd7-01bc-7de7-9b63-c0310318cbac", "record_name": "Utsira", "object": "sites",
"timeseries": "power", "column": null, "label": "Actual", "color": "#f5b544",
"kind": "line", "step": false, "dash": "solid", "axis": "left", "scale": 1,
"unit": "MW", "shape": "singleindex", "columns": [], "error": null
},
{
"key": "1", "record": "01a07fd7-01bc-7de7-9b63-c0310318cbac", "record_name": "Utsira", "object": "sites",
"timeseries": "power-forecast", "column": "power", "label": "Forecast", "color": "#34d399",
"kind": "line", "step": false, "dash": "solid", "axis": "left", "scale": 1,
"unit": "MW", "shape": "multiindex", "columns": ["power"], "error": null
}
]
},
"errors": []
}
]
}
],
"warnings": [],
"resolved_at": "2026-10-02T13:56:02Z"
}What resolution does
In order: expands each group's iterator into items; builds one card per item from the template, rendering templates against the item; looks up every record once; reads the series catalog for every record and timeseries pair and annotates each line with unit, shape, and columns, or sets error when the type or column is not there; fills default labels and palette colours; computes the window against resolved_at; evaluates templated axis bounds and fills axis labels from units.
- Keys. A standalone chart's key is its index in
components("0"). A group's key is its index; its cards are"<index>.<item>". A series key is its index inchart.series. itemis the iterated record for a card inside a group,nullotherwise.errorson a card are mistakes in the document for that card: an unknown template variable, an axis bound that did not evaluate to a number. The web app shows these under the card.erroron a line explains why it cannot be read:record ... not found,no timeseries 'x' on this record,no column 'p50' on 'forecast' (has p10, p50, p90),'...' is not a record id. The web app skips such lines quietly, since a record simply lacking a series is normal when iterating; the response keeps them for authors.warningsare dashboard-level: an iterator that matched nothing, an unknown object type in an iterator, items or cards dropped by a limit.windowis the document's opening range made concrete, ornullwhen the document has none (the renderer then defaults to the last seven days).- Axes come back as a list in drawing order, with
labelfilled from the units of the series drawn on each and templated bounds evaluated to numbers.
Reading the data
The resolved form is a manifest for POST /timeseries/read (Many series at once): one entry per line without an error, read over one range.
window = resolved["window"] or {"start": week_ago, "end": now}
cards = [c for x in resolved["components"] for c in (x["components"] if x["type"] == "group" else [x])]
entries = [
{"record": s["record"], "type": s["timeseries"], **({"name": s["column"]} if s["column"] else {})}
for c in cards for s in c["chart"]["series"] if s["error"] is None
]
data = requests.post(f"{BASE}/timeseries/read", headers=HEADERS, json={
"series": entries,
"start": window["start"], "end": window["end"],
"max_points": 2000, # or "bucket_seconds": 3600 for a fixed bucket
"format": "columns", # times + one value list per column
"on_missing": "skip",
}).json()Items come back with the entry index, so the i-th entry's values draw the i-th line. A whole-type entry (no name) returns one value list per column. Multiply by the line's scale before drawing.
The web app sends one such request per card, only while the card is near the viewport, and re-sends when the viewer changes the range or the aggregation. All cards share one time range, so the x axis is pinned to it.