Harumi

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's output.json once, 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 one intervals dataset 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's FROM clause, and the [clock] section, refer to it by.
  • kind — one of intervals, timeline, records, or scalars. Picks which roles are required (below) and how a metric can query it.
  • source_key — dot-path to the array in output.json this dataset reads.
  • time_unit — how start/end/duration/at values (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.
KindNeedsOptional
intervalsstart, and either end or durationresource, label, category
timelinevalue, and either at or startlabel, category
recordsany role; a table binds to this kind with none set
scalarsvalue

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 idschedule 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 to id where 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 changesINSERT, UPDATE, DELETE, DROP, CREATE, ALTER, TRUNCATE, and similar.
  • Anything that reaches outside the loaded datasetsATTACH, 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 behaviorPRAGMA, 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 = 60

Declaring [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) intervals dataset. Required — a records or scalars dataset 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 to 60 (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 unknown kind, a missing source_key, or a required column role absent from [datasets.roles] for that kind (e.g. an intervals dataset needs start plus either end or duration).
  • [[metrics]] — a missing id or sql, and the same read-only SQL guard the dashboard itself runs: no INSERT/UPDATE/ATTACH, no multiple statements.
  • [clock]dataset must name a declared intervals dataset (or a timeline/gantt-chart widget's own synthesized one); speed must be a positive finite number; a metric's time_key is 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.

On this page