Dashboard datasets, metrics, and the clock
Declare a dataset's shape once, define new KPIs as SQL instead of a widget, and add clock-driven playback to a dashboard.
Three optional layers under [[widgets]]
Every dashboard.toml has [[widgets]] — see Widgets
for the full, always-current reference of every widget type and its fields.
Three more
top-level sections are optional, and build on each other:
[[datasets]]— names the shape of an array in your run'soutput.jsononce, instead of restating it in every widget that reads it.[[metrics]]— a single read-only SQL statement over your datasets. A new KPI is a query, not a new widget.[clock]— turns oneintervalsdataset into a play/pause/scrub transport bar above the dashboard.
None of the three are required. A dashboard.toml with only [[widgets]] —
every project's starter template, and every dashboard written before this
existed — keeps rendering exactly as before.
Datasets
[[datasets]]
id = "schedule"
kind = "intervals"
source_key = "schedule"
time_unit = "h"
[datasets.roles]
resource = "machine"
label = "task"
start = "start_h"
end = "end_h"id— unique within the dashboard. What a metric'sFROMclause, and the[clock]section, refer to it by.kind— one ofintervals,timeline,records, orscalars. Picks which roles are required (below) and how a metric can query it.source_key— dot-path to the array inoutput.jsonthis dataset reads.time_unit— howstart/end/duration/atvalues (below) are read: a plain display suffix (e.g."h"or"min", the default reading — bare numbers with that unit), or a wall-clock sentinel —"epoch"(seconds since epoch),"epoch_ms"(milliseconds), or"iso"(an ISO 8601 string) — rendered as a real date/time and used to scale a[clock]section's playback speed.[datasets.roles]— which column plays which part. Every role is a dot-path field name inside one row of the source array, not a dot-path into the whole payload.
| Kind | Needs | Optional |
|---|---|---|
intervals | start, and either end or duration | resource, label, category |
timeline | value, and either at or start | label, category |
records | — | any role; a table binds to this kind with none set |
scalars | — | value |
A dataset missing a role its kind needs is reported in config.errors and
skipped, rather than producing a view that silently renders blank — the same
"loud failure" every other part of a dashboard spec follows.
Every widget already has one
Declaring [[datasets]] is optional because every existing widget type
(table, gantt-chart, chart, …) already implies one, derived from its own
inline keys — a gantt-chart's tasks_key/resource_key/start_key/…
becomes an intervals dataset automatically. Declaring one explicitly only
matters once you want a [[metrics]] query or a [clock] to reference it by
name — a widget's own inline keys still work standalone.
Metrics
[[metrics]]
id = "per_machine"
title = "Load per machine"
sql = """
SELECT machine, count(*) AS tasks, sum(end_h - start_h) AS busy_hours
FROM schedule
GROUP BY machine
ORDER BY machine
"""Each declared dataset is loaded as a table named after its id — schedule
above — so a metric's SQL reads your datasets the way it would read any other
table. This runs in a DuckDB engine in the viewer's own browser (lazy-loaded
only when a spec declares at least one metric), not on a server.
id— unique within the dashboard. What a widget binds to (see below).sql— a single read-only statement.title— optional display label; falls back toidwhere a view needs one.
A dataset whose id isn't a valid SQL identifier (letters, digits,
underscores, not starting with a digit) can still back a widget through its
own inline keys, but can't be queried — the dashboard flags this explicitly
rather than letting the query fail with a bare "table does not exist".
What SQL is allowed
The statement must be exactly one SELECT, WITH, or FROM-first query (a
DESCRIBE/SUMMARIZE is also accepted, for iterating on a query). Rejected
wherever they appear in the statement, not just at the start:
- Writes and schema changes —
INSERT,UPDATE,DELETE,DROP,CREATE,ALTER,TRUNCATE, and similar. - Anything that reaches outside the loaded datasets —
ATTACH,COPY,LOAD,INSTALL,EXPORT/IMPORT, and table functions that take a path or URL (read_csv,read_parquet,postgres_scan, …). A dashboard's SQL queries the run's own output, never an outside address — for external data, connect it as a datasource instead. - Changing session/engine behavior —
PRAGMA,SET,RESET,CALL, and prepared-statement keywords (PREPARE/EXECUTE).
A rejected query is reported with the specific keyword or function that
triggered it, in config.errors — the same banner an unresolvable widget key
shows.
Reading a metric's result from a widget
A metric's rows are exposed under a reserved metrics namespace, resolved by
dot-path exactly like a run's own output:
[[widgets]]
type = "table"
id = "load-table"
title = "Load per machine"
rows_key = "metrics.per_machine"
columns = [
{ key = "machine", label = "Machine" },
{ key = "tasks", label = "Tasks" },
{ key = "busy_hours", label = "Busy hours" },
]value_key = "metrics.per_machine.0.busy_hours" would read the first row's
busy_hours for a metric tile — the dot-path indexes into the array with
.0., same as any other numeric segment. Any widget type can bind to a
metric's result this way; a metric needed no new widget type of its own.
A metric runs once per run, against the datasets already loaded — not once
per [clock] tick. It has no access to the clock's current position.
Clock
[clock]
dataset = "schedule"
speed = 60Declaring [clock] adds a play/pause/scrub transport bar above the
dashboard, driven by one intervals dataset's own start/end columns.
dataset— the id of a declared (or widget-derived)intervalsdataset. Required — arecordsorscalarsdataset has no time axis to play through, and is rejected with an error naming its actual kind.speed— simulated time units per real second. A positive finite number; defaults to60(so an hour of a schedule plays out in one real minute) when omitted.
There's deliberately no step size or sampling rate to configure: playback moves between the data's own event boundaries (where an interval starts or ends), which are exact, rather than sampling on a fixed grid that could miss or blur a change between samples.
Checking a spec before you commit
harumi dashboard validate (see CLI commands)
checks all four sections, not just [[widgets]]:
[[datasets]]— a missing or unknownkind, a missingsource_key, or a required column role absent from[datasets.roles]for that kind (e.g. anintervalsdataset needsstartplus eitherendorduration).[[metrics]]— a missingidorsql, and the same read-only SQL guard the dashboard itself runs: noINSERT/UPDATE/ATTACH, no multiple statements.[clock]—datasetmust name a declaredintervalsdataset (or atimeline/gantt-chartwidget's own synthesized one);speedmust be a positive finite number; a metric'stime_keyis flagged if no[clock]section exists to give it a timeline to place itself on.
This is structural validation — it catches a malformed section before you
commit, the same way it already did for [[widgets]]. It does not execute
your [[metrics]] SQL against a real output.json, so a query that's valid
SQL but references a column your dataset doesn't actually have still only
surfaces as a runtime error banner the next time the dashboard is opened.