Build, repair, or review Maple dashboard widgets via the MCP. Triggers on phrases like 'create_dashboard', 'add_dashboard_widget', 'update_dashboard_widget', 'dashboard widget JSON', 'panel_type', 'QueryDraft', or any session that submits widget JSON to the maple MCP. Covers the panel-type table, the kind-discriminated data source, the percent vs percent_100 unit rule, valid aggregations and group-by tokens per source, the custom whereClause grammar, the scalar reduceToValue transform, and th...
Scanned 10/1/2026
npx -y skills add MapleTechLabs/maple --skill maple-dashboard-widgets --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Maple Dashboard Widgets?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/mapletechlabs-maple-dashboard-widgets)More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.
---
name: maple-dashboard-widgets
description: "Build, repair, or review Maple dashboard widgets via the MCP. Triggers on phrases like 'create_dashboard', 'add_dashboard_widget', 'update_dashboard_widget', 'dashboard widget JSON', 'panel_type', 'QueryDraft', or any session that submits widget JSON to the maple MCP. Covers the panel-type table, the kind-discriminated data source, the percent vs percent_100 unit rule, valid aggregations and group-by tokens per source, the custom whereClause grammar, the scalar reduceToValue transform, and the verification step (MCP success != chart correctness)."
---
# Maple dashboard widgets via MCP
Everything below is generated from the live widget schema by
`bun run --cwd apps/ai mcp:docs`. **Do not edit this file by hand.** Edit
`apps/ai/src/mcp/lib/dashboard-schema-doc.ts` and regenerate. The same module backs the
`describe_dashboard_schema` MCP tool, so an agent at runtime and a reader here see one truth.
## When to use this skill
When constructing widget JSON for `mcp__maple__create_dashboard`,
`mcp__maple__add_dashboard_widget`, `mcp__maple__update_dashboard_widget` or
`mcp__maple__replace_dashboard_widgets`.
For a brand-new dashboard, prefer the simplified `widgets` array on `create_dashboard`
(`{ title, source, metric, group_by?, service_name?, unit? }`). It fills in the traps below.
Reach for raw JSON when the simplified spec can't express what you need: multi-query charts,
formulas, hidden series, non-default transforms.
## The three silent failures
1. **A data source is a `kind`-discriminated union.** `{ "endpoint": …, "params": … }` is the
retired v2 shape and will not decode.
2. **`percent` means a 0–1 fraction; `percent_100` means 0–100.** Inverted from Grafana.
3. **`groupBy` is ignored unless `addOns.groupBy` is `true`.** No error, just an ungrouped total.
## Verification
MCP success is not chart correctness. The mutation tools reject queries the engine can't honor and
widgets that cannot render, and return an automatic `inspect_chart_data` summary for everything
else. Read the verdict: `suspicious` or `broken` means fix and resubmit.
## Panel types
`panel_type` is the whole answer to “what kind of widget is this”. Pass it and the
persisted `visualization`, the `display.chartId` and the raw-SQL display type are all
derived for you. The legacy `visualization` parameter is still accepted, but it collapses
line/bar/area into `chart` and then needs a `display.chartId` to tell them apart. The two
columns below are what `panel_type` resolves to, and are what you write directly when
authoring an assembled widget rather than calling `add_dashboard_widget`. A panel whose
`chartId` is `-` takes none: the `visualization` alone identifies it.
| panel_type | Label | `visualization` | `display.chartId` | Raw-SQL type | Default w×h | Requirements |
|---|---|---|---|---|---|---|
| `line` | Line | `chart` | `query-builder-line` | `line` | 4×6 | - |
| `bar` | Bar | `chart` | `query-builder-bar` | `bar` | 4×6 | - |
| `hbar` | Horizontal Bar | `hbar` | `query-builder-hbar` | `hbar` | 4×6 | needs a group-by |
| `area` | Area | `chart` | `query-builder-area` | `area` | 4×6 | - |
| `pie` | Pie | `pie` | `query-builder-pie` | `pie` | 4×6 | needs a group-by |
| `stat` | Stat | `stat` | - | `stat` | 3×4 | needs `transform.reduceToValue` |
| `gauge` | Gauge | `gauge` | - | `stat` | 4×6 | needs `transform.reduceToValue` |
| `table` | Table | `table` | - | `table` | 6×5 | - |
| `list` | List | `list` | - | - | 6×5 | **no raw-SQL support** |
| `histogram` | Histogram | `histogram` | `query-builder-histogram` | `histogram` | 4×6 | - |
| `heatmap` | Heatmap | `heatmap` | `query-builder-heatmap` | `heatmap` | 4×6 | needs a group-by |
| `funnel` | Funnel | `funnel` | `query-builder-funnel` | `funnel` | 4×6 | needs a group-by |
| `paths` | Paths | `paths` | `query-builder-paths` | - | 6×5 | **no raw-SQL support** |
| `markdown` | Note | `markdown` | - | - | 4×5 | **no raw-SQL support** |
### Choosing one
- **line / area / bar**: a value over time. `area` and `bar` accept `display.stacked`; `line` does not.
- **hbar**: a ranked “top N by volume”. Each row is labelled with its share of the **total**.
- **funnel**: sequential stages with a drop-off. Labels each bar as a share of the *largest*,
so an unranked breakdown of four equal things reads “100%” four times. Use `hbar` for that.
`display.funnel.variant: "dropoff"` draws the same funnel as one column per step with the
loss between steps, the median time between them, and where the leavers went.
- **paths**: what people do in the steps after (or before) one event or page, as a flow.
Defined by `display.paths` alone; no query set.
- **pie**: composition, few slices. Collapses a long tail into “Other”.
- **stat / gauge**: one number. A gauge adds an arc; set `display.gauge.min`/`max` to match the unit.
- **table**: rows and columns; set `display.columns` for headers and per-column units.
- **list**: recent traces/logs. Configured by `display.listDataSource`, never by SQL.
- **heatmap / histogram**: a distribution. A histogram over traces can bucket raw values client-side.
- **markdown**: a static note. Takes no query at all.
## Data sources
A widget's `dataSource` is a discriminated union over `kind`: `query`, `raw_sql`, `route`, `static`. Every arm requires its `kind`.
> **If you have seen `{ "endpoint": …, "params": … }` anywhere, that is the retired v2 shape
> and it will not decode.** A `query` source spreads `queries`/`formulas` at the TOP LEVEL,
> not under `params`, and requires `resultShape`.
### `kind: "query"`: the query builder
`resultShape` is required and is one of `timeseries` (a value over time), `breakdown`
(one row per group) or `list` (raw rows). Optional: `formulas`, `comparison`, `limit`,
`defaultLimit`, `columns`, `transform`.
```json
{
"kind": "query",
"resultShape": "timeseries",
"queries": [
{
"id": "q1",
"name": "A",
"enabled": true,
"whereClause": "service.name = \"api\"",
"aggregation": "error_rate",
"stepInterval": "",
"orderByDirection": "desc",
"addOns": {
"groupBy": true,
"having": false,
"orderBy": false,
"limit": false,
"legend": false
},
"groupBy": [
"service.name"
],
"having": "",
"orderBy": "",
"limit": "",
"legend": "",
"dataSource": "traces"
}
]
}
```
### `kind: "raw_sql"`: your own ClickHouse SQL
```json
{
"kind": "raw_sql",
"sql": "SELECT count() AS value FROM logs WHERE $__orgFilter",
"displayType": "stat"
}
```
### `kind: "static"`: a markdown note, no request
```json
{
"kind": "static"
}
```
### `kind: "route"`: a curated built-in panel
`{ "kind": "route", "endpoint": "service_overview", "params": { … } }`. These back the
prebuilt panels; you rarely author one by hand.
### Scalar panels need a reduction
A `stat` or `gauge` reads `data[0].value`. Without `transform.reduceToValue` it renders
`[object Object]`. `add_dashboard_widget` injects `{ field: "value", aggregate: "first" }`
when you omit it; set it explicitly to choose a different reducer. Valid aggregates:
`sum`, `first`, `count`, `avg`, `max`, `min`: **there is no `last`**.
Which one depends on what the query returns, because the query is bucketed over time and
the reducer collapses those buckets into one number:
- **A rate or a count** (`count`, a metrics `rate`) → `sum`, for a window total.
- **A latency percentile or an average** (`p95_duration`, `avg_duration`, a gauge metric)
→ `avg` for the typical value over the window, or `max` for the worst bucket. **Not**
`sum`: adding percentiles together is meaningless, and it is the common wrong choice.
- **A current reading**, where only the newest bucket matters → `first`.
```json
{
"kind": "query",
"resultShape": "timeseries",
"queries": [
{
"id": "q1",
"name": "A",
"enabled": true,
"whereClause": "",
"aggregation": "count",
"stepInterval": "",
"orderByDirection": "desc",
"addOns": {
"groupBy": false,
"having": false,
"orderBy": false,
"limit": false,
"legend": false
},
"groupBy": [],
"having": "",
"orderBy": "",
"limit": "",
"legend": "",
"dataSource": "traces"
}
],
"transform": {
"reduceToValue": {
"field": "value",
"aggregate": "sum"
}
}
}
```
### The breakdown shape
`resultShape: "breakdown"` returns one row per group instead of a series over time, the
shape `pie`, `hbar`, `funnel` and `heatmap` need. It requires a group-by, and `limit` caps
the rows (honoured for 1–100).
```json
{
"kind": "query",
"resultShape": "breakdown",
"queries": [
{
"id": "q1",
"name": "A",
"enabled": true,
"whereClause": "",
"aggregation": "count",
"stepInterval": "",
"orderByDirection": "desc",
"addOns": {
"groupBy": true,
"having": false,
"orderBy": false,
"limit": false,
"legend": false
},
"groupBy": [
"service.name"
],
"having": "",
"orderBy": "",
"limit": "",
"legend": "",
"dataSource": "traces"
}
],
"limit": 10
}
```
### Product-event funnels (`panel_type: "funnel"` + `display.funnel.steps`)
A funnel widget has two modes. Without `display.funnel.steps` it draws a group-by breakdown
as descending stages (the shape above). With them it is a **conversion funnel over product
events** (page views, `track()` events and server-side events, stitched per person), and
the query set is not used at all. Set the definition on `display_json.funnel` and
`add_dashboard_widget` derives the data source (`kind: "route"`,
`endpoint: "product_events_funnel"`) for you; do not pass `data_source_json`.
- `steps`: 1–10, in order. `{ kind: "event", eventName, attributeEquals? }`,
`{ kind: "page", pagePath, host? }`, or (**step 1 only**)
`{ kind: "session", dimension, value }` with `dimension` one of `referrerHost`,
`utmSource`, `utmMedium`, `utmCampaign`, `country`, `host`.
- `keyBy`: `person` (default; user id, else the visitor's linked user, else the visitor),
`visitor`, `user`, or `session`.
- `windowSeconds`: the whole chain must complete within this many seconds of step 1
(default 86400). Must be positive.
- `breakdownBy`: split the funnel by a session dimension (`referrerHost`, `utmSource`,
`utmMedium`, `utmCampaign`, `country`, `host`) or `attribute:<key>`; the widget draws one
bar per group per step for the top 6 groups by step-1 count.
- `filters`: the population filter: only persons with a session matching these
dimensions take part. `{ host?, pagePath?, referrerHost?, country?, deviceType?,
browserName?, osName?, language?, utmSource?, utmMedium?, utmCampaign?,
visitorType?: "new" | "returning" }`. They are spread FLAT into the derived route params.
The step count, the step-1-only session rule and a positive `windowSeconds` are enforced on
write: a definition that breaks one of them is rejected rather than saved, because the
query engine would reject it again on every render.
Use `list_product_events` to see which event names exist, and `query_funnel` to try a
definition before pinning it to a board.
```json
{
"id": "w-signup-funnel",
"visualization": "funnel",
"dataSource": {
"kind": "route",
"endpoint": "product_events_funnel",
"params": {
"steps": [
{
"kind": "page",
"pagePath": "/pricing"
},
{
"kind": "event",
"eventName": "signup_completed"
},
{
"kind": "event",
"eventName": "plan_started",
"attributeEquals": {
"plan": "pro"
}
}
],
"keyBy": "person",
"windowSeconds": 604800
}
},
"display": {
"title": "Signup funnel",
"chartId": "query-builder-funnel",
"funnel": {
"showStepPercent": true,
"steps": [
{
"kind": "page",
"pagePath": "/pricing"
},
{
"kind": "event",
"eventName": "signup_completed"
},
{
"kind": "event",
"eventName": "plan_started",
"attributeEquals": {
"plan": "pro"
}
}
],
"keyBy": "person",
"windowSeconds": 604800
}
},
"layout": {
"x": 0,
"y": 0,
"w": 6,
"h": 4
}
}
```
`display.funnel.variant: "dropoff"` switches the same widget to the drop-off view, which
also fetches the time between steps and the most common next event of the persons who
stopped before each step. Needs at least two steps to say anything; with `breakdownBy` it
draws one cap per group and no timing or leavers.
### Paths (`panel_type: "paths"` + `display.paths`)
A flow of what people did in the hops after (or, with `direction: "before"`, before)
one anchor. Product events only; there is no query set. Set the definition on
`display_json.paths` and `add_dashboard_widget` derives the data source (`kind: "route"`,
`endpoint: "product_events_paths"`); do not pass `data_source_json`.
- `anchor`: required. `{ kind: "event", eventName, attributeEquals? }` or
`{ kind: "page", pagePath, host? }`. A session step cannot anchor a sequence.
- `direction`: `"after"` (default) or `"before"`.
- `depth`: hops to draw, 1–5 (default 3). `branches`: nodes named per step, 1–10
(default 4); the rest fold into an *Other* node. A sequence that stops short ends in
an *Ended* node.
- `keyBy`, `windowSeconds` (how far from the anchor a hop may be, default 86400) and
`filters`: as for funnels.
- `include`: `"all"` (default), `"events"` or `"pages"`. `exclude`: names (event names
or page paths) dropped before sequencing, e.g. `["heartbeat", "/"]`.
### A complete widget
The sections above describe `add_dashboard_widget`'s parameters, which it assembles into a
widget for you. `update_dashboard_widget`, `replace_dashboard_widgets` and `dashboard_json`
take the assembled object instead. This is its shape. `timeRange` and `sectionId`/`tabId`
are the only other top-level keys, both optional.
```json
{
"id": "w-error-rate",
"visualization": "chart",
"dataSource": {
"kind": "query",
"resultShape": "timeseries",
"queries": [
{
"id": "q1",
"name": "A",
"enabled": true,
"whereClause": "service.name = \"api\"",
"aggregation": "error_rate",
"stepInterval": "",
"orderByDirection": "desc",
"addOns": {
"groupBy": true,
"having": false,
"orderBy": false,
"limit": false,
"legend": false
},
"groupBy": [
"service.name"
],
"having": "",
"orderBy": "",
"limit": "",
"legend": "",
"dataSource": "traces"
}
]
},
"display": {
"title": "Error rate by service",
"chartId": "query-builder-line",
"unit": "percent",
"chartPresentation": {
"legend": "visible"
}
},
"layout": {
"x": 0,
"y": 0,
"w": 4,
"h": 6,
"minW": 2,
"minH": 2
}
}
```
## Units (`display.unit`)
**The one that bites: Maple's percent tokens are inverted relative to Grafana's.**
- `percent` expects a **fraction 0–1** and multiplies by 100 on render. (Grafana calls this `percentunit`.)
- `percent_100` expects **0–100** and renders as-is. (Grafana calls *this* one `percent`.)
The traces `error_rate` aggregation returns a 0–1 ratio, so it pairs with `percent`.
Most exporter metrics named `*_percent`/`*_utilization` already report 0–100 and want
`percent_100`. Getting it backwards renders 100× off with no error anywhere.
| Token | Label | Expects |
|---|---|---|
| `none` | None | any number; rendered like `number` |
| `number` | Number | any number; grouped thousands |
| `percent` | Percent (0–1) | a FRACTION 0–1; multiplied by 100 on render. `error_rate` is this one |
| `percent_100` | Percent (0–100) | already 0–100; rendered as-is. Grafana spells this one `percent` |
| `duration_ms` | Duration (ms) | milliseconds. The query builder's `*_duration` aggregations are already ms |
| `duration_s` | Duration (s) | seconds |
| `duration_us` | Duration (µs) | microseconds |
| `duration_ns` | Duration (ns) | nanoseconds |
| `bytes` | Bytes | bytes; scaled decimal (1000-base), not 1024 |
| `requests_per_sec` | Requests/sec | a per-second rate |
| `short` | Short | any number; rendered like `number` |
`display.unit` is stored as an open string, so an unrecognised value like `"ms"`, `"%"`
or `"GB"` **saves successfully and then renders as a plain number**. The write tools warn
when they see one and suggest the right token. The same vocabulary applies to
`display.yAxis.unit`, `display.xAxis.unit` and `display.columns[].unit`.
A gauge's arc is independent of its unit and defaults to 0–100: on a `percent` gauge set
`display.gauge: { "min": 0, "max": 1 }` or the needle sits pinned at zero.
## Queries
A query draft is discriminated on `dataSource` (`traces` / `logs` / `metrics` / `product_events`). The
metric-only fields belong solely to `metrics` queries; do not add them to trace or log
queries:
- `metricName`: required; discover real names with `list_metrics`.
- `metricType`: required, one of `sum`, `gauge`, `histogram`, `exponential_histogram`. Anything else fails to decode.
- `signalSource`: optional, one of `default`, `meter`. Omit it unless you know you need `meter`.
- `isMonotonic`: optional; `false` marks a Sum as an UpDownCounter, which changes the
aggregations that make sense (`rate`/`increase` assume a monotonic counter).
### `addOns` is required, and all five keys must be present
`addOns: { groupBy, having, orderBy, limit, legend }`: every key, every time. A missing
one fails to decode. Each flag gates whether the matching field is read at all, which is
why `groupBy` without `addOns.groupBy: true` silently does nothing.
### Aggregations, per source
| dataSource | Valid `aggregation` |
|---|---|
| `traces` | `count`, `avg_duration`, `p50_duration`, `p95_duration`, `p99_duration`, `error_rate` |
| `logs` | `count` |
| `metrics` | `avg`, `sum`, `min`, `max`, `count`, `rate`, `increase` |
| `product_events` | `count`, `sessions`, `persons`, `users`, `visitors` |
On traces only, setting `valueField: "attr.<key>"` switches the query to numeric-attribute
mode, where the aggregation is one of `avg`, `sum`, `min`, `max`, `p50`, `p95`, `p99`.
This is the **only** place a bare `p50`/`p95`/`p99` is valid; latency percentiles are
spelled `p95_duration`. Metrics never accept percentiles.
### Group-by tokens
**`groupBy` is ignored unless `addOns.groupBy` is `true`.** This is the single most common
silent failure: the array is present, the chart shows an ungrouped total, and nothing errors.
| dataSource | Literal tokens | Prefixed |
|---|---|---|
| `traces` | `service`, `service.name`, `service_name`, `span`, `span.name`, `span_name`, `status`, `status.code`, `status_code`, `http.method`, `none`, `all` | `attr.<key>` |
| `logs` | `service`, `service.name`, `service_name`, `severity`, `severity_text`, `none`, `all` | **none** |
| `metrics` | `service`, `service.name`, `none`, `all` | `attr.<key>`, `resource.<key>` |
| `product_events` | `event`, `event.name`, `event_name`, `kind`, `event.kind`, `source`, `host`, `page`, `page.path`, `page_path`, `service`, `service.name`, `service_name`, `group`, `group.id`, `none`, `all` | `attr.<key>` |
Anything outside the literal list must use a supported prefix; unrecognised tokens are
dropped, which makes the write tools reject the widget rather than save a mis-scoped chart.
### `whereClause` is a custom grammar, not SQL
Operators (the only ones): `=`, `!=`, `>`, `<`, `>=`, `<=`, `contains`, `!contains`,
`exists`, `!exists`. Clauses join with ` AND `; there is no `OR` and no parentheses.
Values use double quotes. **There is no `IS NULL` / `IS NOT NULL`**: write `<key> exists`
or `<key> !exists`. `exists` means present *and* non-empty, because attributes live in
ClickHouse `Map` columns where a missing key reads back as `''`.
On `traces` any bare key outside the structured allowlist (`service.name`, `span.name`,
`deployment.environment`, `vcs.ref.head.revision`, `root_only`, `has_error`) is treated as
a span attribute, so `db.system = "clickhouse"` works directly. Cap: 5 `attr.*` plus 5
`resource.*` filters per query.
### Formulas and hidden series
`formulas: [{ id, name, expression, legend }]` references queries by `name` (`A / B`), and
is valid on the **timeseries** shape only. Marking a query `hidden: true` is UI-only in raw
JSON, so also add `transform.hideSeries.baseNames: ["A"]` or the auxiliary series renders at
full scale and flattens the axis.
## Display config
| Key | Applies to | Notes |
|---|---|---|
| `title`, `description` | all | |
| `unit` | all | See the `units` section: read it before choosing a percent token. |
| `thresholds` | stat, gauge, charts | `[{ value, color, label? }]`; highest matching value wins. |
| `prefix`, `suffix` | stat, gauge | Wrap the formatted value. |
| `chartPresentation.legend` | charts | `visible` \| `hidden` \| `right`. |
| `chartPresentation.seriesStats` | charts | Min/Max/Mean/Last table; costs up to 45% of tile height. |
| `chartPresentation.tooltip` | charts | `visible` \| `hidden`. |
| `chartPresentation.showPoints` | charts | Omit for auto, `true` always, `false` never. |
| `stacked` | area, bar | Meaningless on `line`. |
| `curveType` | line, area | `linear` \| `monotone`. |
| `yAxis.logScale`, `softMin`, `softMax`, `fitYAxisToData` | charts | |
| `columns` | table, list | `[{ field, header, unit?, width?, align?, hidden? }]`. |
| `listDataSource`, `listWhereClause`, `listLimit`, `listRootOnly` | list | |
| `pie` | pie | `{ donut, innerRadius, showLabels, showPercent }`. |
| `gauge` | gauge | `{ min, max }`: defaults to 0–100, which is wrong for a `percent` unit. |
| `histogram` | histogram | `{ bucketCount, bucketWidth, logScaleY }`. |
| `heatmap` | heatmap | `{ colorScale, scaleType }`. |
| `funnel` | funnel | `{ showStepPercent, variant?, steps?, keyBy?, windowSeconds?, breakdownBy?, filters? }`: with `steps` it is a product-event funnel (see Data sources); `variant: "dropoff"` is the drop-off view. |
| `paths` | paths | `{ anchor, direction?, depth?, branches?, keyBy?, windowSeconds?, include?, exclude?, filters? }`: the whole definition (see Data sources). |
| `markdown` | markdown | `{ content }`: the note body. |
| `sparkline` | stat | `{ enabled, dataSource? }`; embeds a full nested data source. |
### Stored but not rendered
These decode and persist, and the chart renderer **ignores them**. Setting one to fix a
problem will look like it worked and change nothing:
`yAxis.min`, `yAxis.max`, every `xAxis` field, `seriesMapping`, `colorOverrides`,
`chartPresentation.fillNulls`, `gauge.style`.
To bound a chart's axis use `yAxis.softMin`/`softMax`.
### Per-widget time range
A widget follows the dashboard's range unless it carries its own top-level `timeRange`:
`{"type":"relative","value":"30m"}` or `{"type":"absolute","startTime":"…","endTime":"…"}`.
Pin one only when the window is part of what the tile means. Because
`update_dashboard_widget` replaces the whole widget, omitting `timeRange` there REMOVES an
existing override.
## Raw SQL widgets
Pass `sql` to `add_dashboard_widget` and the tool builds the data source for you.
**Call `describe_warehouse_tables` first**: a hallucinated table or column silently
produces an empty chart.
### Macros
- `$__orgFilter` → **required**; scopes the query to your org.
- `$__timeFilter(Column)` → a bare column identifier, no expressions. Prefer this in WHERE.
- `$__startTime` / `$__endTime` → `toDateTime(…)` literals for use outside a WHERE comparison.
- `$__interval_s` → bucket size in seconds; only interpolate it if the SQL buckets time.
### Conventions that catch everyone
- Columns are PascalCase (`ServiceName`, `Timestamp`), never snake_case.
- `StatusCode` / `SeverityText` / `SpanKind` values are **Title Case** (`'Error'`, not `'ERROR'`).
Wrong casing runs fine and matches zero rows.
- Span `Duration` is **nanoseconds**. Divide by `1e6` for ms.
- `SpanAttributes['key']`: square brackets. A missing key returns `''`, not NULL.
- One statement only; writes are rejected; every query is wrapped in `LIMIT 1001`.
### What to SELECT, per panel type
The renderer is opinionated. Wrong aliases give an empty chart or `[object Object]`.
- **line / area / bar**: a DateTime bucket as the FIRST column (alias `bucket`) plus one or
more **numeric** columns; each becomes a series named after the column. **String columns
are dropped**, so multi-series must be pivoted in SQL with `countIf(...)`; tall form
(`bucket, ServiceName, count()`) collapses to one aggregate line.
- **stat / gauge**: one scalar aliased `value`.
- **pie / funnel / hbar**: a string column aliased `name` plus a numeric column. Cap at ~8–10 rows.
- **heatmap**: three columns aliased `x`, `y`, `value`; string-cast numeric `x`/`y`.
- **histogram**: one numeric column aliased `value`, one row per observation; add `LIMIT 5000`.
- **table**: any rows; columns render in order, so use `AS` for readable headers.
- **list**: not supported. A list is configured by `display.listDataSource`.
```sql
SELECT toStartOfInterval(Timestamp, INTERVAL $__interval_s SECOND) AS bucket,
countIf(SeverityNumber BETWEEN 17 AND 20) AS Error,
countIf(SeverityNumber BETWEEN 13 AND 16) AS Warn
FROM logs
WHERE $__orgFilter AND $__timeFilter(Timestamp)
GROUP BY bucket
ORDER BY bucket
```
`granularity_seconds` only matters if the SQL references `$__interval_s`. Either use
`toStartOfInterval(…, INTERVAL $__interval_s SECOND)` with it, or a fixed `toStartOf*`
without it; mixing them means the setting silently does nothing.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!