maple-dashboard-widgets
GitHub用于通过 MCP 构建、修复和审查 Maple 仪表盘组件。提供面板类型、数据源、单位规则及验证等核心规范,确保生成正确的 Widget JSON。
Trigger Scenarios
Install
npx skills add MapleTechLabs/maple --skill maple-dashboard-widgets -g -y
SKILL.md
Frontmatter
{
"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/api mcp:docs. Do not edit this file by hand — edit
apps/api/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
- A data source is a
kind-discriminated union.{ "endpoint": …, "params": … }is the retired v2 shape and will not decode. percentmeans a 0–1 fraction;percent_100means 0–100. Inverted from Grafana.groupByis ignored unlessaddOns.groupByistrue. 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 |
markdown |
Note | markdown |
— | — | 4×5 | no raw-SQL support |
Choosing one
- line / area / bar — a value over time.
areaandbaracceptdisplay.stacked;linedoes 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
hbarfor that. - pie — composition, few slices. Collapses a long tail into “Other”.
- stat / gauge — one number. A gauge adds an arc; set
display.gauge.min/maxto match the unit. - table — rows and columns; set
display.columnsfor 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. Aquerysource spreadsqueries/formulasat the TOP LEVEL, not underparams, and requiresresultShape.
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.
{
"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
{
"kind": "raw_sql",
"sql": "SELECT count() AS value FROM logs WHERE $__orgFilter",
"displayType": "stat"
}
kind: "static" — a markdown note, no request
{
"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 metricsrate) →sum, for a window total. - A latency percentile or an average (
p95_duration,avg_duration, a gauge metric) →avgfor the typical value over the window, ormaxfor the worst bucket. Notsum— adding percentiles together is meaningless, and it is the common wrong choice. - A current reading, where only the newest bucket matters →
first.
{
"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).
{
"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
}
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.
{
"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.
percentexpects a fraction 0–1 and multiplies by 100 on render. (Grafana calls thispercentunit.)percent_100expects 0–100 and renders as-is. (Grafana calls this onepercent.)
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). The
metric-only fields belong solely to metrics queries; do not add them to trace or log
queries:
metricName— required; discover real names withlist_metrics.metricType— required, one ofsum,gauge,histogram,exponential_histogram. Anything else fails to decode.signalSource— optional, one ofdefault,meter. Omit it unless you know you needmeter.isMonotonic— optional;falsemarks a Sum as an UpDownCounter, which changes the aggregations that make sense (rate/increaseassume 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 |
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> |
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, deployment.commit_sha, 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 — 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 }. |
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/SpanKindvalues are Title Case ('Error', not'ERROR'). Wrong casing runs fine and matches zero rows.- Span
Durationis nanoseconds. Divide by1e6for 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 withcountIf(...)— tall form (bucket, ServiceName, count()) collapses to one aggregate line. - stat / gauge — one scalar aliased
value. - pie / funnel / hbar — a string column aliased
nameplus a numeric column. Cap at ~8–10 rows. - heatmap — three columns aliased
x,y,value; string-cast numericx/y. - histogram — one numeric column aliased
value, one row per observation; addLIMIT 5000. - table — any rows; columns render in order, so use
ASfor readable headers. - list — not supported. A list is configured by
display.listDataSource.
SELECT toStartOfInterval(Timestamp, INTERVAL $__interval_s SECOND) AS bucket,
countIf(SeverityText = 'Error') AS Error,
countIf(SeverityText = 'Warn') 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.
Version History
-
b9cc9f8
Current 2026-08-19 16:20
重构仪表盘组件文档,改为从实时 Schema 自动生成,修正了百分比单位规则,并统一了面板类型定义。
-
a80abb5
2026-08-03 13:03
新增组件独立时间范围(timeRange)功能,允许单个widget覆盖仪表盘的默认时间窗口;修复exists操作符逻辑,使其能正确排除空值属性而非仅缺失键。
- 01a5dc6 2026-07-05 18:16


