maple-dashboard-widgets
GitHub通过MCP构建、修复或审查Maple仪表板组件。涵盖原始JSON构造、数据源区分、自定义whereClause语法及聚合陷阱,指导处理多查询、公式图表等复杂场景,并强调验证查询有效性。
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', 'QueryDraft', 'trace dashboard widget', 'Invalid input for getQueryBuilderTimeseries', or any session that submits raw widget JSON to the maple MCP. Covers the source-discriminated QueryDraft shape, the custom whereClause grammar, valid aggregations per data source, groupBy prefix conventions, the stat-widget `reduceToValue` transform, hiding auxiliary series on formula charts, and the verification step (MCP success ≠ query success)."
}
Maple dashboard widgets via MCP
When to use this skill
When you are constructing raw widget JSON for any of:
mcp__maple__create_dashboardwith adashboard_jsonpayloadmcp__maple__add_dashboard_widgetmcp__maple__update_dashboard_widget
If you are creating a fresh dashboard, prefer the simplified widgets array on create_dashboard (SimpleWidgetSpec at apps/api/src/mcp/tools/create-dashboard.ts:94). It side-steps every trap below — fill in title, source, metric, optional group_by, optional service_name, and the tool builds the full shape for you. Raw JSON is for cases the simplified spec can't express (multi-query charts, formulas, hidden series, non-default transforms).
Trap 1 — Query drafts are source-discriminated
The query draft schema (packages/domain/src/http/query-engine.ts) is a union discriminated on dataSource. The metric-only fields — metricName, metricType, isMonotonic, signalSource — exist only on dataSource: "metrics" queries.
- For
dataSource: "traces"or"logs": do not includemetricName,metricType,isMonotonic, orsignalSource. They are not part of the trace/log query shape. - For
dataSource: "metrics": includemetricName(the metric to query),metricType, and optionallyisMonotonic/signalSource.
Never add metricName/metricType/isMonotonic/signalSource to a trace or log query — they belong solely to dataSource: "metrics" queries. (Older stored dashboards were migrated to drop them; new widgets must not reintroduce them.)
Trap 2 — whereClause is a custom grammar, not SQL
Parser: packages/domain/src/where-clause.ts:7. Supported operators (the only supported operators):
| Operator | Example |
|---|---|
= != |
service.name = "ingest", error.type != "Timeout" |
> < >= <= |
http.request.body.size > 1000 |
contains !contains |
http.route contains "v1", http.route !contains "/health" |
exists !exists |
maple.signal exists, db.system !exists |
Rules:
- Clauses join with
AND(case-insensitive). NoOR, no parentheses. - Keys are normalized to lowercase by the parser.
- Quoted values use double quotes.
- There is no
IS NULL/IS NOT NULL. To require an attribute be present, use<key> exists; to require it absent,<key> !exists. This is the single most common mistake. - Attribute filters work directly. On
dataSource: "traces"you can filter by any span/resource attribute —query.context = "tracesList",error.type != "Timeout",db.system = "clickhouse". Bare keys outside the small structured allowlist (service.name,span.name,deployment.environment,deployment.commit_sha,root_only,has_error) are auto-treated as span attributes; you can also write them explicitly asattr.<key>/resource.<key>. Cap: 5attr.*+ 5resource.*filters per query. - Unhonored clauses are now rejected at write time.
add_dashboard_widget/update_dashboard_widget/replace_dashboard_widgetsrun the builder before persisting and FAIL (nothing saved) if a clause can't be honored — e.g. exceeding the attr cap, or an unsupported logs/metrics filter key. (This used to be a silent drop.)
Wrong:
service.name = "ingest" AND maple.signal IS NOT NULL
Right:
service.name = "ingest" AND maple.signal exists
Minimum-viable trace chart widget JSON
Use as a template. Fill whereClause, groupBy, aggregation, display.title, display.unit, layout:
{
"id": "w0",
"visualization": "chart",
"dataSource": {
"endpoint": "custom_query_builder_timeseries",
"params": {
"queries": [
{
"id": "q-w0",
"name": "A",
"enabled": true,
"hidden": false,
"dataSource": "traces",
"whereClause": "service.name = \"ingest\" AND maple.signal exists",
"aggregation": "count",
"stepInterval": "",
"orderByDirection": "desc",
"addOns": {
"groupBy": true,
"having": false,
"orderBy": false,
"limit": false,
"legend": false
},
"groupBy": ["maple.signal"],
"having": "",
"orderBy": "",
"limit": "",
"legend": ""
}
],
"formulas": [],
"comparison": { "mode": "none", "includePercentChange": true },
"debug": false
}
},
"display": {
"title": "Requests by Signal",
"chartId": "query-builder-bar",
"chartPresentation": { "legend": "visible" },
"stacked": true,
"curveType": "linear",
"unit": "number"
},
"layout": { "x": 0, "y": 0, "w": 6, "h": 4 }
}
Stat widget delta
For visualization: "stat", add dataSource.transform.reduceToValue. Transform schema is at apps/web/src/components/dashboard-builder/types.ts:42:
"transform": {
"reduceToValue": { "field": "value", "aggregate": "sum" }
}
Valid aggregate values: "sum" | "first" | "count" | "avg" | "max" | "min". No "last". Without reduceToValue, the series array passes through to the renderer and the stat shows [object Object],....
Gauge widget delta
visualization: "gauge" renders the same scalar as a stat, but on a 180° radial arc. It needs the same reduceToValue transform as a stat widget. Add gauge presentation under display:
"display": {
"unit": "percent",
"gauge": { "min": 0, "max": 100 },
"thresholds": [
{ "value": 70, "color": "var(--chart-3)" },
{ "value": 90, "color": "var(--destructive)" }
]
}
display.thresholds color the arc (the highest threshold ≤ the value wins) and place tick marks. gauge.min/max default to 0/100. Arc color falls back to var(--chart-1) when no threshold matches.
Threshold lines on time-series charts
display.thresholds also works on chart widgets — each entry draws a dashed horizontal ReferenceLine across line/area/bar charts, with an optional label. Reuse it to mark SLO/alert boundaries.
Valid aggregation values per dataSource
From normalizeTraceAggregation / normalizeMetricsAggregation in apps/web/src/components/dashboard-builder/ai/normalize-widget-proposal.ts:148:
- traces:
count,avg_duration,p50_duration,p95_duration,p99_duration,error_rate - metrics:
rate,increase,avg,sum,count,min,max,p50,p95,p99 - logs:
count
rate / sum / increase are invalid for traces — a common mistake when porting metrics widgets to traces.
groupBy prefix conventions
The query-builder groupBy accepts a small literal allowlist per source; everything else
MUST be prefixed attr.<key>. A token that isn't recognized and isn't attr.-prefixed is
dropped — and now causes the widget mutation tools to reject the write rather than silently
breaking the breakdown.
- traces: bare literals are only
service/service.name,span/span.name,status/status.code,http.method,none/all. Every other attribute needs theattr.prefix —attr.maple.signal,attr.error.type,attr.http.response.status_code,attr.maple.org_id, etc. (Writingerror.typebare ingroupBydoes NOT work — useattr.error.type.) - logs: bare literals only —
service/service.name,severity,none. Logs do not supportattr.*group by. - metrics: bare
service/none; everything else usesattr.—attr.signal,attr.status,attr.org_id.
If a groupBy on an attribute finds zero distinct values, the chart collapses to a single
all series. inspect_chart_data now flags this as EMPTY_GROUPING (verdict broken) instead
of silently showing the ungrouped total.
display.unit is mandatory
Always set display.unit on chart and stat widgets. The default is "number". Pick more specific where applicable:
duration_msfor latency aggregations (avg_duration,p50_duration,p95_duration,p99_duration)percentforerror_ratenumberforcountbytes/GBfor size aggregations
Hiding auxiliary queries on charts with formulas
When a chart uses formulas and the auxiliary queries shouldn't render on their own, query.hidden: true is not enough on its own for the raw-JSON path — that flag is only consumed by the UI builder to generate the actual transform. For raw JSON, pair it with dataSource.transform.hideSeries.baseNames:
"transform": {
"hideSeries": { "baseNames": ["A", "B"] }
}
baseNames matches each hidden query's legend || name. Otherwise the auxiliary series render at full scale and skew percent-axis charts to absurd values (raw counts showing as "1200%").
Verification — MCP success ≠ query success
The widget mutation tools now do two things automatically that used to be silent gaps:
- Pre-persist rejection: unhonored where-clause/groupBy clauses fail the write (nothing saved) — so a
IS NULL, a 6th attr filter, or an unsupported logs/metrics key surfaces as an error instead of degrading to wrong/empty data. - Auto-validation: the response includes an
inspect_chart_datasummary (verdict + flags).inspect_chart_datanow also evaluatesformulas[](so formula/hit-rate widgets verify end-to-end) and appliesreduceToValuewith the same first-numeric-field fallback the renderer uses (so stat-tilereducedValuereflects what renders).SUSPICIOUS_GAPis informational only — sparse/bursty data no longer downgrades the verdict.
Still confirm after submitting:
- Read the returned validation summary; if
verdictissuspicious/broken, fix and resubmit. - Call
mcp__maple__inspect_chart_datafor a deeper look, ormcp__maple__get_dashboardto read back the stored JSON, or load the dashboard URL.
Verdict flags worth knowing: EMPTY_GROUPING (groupBy found zero distinct values → one all series), METRIC_NOT_FOUND (a metrics widget references a metric name not in the warehouse — distinct from a real metric with no recent data), BUILDER_WARNINGS (a clause the engine couldn't honor).
If you see Invalid input for getQueryBuilderTimeseries, the culprit is almost always Trap 1 (metric fields on a trace/log query) or a malformed query draft.
Rebuilding many widgets at once
To replace a dashboard's whole widget list in one validated, atomic write, use
mcp__maple__replace_dashboard_widgets (widgets_json = a JSON array of widget objects, same
shape as widgets[] from get_dashboard). id and layout are optional per widget
(auto-generated / auto-placed). Every widget is validated before anything persists — if one
widget's query can't be honored, nothing is saved. This is the safe middle ground between N
incremental add_dashboard_widget calls and the corruption-prone full dashboard_json replace.
Quick checklist before submitting widget JSON
- Trace/log queries omit
metricName/metricType/isMonotonic/signalSource; metrics queries includemetricName+metricType. -
whereClauseuses only=,!=,>,<,>=,<=,contains,!contains,exists,!exists, joined byAND(no SQLIS NULL). -
aggregationis valid for the chosendataSource(norate/sumon traces). -
groupByuses the right prefix: bare only for the per-source allowlist (traces:service/span/status/http.method); every other attribute needsattr.<key>(traces & metrics). Logs: bareservice/severityonly. -
display.unitis set (and matches the aggregation —duration_ms,percent, etc.). - Stat widgets include
dataSource.transform.reduceToValue. - Formula charts with hidden queries include
dataSource.transform.hideSeries.baseNames. - After submitting, read the auto-validation summary; verify
suspicious/brokenwidgets withinspect_chart_dataor by loading the dashboard.
Version History
- 01a5dc6 Current 2026-07-05 18:16


