> Section: [8. Observing it](https://jaque.sh/docs/ui/dashboard.md)
> Next: observability/metrics
> Index: https://jaque.sh/llms.txt


A sink (see [Sinks and perfdata](https://jaque.sh/docs/observability/sinks-and-perfdata.md))
writes perfdata somewhere. A datasource reads it back. The two are
declared separately because writing and reading are separate concerns: a
deployment can run sinks with no datasources declared, in which case the
dashboard serves no graphs at all, or declare a datasource pointed at a
backend nothing in `sinks:` writes to.

jaque never hands the browser a datasource's URL or credentials. The
dashboard asks jaque's query service for a panel or a metric by name, and
jaque runs the query against the configured backend itself. Whatever a
datasource's `url` carries stays server-side.

## 1. The `datasources:` block

Each datasource is a named entry under `datasources:`, discriminated by
`type` the same way `sinks:` is.

```cue
datasources: {
	ch: {
		type:  "clickhouse"
		url:   "clickhouse://ch:9000/jaque"
		table: "perfdata"
	}
	prom: {
		type: "prometheus"
		url:  "http://prometheus:9090"
	}
}
```

| Type | Fields | Notes |
|---|---|---|
| `clickhouse` | `url`, `table` | `table` defaults to `perfdata`; queried for both the semantic and raw SQL panel forms below |
| `prometheus` | `url` | queried through `/api/v1/query_range`; only the raw PromQL panel form runs against it |

A panel or the object detail view can only ask a `clickhouse` datasource
for a semantic or raw SQL query, and only a `prometheus` datasource for a
PromQL query. Naming the wrong type is a config error caught when jaque
loads the config, not when the panel is first viewed.

## 2. The `ui:` block

```cue
ui: {
	datasource: "ch"
	panels: {
		load_avg: {
			ds: "ch"
			query: {selector: "env=prod", metric: "load", agg: "avg"}
		}
	}
}
```

`ui.datasource` names the datasource the object detail view's automatic
per-object graphs use. It is optional: leaving it unset falls back to the
single declared datasource, or, with more than one declared, the
lexicographically first by name. Either way that datasource must be
`clickhouse` -- the object detail graphs use the semantic query form,
which needs an object ID to filter by, and PromQL has no such axis
(ADR-018).

`ui.panels` declares the named panels the panels view renders. Each panel
has:

| Field | Default | Meaning |
|---|---|---|
| `ds` | (required) | the datasource name this panel queries |
| `query` | (required) | one of the three forms below |
| `range` | `6h` | how far back the panel plots by default |
| `type` | `line` | `line` or `area` |

## 3. The three query forms

A panel's `query` is exactly one of semantic, raw SQL, or raw PromQL.

### Semantic -- no query language

```cue
query: {selector: "env=prod, team=redes", metric: "load", agg: "avg"}
```

A [label selector](https://jaque.sh/docs/config/labels-and-views.md), a metric name (the
perfdata point's label, e.g. `load` or `rta`), and an aggregation --
`avg`, `max` or `min`. jaque resolves the selector to a set of object
IDs and groups by object, one series per matching object. Requires a
`clickhouse` datasource. This is the form to reach for by default: it
needs no SQL and no PromQL, and it is the only form the object detail
view's automatic graphs use.

### Raw SQL

```cue
query: {sql: "SELECT service AS series, bucket, avg(value) AS value FROM (SELECT service, toStartOfInterval(ts, INTERVAL $__interval SECOND) AS bucket, value FROM perfdata WHERE ts >= toDateTime($__from) AND ts <= toDateTime($__to)) GROUP BY series, bucket ORDER BY series, bucket"}
```

Runs verbatim against the panel's ClickHouse datasource. See section 4 for
the column contract and macros.

### Raw PromQL

```cue
query: {promql: "rate(jaque_perfdata_bytes{host=\"gw\"}[5m])"}
```

Runs verbatim against the panel's Prometheus datasource through
`query_range`. Requires a `prometheus` datasource. jaque does not parse
or evaluate PromQL itself -- it forwards the query text and reshapes
whatever Prometheus returns into the same series format every panel uses.

## 4. The raw SQL column contract and macros

A raw SQL query must return exactly three columns, in this order:

| Column | Type | Meaning |
|---|---|---|
| `series` | `String` | the series name this row belongs to |
| `bucket` | `DateTime` or `DateTime64` | the sample's timestamp |
| `value` | `Float64` | the sample's value |

A query returning different columns, a different order, or different
types is rejected when it runs, with an error naming what was expected.

Three macros are substituted into the query text before it runs:

| Macro | Value |
|---|---|
| `$__from` | the range's start, unix seconds |
| `$__to` | the range's end, unix seconds |
| `$__interval` | the query's step, in whole seconds |

The step is the queried range divided into 200 buckets, rounded up to a
whole number of seconds, with a floor of 10 seconds -- a 6h panel (the
default range) steps at 108s; nothing ever queries at a step finer than
10s regardless of how short the range is.

## 5. Counter rate

A metric point's kind -- `gauge` or `counter` -- comes from how it was
recorded (see [Sinks and perfdata](https://jaque.sh/docs/observability/sinks-and-perfdata.md)
section 3); a semantic query knows this per series and handles each
according to its kind. A gauge series is aggregated by the requested
`agg` and plotted as-is. A counter series is always aggregated by `max`
within each bucket regardless of the requested `agg`, then converted to a
per-second rate in jaque before it reaches the dashboard: each plotted
point is `(value[i] - value[i-1]) / step`. The first point of a counter
series has no predecessor and is dropped, and any point whose delta would
be negative -- the counter reset -- is dropped too, rather than plotted
as a spike or a trough.

Raw SQL and raw PromQL queries get no such handling: a raw query returns
whatever it computes, so a raw SQL query plotting a counter needs its own
rate expression, and a raw PromQL query reaches for `rate()` or
`increase()` the way any PromQL query would.

## 6. Units

Every point a semantic query returns is normalized to a scale-free unit
before it is stored (see [Sinks and perfdata](https://jaque.sh/docs/observability/sinks-and-perfdata.md)
section 3) -- a plugin reporting `6ms` is stored as `0.006` seconds, and
that unit travels with the series to the dashboard. The axis and the
legend both format each series' values by that unit rather than showing
the raw stored number: a duration reads as `6ms` or `82d`, a byte count
as `1.4MiB`, a fraction as `65.9%`. The unit comes from the perfdata the
sink normalized, never from the panel's own declaration -- a panel does
not say what its values mean, the recorded points already know.

Raw SQL and raw PromQL panels carry no unit: neither query form's
three-column contract has anywhere to put one, so their values render as
plain numbers. And a series whose samples disagree on the unit -- the
same metric recorded two different ways -- is treated as having none
too, rather than guessing: a single scale is the whole point of a unit,
and a wrong guess would be worse than a plain number.

## 7. Where graphs appear

Two places. The panels view (`#/panels`) renders every declared
`ui.panels` entry as a chart, `line` or `area` per its `type`, over
its default `range`. The object detail view
(`#/object/<host>`, `#/object/<host>/<service>`) renders one graph per
metric the object has reported perfdata for, using the semantic form
against `ui.datasource` -- no panel needs to be declared for these, they
follow from the perfdata jaque has already recorded for that object.
Neither place needs the `datasources:`/`ui:` blocks to exist for the rest
of the dashboard to work; a deployment with no datasource declared simply
shows neither.

Every graph is interactive. Hovering over the plot reads every series'
value at that instant. Dragging horizontally across the plot zooms into
that window, and double-clicking restores the full range. Clicking a
legend entry hides or shows that series.

A time picker sits above the panels grid: one button showing the
active window ("Last 1 hour", or the typed start and end). Clicking
it opens a dropdown. One column lists quick ranges -- Last 5 minutes
up to Last 1 year -- and clicking one applies it at once; `default`
returns every panel to its own configured `range`. The other column
holds the absolute range: two text fields, start and end, a calendar
that fills them by picking two days (time included), and an apply
button. Each field takes either an absolute date-time
(`2026-08-20 14:00`) or an expression relative to now (`now-7d`),
including snapped forms like `now-7d/d` that round to a whole day
boundary -- so a link built from a snapped expression means the same
whole days again tomorrow. Setting both fields overrides every
panel's window at once and re-queries them all. The link carries
whatever was typed, not the resolved time. Escape or clicking
outside closes the dropdown without applying.

Each panel card also has an expand button. Clicking it grows that one
panel to the full width of the view and its chart to a taller height, in
place of the grid. The expanded panel is part of the URL, so it's also
part of any link you send -- opening a shared link lands directly on that
panel, already expanded. The same button, now labeled to collapse,
returns to the grid, and pressing Escape does the same.
