references/panel-config.md
# Panel `config` Reference
Source of truth: `webapp/src/pages/explorer/v2/utils/zod-schemas.ts` (`panelConfigSchema`).
**Every field has a `.catch()`, and so does the whole object.** `config: {}` is always
valid and inherits all defaults. A bad value never fails import — it silently reverts to
the default. Unknown keys are stripped.
## Full field list
| Field | Type / allowed values | Default |
|---|---|---|
| `lineWidth` | number 0–10 | `1.5` |
| `fillOpacity` | number 0–100 | `0` |
| `stackSeries` | boolean | `false` |
| `showTrend` | boolean | `false` |
| `showLabel` | boolean | `false` |
| `showPercentageChange` | boolean | `false` |
| `percentageChangeColorMode` | `standard` \| `invert` \| `same_as_value` | `standard` |
| `mergeTables` | `z.literal(true)` — always true, cannot be disabled | `true` |
| `legendVisibility` | boolean | `true` |
| `legendPlacement` | `bottom` \| `right` | `bottom` |
| `tooltipMode` | `single` \| `all` | `single` |
| `yAxisLabelFormatter` | one of the 199 units below | `auto` |
| `yAxisMin` | number \| null | `null` |
| `yAxisMax` | number \| null | `null` |
| `yAxisIncludeZero` | boolean | `false` |
| `step` | `"auto"` \| **positive integer** | `auto` |
| `thresholds` | array, see below | one default entry |
| `thresholdDisplayMode` | see below | `off` |
| `valueOptions` | `{show, calculation}` | `{show:"calculate",calculation:"last"}` |
| `colorScheme` | 5-variant union, see below | `{type:"palette",palette:"default"}` |
| `tableSettings` | `{columnWidths: Record<string, number>}` | `{columnWidths:{}}` |
| `alignColumns` | array, see below | `[]` |
| `columnFormatting` | array, see below | `[]` |
| `topListDisplayMode` | `flat` \| `stacked` | `flat` |
| `visualFormattingRules` | array, see below | `[]` |
`step` must be an *integer* — `60.5` silently becomes `"auto"`.
## `thresholdDisplayMode`
```
off | lines | lines_dashed | filled_regions | filled_regions_and_lines | filled_regions_and_lines_dashed
```
There is **no `enableThresholds` field** — it was removed. Use this instead.
## `thresholds[]`
```json
{ "threshold": 80, "color": "#e24d42", "isDefault": false, "isLabelShown": true, "label": "warn" }
```
| Field | Type | Default |
|---|---|---|
| `threshold` | number \| `"-Infinity"` \| `"Infinity"` | `"-Infinity"` |
| `color` | **any string** — hex expected | `"#56a64b"` |
| `isDefault` | boolean | — |
| `isLabelShown` | boolean, optional | — |
| `label` | string, optional | — |
The default `thresholds` value is **not** `[]` — it is one entry:
```json
[{ "threshold": "-Infinity", "color": "#56a64b", "isDefault": true, "isLabelShown": false, "label": "" }]
```
`color` is an unconstrained string, so named colors like `"green"` validate but will not
render as intended. The named constants in the codebase are hex: `#56a64b` (green),
`#ef843c` (orange), `#e24d42` (red).
## `colorScheme`
Discriminated on `type` — five variants:
```json
{ "type": "palette", "palette": "default" }
{ "type": "single", "color": "#e24d42" }
{ "type": "shades", "color": "#e24d42" }
{ "type": "thresholds", "seriesReducer": "last" }
{ "type": "custom", "colors": ["#e24d42", "#56a64b"] }
```
- `palette` — only `default`, `success`, `warning`, `error`. (`classic`, `warm`, `cool`,
`vivid`, `muted` do **not** exist and silently fall back to `default`.)
- `seriesReducer` — `last` \| `min` \| `max`.
- `custom.colors` — at least one entry, each matching
`/^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/`.
There is **no `colorPalette` field** — it was replaced by `colorScheme`.
## `valueOptions`
```json
{ "show": "calculate", "calculation": "last" }
```
`show` is the literal `"calculate"`. `calculation` — 10 values:
```
mean | standard_deviation | sum | max | min | median | last | first | range | min_above_zero
```
## `alignColumns[]`
```json
{
"id": "col-1",
"displayName": "",
"mappings": [ { "queryLabel": "A", "field": "namespace", "is_attribute": false } ]
}
```
Used to align columns across queries in a table panel.
## `columnFormatting[]`
Per-column overrides for a **table** panel — display name, visibility, cell rendering, and
colouring. One entry per column you want to change; a column with no entry renders with its
default name and a plain numeric cell. Ignored by non-table panels.
```json
{
"id": "b1e2…",
"columnId": "count-a",
"cellType": "number",
"colorMode": "conditions",
"conditions": [ { "operator": ">", "value": 100, "style": "red-background" } ],
"range": { "palette": "green", "scale": "logarithmic", "min": null, "max": null },
"displayName": "Requests",
"hidden": false,
"width": 120
}
```
| Field | Type / allowed values | Default |
|---|---|---|
| `id` | string — a uuid, the array key | required |
| `columnId` | string — the rendered column id this applies to | required |
| `cellType` | `number` \| `bar` | `number` |
| `colorMode` | `conditions` \| `range` | `conditions` |
| `conditions` | array of condition objects, see below | `[]` |
| `range` | object, see below | omitted |
| `displayName` | string | omitted |
| `hidden` | boolean | omitted |
| `width` | positive number (px) | omitted |
`columnId` is the id the renderer assigns, which differs by table shape: a merged dimension
column is its metric key (`service`, `@service`), a merged value column is the kebab-cased
query label (`a`, `count-a`), and an SPL/SQL column is `<name>-<index>`. A `columnId` matching
nothing currently rendered is **not** an error — the entry simply does not apply, which is
what lets a saved panel survive a query edit.
`displayName` is the outermost of the column-naming layers and wins over
`alignColumns[].displayName`, `columnFields[].alias`, and the field-catalog label. An empty or
whitespace-only value counts as absent (the derived name shows instead).
**`conditions[]`** — **last** match wins, in list order (not first, not most-specific).
A threshold list reads as escalating, so `> 10 green` above `> 15 red` paints 58 red; put
the most specific rule at the bottom:
| Field | Type | Notes |
|---|---|---|
| `operator` | `>` \| `>=` \| `<` \| `<=` \| `=` \| `!=` | required |
| `value` | number | required |
| `style` | see list below | `red-background` on a bad value |
| `color` | string (hex) | read only for `custom-background` / `custom-text` |
`style` values: `red-background`, `yellow-background`, `green-background`,
`light-red-background`, `light-yellow-background`, `light-green-background`, `red-text`,
`yellow-text`, `green-text`, `custom-background`, `custom-text`.
**`range`** — continuous colouring, read when `colorMode` is `range`:
| Field | Type | Default |
|---|---|---|
| `palette` | see list below | `green` |
| `scale` | `linear` \| `logarithmic` | `logarithmic` |
| `min` | number \| null (`null` = derive from data) | `null` |
| `max` | number \| null (`null` = derive from data) | `null` |
`palette` values: `green`, `orange`, `red`, `blue`, `red-green`, `red-blue`, `solid-green`,
`solid-orange`, `solid-red`, `solid-blue`, `solid-red-green`, `solid-red-blue`. Gradient
palettes go transparent→colour; two-hue ramps name their direction (`red-green` = red at the
minimum, green at the maximum).
> [!NOTE]
> **`bar`** cells are always coloured by `conditions` — the bar length carries the
> magnitude, so `range` applies to `number` cells only.
>
> `columnFormatting` ships with the panel field-overrides release. A server that predates it
> strips the key (like any unknown field), so the panel renders unformatted rather than
> failing import.
## `topListDisplayMode` and `visualFormattingRules[]`
Both are **top-list only**. Other panel types ignore them.
### `topListDisplayMode`
`flat` (default) draws one block per row. `stacked` splits each row's bar by the group-bys
**after the first**: the first group-by becomes the row, the rest become the blocks inside
it, with a legend underneath.
Stacking therefore needs **two or more `groupBy` entries** (or, for SPL/SQL panels, two or
more entries in the query's `list`). With one dimension the renderer falls back to `flat`
whatever this says — so `stacked` on a single-group-by panel is stale rather than broken,
and removing a group-by from a stacked panel cannot break it.
```json
{
"panelType": "topList",
"queries": [ { "selectedMode": "traces", "label": "A",
"groupBy": [ {"field": "service", "type": "string", "is_attribute": false},
{"field": "status", "type": "string", "is_attribute": false} ],
"aggregation": {"function": "row_count"} } ],
"config": { "topListDisplayMode": "stacked" }
}
```
### `visualFormattingRules[]`
Value-driven block colouring. **Supersedes `thresholds` on top-list panels** — the editor
no longer offers a Thresholds section there. Nothing rewrites `thresholds`, so a panel
saved with them keeps that colouring until its rule list is non-empty; the first rule takes
over.
```json
{ "id": "0c8f…", "operator": ">", "value": 1000, "style": "light-red-background" }
```
| Field | Type | Notes |
|---|---|---|
| `id` | string — a uuid, the array key | required |
| `operator` | `>` \| `>=` \| `<` \| `<=` \| `=` \| `!=` | required |
| `value` | number | required |
| `style` | see below | `light-red-background` on a bad value |
| `color` | string (hex) | read only for `custom-background` |
`style` values — **light backgrounds and custom only**, a narrower set than
`columnFormatting`'s conditions: `light-red-background`, `light-yellow-background`,
`light-green-background`, `custom-background`. A solid or `*-text` style is repaired to
`light-red-background`.
**Last** match wins, as with `columnFormatting[].conditions`.
Three behaviours worth knowing before writing rules:
- Every **block** is judged by its OWN value, not the row's. On a stacked row the rule marks
the block that breached; on a flat row the single block is worth the row total, so the two
agree.
- Once **any** rule exists, colour stops encoding the series and starts encoding the value:
every block matching no rule collapses to one neutral colour, and the legend's swatches
collapse with it. Remove every rule and the palette returns.
- `custom-background` with no `color` is **inert** — the rule paints nothing rather than
failing import. The editor seeds a colour when you pick it; a hand-written preset has to
supply one.
Colouring with no rules at all: a single group-by paints every row the same colour, and
multiple group-bys use the palette per series.
> [!NOTE]
> `topListDisplayMode` and `visualFormattingRules` ship with the top-list stacking release.
> A server that predates them strips the keys (like any unknown field), so the panel renders
> flat and unformatted rather than failing import.
## `yAxisLabelFormatter` — all 199 values
Also the enum for `fieldConfig.unit` on any query.
> [!WARNING]
> `percent`, `percent_unit`, `short`, `ops`, `bps`, `celsius`, `fahrenheit`, and `none` are
> **not** in this list. They silently become `auto`. Use `percentage`, `number`,
> `mCPU`/`CPU`, `bytes/sec` instead.
**general** — `auto`, `number`, `raw`, `percentage`
**cpu** — `mCPU`, `CPU`
**time** — `nanoseconds`, `microseconds`, `milliseconds`, `seconds`, `minutes`, `hours`,
`days`
**data** — `bytes`, `bytes_iec`, `bytes_si`, `bits_iec`, `bits_si`, `kibibytes`,
`kilobytes`, `mebibytes`, `megabytes`, `gibibytes`, `gigabytes`, `tebibytes`, `terabytes`,
`pebibytes`, `petabytes`
**data_rate** — `bytes/sec`, `packets_per_sec`, `bytes_per_sec_iec`, `bytes_per_sec_si`,
`bits_per_sec_iec`, `bits_per_sec_si`, `kibibytes_per_sec`, `kibibits_per_sec`,
`kilobytes_per_sec`, `kilobits_per_sec`, `mebibytes_per_sec`, `mebibits_per_sec`,
`megabytes_per_sec`, `megabits_per_sec`, `gibibytes_per_sec`, `gibibits_per_sec`,
`gigabytes_per_sec`, `gigabits_per_sec`, `tebibytes_per_sec`, `tebibits_per_sec`,
`terabytes_per_sec`, `terabits_per_sec`, `pebibytes_per_sec`, `pebibits_per_sec`,
`petabytes_per_sec`, `petabits_per_sec`
**currency** — `dollars`, `pounds`, `euro`, `yen`, `rubles`, `hryvnias`, `real`,
`danish_krone`, `icelandic_krona`, `norwegian_krone`, `swedish_krona`, `czech_koruna`,
`swiss_franc`, `polish_zloty`, `bitcoin`, `milli_bitcoin`, `micro_bitcoin`,
`south_african_rand`, `indian_rupee`, `south_korean_won`, `indonesian_rupiah`,
`philippine_peso`, `vietnamese_dong`, `turkish_lira`, `malaysian_ringgit`, `cfp_franc`,
`bulgarian_lev`, `guarani`, `inr`
**energy** — `watt`, `kilowatt`, `megawatt`, `gigawatt`, `milliwatt`,
`watt_per_square_meter`, `volt_ampere`, `kilovolt_ampere`, `volt_ampere_reactive`,
`kilovolt_ampere_reactive`, `watt_hour`, `watt_hour_per_kilogram`, `kilowatt_hour`,
`kilowatt_min`, `megawatt_hour`, `ampere_hour`, `kiloampere_hour`, `milliampere_hour`,
`joule`, `electron_volt`, `ampere`, `kiloampere`, `milliampere`, `volt`, `kilovolt`,
`millivolt`, `decibel_milliwatt`, `milliohm`, `ohm`, `kiloohm`, `megaohm`, `farad`,
`microfarad`, `nanofarad`, `picofarad`, `femtofarad`, `henry`, `millihenry`, `microhenry`,
`lumens`
**data science / concentration** — `ppm`, `ppb`, `ng_m3`, `ng_Nm3`, `ug_m3`, `ug_Nm3`,
`mg_m3`, `mg_Nm3`, `g_m3`, `g_Nm3`, `mg_dL`, `mmol_L`
**acceleration** — `meters_per_sec2`, `feet_per_sec2`, `g_unit`
**angle** — `degrees`, `radians`, `gradian`, `arc_minutes`, `arc_seconds`
**area** — `square_meters`, `square_feet`, `square_miles`, `acres`, `hectares`
**flow** — `gallons_per_min`, `cubic_meters_per_sec`, `cubic_feet_per_sec`,
`cubic_feet_per_min`, `litre_per_hour`, `litre_per_min`, `millilitre_per_min`, `lux`
**force** — `newton_meters`, `kilonewton_meters`, `newtons`, `kilonewtons`
**hash_rate** — `hashes_per_sec`, `kilohashes_per_sec`, `megahashes_per_sec`,
`gigahashes_per_sec`, `terahashes_per_sec`, `petahashes_per_sec`, `exahashes_per_sec`
**mass** — `milligram`, `gram`, `pound`, `kilogram`, `metric_ton`
**length** — `millimeter`, `inch`, `feet`, `meter`, `kilometer`, `mile`
**pressure** — `millibars`, `bars`, `kilobars`, `pascals`, `hectopascals`, `kilopascals`,
`inches_of_mercury`, `psi`
**radiation** — `becquerel`, `curie`, `gray`, `rad`, `sievert`, `millisievert`,
`microsievert`, `rem`, `exposure`, `roentgen`, `sievert_per_hour`,
`millisievert_per_hour`, `microsievert_per_hour`
## Picking a unit
| Data | Unit |
|---|---|
| Trace `duration` aggregation | `nanoseconds` (auto-scales to µs/ms/s) |
| Ratio or error rate (already ×100) | `percentage` |
| Memory, disk, payload size | `bytes` |
| Throughput | `bytes/sec` |
| CPU from `rate(container_cpu_usage_seconds_total[5m])` | `CPU`, or `mCPU` after ×1000 |
| Plain counts | `number` |
references/variables-and-rows.md
# Variables and Sub-Grids (Rows)
Read this when the dashboard needs **template variables** (a dropdown the user picks from
that feeds into query filters) or **rows** (collapsible groups of panels). Neither is
required — omit `variables`, `subGrids`, and `subGridLayout` entirely and they default to
`[]`.
## Variables
```json
{
"name": "service",
"description": "",
"meta": { "variableType": "custom", "options": ["api", "web"], "value": [], "selectType": "multiple" }
}
```
- `name`: required, non-empty, **max 20 chars**, must match `^[a-zA-Z_][a-zA-Z0-9_]*$`.
- `description`: **required** — use `""`. It is not optional.
- `meta`: discriminated union on `variableType`. There is no `id`, `label`, or
`multiSelect`.
- `selectType`: `"single"` or `"multiple"`.
| variableType | required in `meta` |
|---|---|
| `textbox` | — (`defaultValue`, `value` default to `""`) |
| `custom` | `options: string[]` with ≥ 1 entry |
| `logs` / `traces` | `fieldMeta: {field, type, is_attribute}` — all three; optional `filters`, `filterMode` (`MFD`\|`ADVANCED_QUERY`) |
| `metrics` | `metric` (non-empty) **and** `fieldMeta`; **no `filterMode`** |
`fieldMeta.type` and `fieldMeta.is_attribute` have no defaults — both must be present.
> [!WARNING]
> **One invalid variable silently deletes every variable** with no import error — the
> `variables` array carries a `.catch([])`. Double-check `description: ""` is present and
> the name matches the regex.
### Referencing a variable
A variable is referenced as `$name` in a query `filters` value:
```json
{ "namespace": ["$service"] }
```
A metrics query's own `variables` key is **auto-derived** from its filters and promql — do
not set it by hand, anything you supply is overwritten.
### Examples
A `custom` list the user types out:
```json
{
"name": "env",
"description": "",
"meta": { "variableType": "custom", "options": ["prod", "staging"], "value": ["prod"], "selectType": "single" }
}
```
A `logs` variable populated from a real field's values:
```json
{
"name": "namespace",
"description": "",
"meta": {
"variableType": "logs",
"fieldMeta": { "field": "namespace", "type": "string", "is_attribute": false },
"selectType": "multiple"
}
}
```
A `metrics` variable populated from a label on a metric — note it needs both `metric` and
`fieldMeta`, and must **not** carry `filterMode`:
```json
{
"name": "pod",
"description": "",
"meta": {
"variableType": "metrics",
"metric": "container_cpu_usage_seconds_total",
"fieldMeta": { "field": "pod", "type": "string", "is_attribute": false },
"selectType": "single"
}
}
```
## Sub-Grids (Rows)
```json
"subGrids": [ { "id": "row-1", "title": "Payments", "collapsed": false, "panels": [], "gridLayout": [] } ],
"subGridLayout": [ { "i": "sg-row-1", "x": 0, "y": 0, "w": 12, "h": 1 } ]
```
`id` and `title` are required on a sub-grid. In `subGridLayout`, `i` **must** be
`` `sg-${id}` `` — and only `y` is honoured; `x`, `w`, `h` are forced to `0`, `12`, and a
computed value. Row order comes from `y`.
A sub-grid's inner `gridLayout` is optional-chained with fallbacks, so it may be shorter
than its `panels` — unlike the top-level one, which crashes at render if it is short.
Panels inside a sub-grid use the same panel shape as top-level panels, and the sub-grid's
own `gridLayout` uses the same `{i,x,y,w,h}` shape on the same 12-column grid.
> [!WARNING]
> A bad `subGrids` entry drops **all** sub-grids; a bad entry inside one sub-grid's
> `panels` empties **that row's** panels. Both are silent.
SKILL.md
---
name: kubesense-dashboards
description: Create KubeSense dashboards over metrics, logs, and traces — either directly with the create-dashboard MCP tool or as preset JSON the user imports — with the exact schema, the fields that hard-fail import, and the fields that silently discard your data instead of erroring. Includes validate-dashboard-json for checking a preset before you commit to it.
metadata:
version: "2.3.0"
author: kubesense
repository: https://github.com/kubesense-ai/kubesense-mcp-skills
tags: kubesense,dashboards,panels,json,import,preset,visualization
---
# KubeSense Dashboards
Build a dashboard preset — the same shape the **Export** button produces, so an exported
dashboard can be edited and re-imported.
Two ways to deliver it:
| | `create-dashboard` MCP tool | Preset JSON |
|---|---|---|
| Applied by | the agent (write tool, needs approval) | the user, via Dashboards → Import |
| Use when | the user asked you to *create* one | they asked for the JSON, or want to review first |
| Shape to emit | the preset object itself | the import envelope, with `preset` **stringified** |
Both go through the same server-side schema check, so a preset that passes
`validate-dashboard-json` is one that both paths accept.
## When to Use
- "Create a dashboard for…" / "Build a dashboard with panels for…" → `create-dashboard`
- "Give me the dashboard JSON for…" → preset JSON
- "Generate a dashboard preset" → preset JSON
## Always Validate Before You Finish
Call **`validate-dashboard-json`** on the preset before creating it or handing it over.
It stores nothing, so call it as often as you need.
```
# valid=false findings=2
path rule message
/panels/0/queries/0/selectedMode shape value must be one of 'logs', 'metrics', 'traces', 'formula'
/gridLayout/0 shape must have required property 'h'
```
Each `path` is a JSON Pointer to the exact value to fix. Repeat until `valid=true`.
Pass the **preset object** as `document` — not the import envelope with the stringified
`preset` field. Same for `create-dashboard`'s `preset` argument.
Validation checks *shape*, not whether the data exists. A query against a metric nobody
collects is well-formed and will render empty, so discover names first.
## Discover Fields First
If the KubeSense MCP server is connected, discover real names before writing queries —
never guess a metric, group-by, or filter field.
| To find | Tool |
|---|---|
| Metric names | `get-available-metrics` |
| Metric labels (for `by`) | `get-metric-labels` |
| Log/trace fields | `get-trace-or-log-fields` |
Dashboard queries use **storage-level field names**, not the MCP catalog labels — the
webapp posts these directly. Take field names from the discovery output's storage column
where they differ, and tell the user to confirm on the panel preview. See
[kubesense-mcp](../kubesense-mcp/SKILL.md) for the label/storage distinction.
Without MCP, fall back to user-provided names and say they need verifying.
## Output Format
```json
{
"name": "<dashboard name>",
"description": "",
"preset": "<stringified JSON of the preset object>"
}
```
> [!IMPORTANT]
> **In this envelope, `preset` is a JSON *string*, not a nested object.** The validator
> declares `preset: z.string()` and only runs the preset schema after `JSON.parse(preset)`.
> Emitting an object fails with *"Preset must be a valid JSON string that matches the
> dashboard preset schema"*.
>
> This applies to the **import envelope only**. `create-dashboard` and
> `validate-dashboard-json` take the preset object itself — passing a stringified blob to
> them works too, but do not wrap it in this envelope.
`name` is required and non-empty. `description` is optional.
The object you stringify:
```json
{
"gridLayout": [],
"panels": [],
"variables": [],
"subGrids": [],
"subGridLayout": []
}
```
Only `gridLayout` and `panels` are required; the rest default to `[]`. A sixth optional
key, `publicDashboardPath` (string), also validates.
## Minimal Valid Dashboard
Verified against the live validator:
```json
{
"name": "Minimal",
"description": "",
"preset": "{\"gridLayout\":[{\"i\":\"0\",\"x\":0,\"y\":0,\"w\":12,\"h\":4}],\"panels\":[{\"name\":\"Up\",\"panelType\":\"timeSeries\",\"queries\":[{\"selectedMode\":\"metrics\",\"label\":\"A\",\"selectedMetric\":\"up\",\"functions\":[]}],\"config\":{}}],\"variables\":[],\"subGrids\":[],\"subGridLayout\":[]}"
}
```
## What Hard-Fails Import
Get these wrong and import is rejected with an error:
1. `preset` not a string.
2. Missing or empty top-level `name`.
3. Missing `gridLayout` or `panels`.
4. A `gridLayout` item missing any of `i`, `x`, `y`, `w`, `h`, or with a wrong type
(`"6"` instead of `6`).
5. A panel missing `name`, or `name: ""`.
6. A panel missing the `queries` array (it may be `[]`, but the key must exist).
7. A query whose `selectedMode` is not `metrics` | `logs` | `traces` | `formula`.
8. **A logs/traces query missing `columnFields`** — the single hard-required field on
those queries.
9. A `columnFields[]` entry with `field: ""`.
10. A formula missing `expression`, or one containing lowercase letters or a decimal point.
11. A formula referencing an undefined label, referencing itself, placed *before* the
queries it references, or with a multi-character label.
## What Silently Destroys Your Data
More dangerous than hard failures — these validate cleanly and then discard what you sent.
Almost every field carries a `.catch()`, so **a single bad value resets its whole array or
object to the default**.
| Field | On a bad value |
|---|---|
| `functions` (metrics query) | **The entire array is wiped to `[]`** — one malformed entry loses every function |
| `variables` | **All variables deleted** — one invalid variable empties the array |
| `subGrids` | All sub-grids dropped |
| `subGrids[].panels` | That row's panels emptied |
| `aggregation` | Resets to `{"function": "row_count"}` |
| `panelType` | Resets to `"timeSeries"` |
| `chart_type` | Resets to `"table"` |
| `yAxisLabelFormatter` | Resets to `"auto"` |
| `colorScheme` | Resets to `{"type":"palette","palette":"default"}` |
| `step` | Non-integer (e.g. `60.5`) resets to `"auto"` |
| `pagination` | `page_size > 500` resets the whole object to `{page:1,page_size:50}` |
Because of this, **prefer omitting an optional field over guessing its value** — an
omitted field takes the default; a wrong one can take out its siblings.
> [!WARNING]
> **`gridLayout` must have at least as many entries as `panels`.** Panels and layout are
> matched **positionally by array index**, not by the `i` value. A short `gridLayout`
> passes validation and then throws a TypeError when the dashboard renders. The `i` string
> is only used to identify sub-grid rows (via a `sg-` prefix); real exports set it to the
> index (`"0"`, `"1"`, …).
## Panel
```json
{
"name": "CPU by namespace",
"description": "",
"panelType": "timeSeries",
"queries": [],
"config": {}
}
```
`panelType` — 9 values: `timeSeries`, `stat`, `table`, `list`, `bar`, `pie`, `topList`,
`spl`, `sql`. Note camelCase `timeSeries`.
`config` can never fail import (the whole object catches), so `{}` is always safe and
inherits every default. For the full field list, defaults, and the `colorScheme` /
`thresholds` / `alignColumns` / `columnFormatting` / `visualFormattingRules` shapes, read
**[references/panel-config.md](./references/panel-config.md)**.
For a **table** panel, per-column display names, visibility, cell type (`number` / `bar`),
and threshold/range colouring go in `config.columnFormatting[]` — see the reference.
For a **top-list** panel, `config.topListDisplayMode` is `flat` (default) or `stacked`, and
`stacked` needs **two or more `groupBy` entries** — the first is the row, the rest are the
blocks stacked inside it. With one dimension the renderer draws flat whatever the field
says. Value-driven colouring goes in `config.visualFormattingRules[]`, which **supersedes
`thresholds` for this panel type**. Both are in the reference.
Three config fields the old format got wrong:
- **`thresholdDisplayMode`**, not `enableThresholds`. Values: `off` (default), `lines`,
`lines_dashed`, `filled_regions`, `filled_regions_and_lines`,
`filled_regions_and_lines_dashed`.
- **`colorScheme`**, not `colorPalette`. A 5-variant union; the palette variant is
`{"type":"palette","palette":"default"}` and the only palette keys are `default`,
`success`, `warning`, `error`.
- **`mergeTables`** is `z.literal(true)` and lives in the defaults — it is **always
`true`** and cannot be disabled. Sending `false` silently becomes `true`. Just omit it.
### Y-axis units
`yAxisLabelFormatter` accepts **199 values** — the same enum as `fieldConfig.unit`. Common
ones: `auto`, `number`, `percentage`, `bytes`, `bytes/sec`, `nanoseconds`, `milliseconds`,
`seconds`, `mCPU`, `CPU`. Use `nanoseconds` for any panel aggregating trace `duration` —
the formatter auto-scales ns → µs/ms/s. Full list in
[references/panel-config.md](./references/panel-config.md).
> [!WARNING]
> `percent`, `percent_unit`, `short`, `ops`, `bps`, `celsius`, `fahrenheit`, and `none`
> **do not exist** and silently become `auto`. Use `percentage`, `CPU` / `mCPU`,
> `bytes/sec`, `number` instead.
## Queries
Discriminated on `selectedMode`: `metrics` | `logs` | `traces` | `formula`. Note `spl` is
**not** a valid `selectedMode` — use `panelType: "spl"` with a logs/traces query and
`filterMode: "SPL"`.
### Metrics query
```json
{
"selectedMode": "metrics",
"label": "A",
"selectedMetric": "container_cpu_usage_seconds_total",
"functions": [],
"filters": {},
"queryMode": "builder",
"promql": "",
"visible": true,
"labelOptions": { "type": "auto" },
"pagination": { "page": 1, "page_size": 50 },
"fieldConfig": {}
}
```
Every field is optional. `queryMode` is `builder` or `code` — use `code` with `promql`
set, `builder` with `selectedMetric` + `functions`.
`variables` is **auto-derived** from filters and promql (`$name` references) — anything you
supply is overwritten. Don't bother setting it.
### Logs / traces query
```json
{
"selectedMode": "logs",
"label": "A",
"columnFields": [],
"filters": {},
"queryMode": "builder",
"filterMode": "MFD",
"aggregation": { "function": "row_count" },
"groupBy": [],
"chart_type": "table",
"visible": true,
"sorting": {
"sortBy": { "field": "", "type": "string", "is_attribute": false },
"sortOrder": "ASC"
},
"query": "",
"pagination": { "page": 1, "page_size": 50 },
"fieldConfig": {}
}
```
> [!IMPORTANT]
> **`columnFields` is required** — include it on every logs/traces query, even as `[]`.
> Omitting it is a hard import failure. Entry shape:
> `{"field": "namespace", "type": "string", "is_attribute": false}` with optional `alias`
> and `label`. `field` must be non-empty.
- `chart_type` — 7 values: `table`, `stat`, `bar`, `pie`, `topList`, `timeseries`, `list`.
**Lowercase `timeseries`** — `timeSeries` silently becomes `table`. This is a query-level
field, separate from the panel-level `panelType`.
- `filterMode` — `MFD`, `ADVANCED_QUERY`, `SPL`, `SQL`. Use `ADVANCED_QUERY` with a `query`
string for anything MFD's equality-only filters can't express (e.g. a latency threshold).
- `groupBy` entries use the same shape as `columnFields`.
- `list` (array) and `value` (string) are the SPL/SQL column fields. There is no
`topListLabel`/`topListValue`.
**`aggregation` is strict about `type`:**
```json
{ "function": "p99", "fields": [ { "field": "duration", "type": "float", "is_attribute": false } ] }
```
- `row_count` — no `fields`.
- `unique_count` — `fields` required, `type` is `"float"` or `"string"`.
- `avg`, `sum`, `max`, `min`, `p99`, `p95`, `p90`, `p75`, `p50` — `fields` required and
`type` must be **literally `"float"`**.
Get any of that wrong — missing `fields`, `type: "string"` on a numeric aggregation, or
`function: "count"` (not a valid name) — and the whole aggregation silently resets to
`row_count`, giving you a row count where you asked for a percentile.
### Formula query
```json
{ "selectedMode": "formula", "label": "C", "expression": "A/B", "visible": true, "fieldConfig": {} }
```
`expression` must match `^[A-Z0-9+/*()-]+$` after whitespace is stripped. Consequences:
- **Uppercase only** — `a+b` fails.
- **No decimal point** — `A*1.5` fails. Use `A*3/2`.
- Labels are **single letters A–Z**; a two-character label breaks the dependency check.
- The formula must appear **after** the queries it references in the `queries` array.
- It cannot reference itself, or a label that doesn't exist.
### Filters
`filters` is `Record<string, string[]>`:
```json
{ "namespace": ["production"], "level": ["ERROR", "FATAL"] }
```
- Attribute keys are prefixed `@_@`, e.g. `"@_@user.id": ["abc"]`.
- Exclusion is a `-` prefix on the **value**: `{"namespace": ["-kube-system"]}`.
- A `$name` value is a dashboard-variable reference.
## Metrics Query Functions
`functions` is an ordered pipeline. **One malformed entry wipes the entire array**, and
argument counts are exact tuples — so build these carefully.
| type | names | arguments |
|---|---|---|
| `range` | `rate`, `increase`, `resets` | `[{arg_name:"over", arg_value:"5m"}]` — any string |
| `aggregations` | `sum`, `avg`, `max`, `min`, `count`, `No_Aggregations` | `[{arg_name:"by", arg_value:["namespace"]}]` — must be an **array** |
| `top_bottom` | `top`, `bottom` | `[{arg_name:"k",arg_value:5},{arg_name:"by",arg_value:"max"}]` — `by` ∈ `max\|min\|avg\|median\|last` |
| `rollup` | `avg_over_time`, `sum_over_time`, `max_over_time`, `min_over_time`, `count_over_time`, `last_over_time`, `absent_over_time`, `present_over_time`, `increases_over_time`, `range_over_time`, `quantile_over_time` | `[{arg_name:"over", arg_value:"5m"}]` — **restricted to `30s\|1m\|5m\|30m\|1h\|1d`** |
| `comparison` | `greater`, `lesser`, `greater_than_or_equal`, `less_than_or_equal`, `equal`, `not_equal` | `[{arg_name:"than"\|"to", arg_value:100}]` |
| `transform` | `clamp` (`min`+`max`), `clamp_max` (`max`), `clamp_min` (`min`), `round` (`to_nearest`), `histogram_quantile` (`quantile`), `abs` / `sort` / `sort_desc` (none) | arg names in parens; the `arguments` key is **still required** — use `[]` for the zero-arg ones |
> [!WARNING]
> `range.over` accepts any string, but **`rollup.over` only accepts
> `30s`, `1m`, `5m`, `30m`, `1h`, `1d`**. A `rollup` with `over: "7m"` silently deletes
> every function on that query.
Typical time-series pipeline: `rate` then `aggregations`.
```json
"functions": [
{ "type": "range", "name": "rate", "arguments": [ { "arg_name": "over", "arg_value": "5m" } ] },
{ "type": "aggregations", "name": "sum", "arguments": [ { "arg_name": "by", "arg_value": ["namespace"] } ] }
]
```
## Grid Layout
```json
{ "i": "0", "x": 0, "y": 0, "w": 6, "h": 4 }
```
All five keys required. 12-column grid, `rowHeight` 100px, panel `minH` 2. No bounds are
validated — `w: 99` is accepted and renders broken.
- `x`: 0–11. Side by side: `0, 6` for two columns; `0, 4, 8` for three.
- `y`: increment by the previous row's height.
- `i`: the array index as a string. **Matching is positional**, so keep `gridLayout` in the
same order as `panels`, and at least as long.
## Variables and Rows
Both are optional and most dashboards need neither — omit `variables`, `subGrids`, and
`subGridLayout` and they default to `[]`.
If the dashboard needs a **template variable** (a dropdown feeding `$name` into query
filters) or **rows** (collapsible panel groups), read
**[references/variables-and-rows.md](./references/variables-and-rows.md)** for the schemas.
Two things to carry into that file: a variable's `description` is required (use `""`), and
one invalid variable silently deletes every variable.
## Delivering It
### Creating it directly
```
validate-dashboard-json → valid=true → create-dashboard
```
`create-dashboard` takes `name`, `preset` (the object, or a JSON string containing it) and
an optional `description`. It returns the dashboard id and its UI path — quote that path so
the user can open it.
It is a **write tool**: only call it when the user has clearly asked for a dashboard to be
created, and say what you are about to create before calling. If it refuses, the response
names the JSON Pointer for every problem; fix them and retry rather than falling back to
handing over JSON.
### Handing over JSON to import
1. Copy the JSON to a `.json` file (the import UI requires `application/json`).
2. KubeSense → **Dashboards → Import**.
3. Upload and review, then save.
On this path never claim the dashboard was created — the user imports and confirms it.
## Rules
Rule 0: run `validate-dashboard-json` before creating or handing over, and fix what it
reports. Everything in [What Hard-Fails Import](#what-hard-fails-import) is machine-checked
— the validator names the JSON Pointer, so it needs no checklist here.
**This checklist is for what the validator is blind to.** Every rule below **passes
validation** and then silently discards your data or crashes at render. A green validation
is not a working dashboard — check these by hand before you hand it over.
1. `gridLayout` must be at least as long as `panels`, in the same order — matching is
positional, and a short layout throws a TypeError when the dashboard renders.
2. Numeric aggregations need `fields[].type: "float"` exactly, or the whole aggregation
resets to `row_count` — a row count where you asked for a percentile.
3. `chart_type` is lowercase `timeseries`; `panelType` is camelCase `timeSeries`. Each
resets to its own default (`table` / `timeSeries`) on a mismatch.
4. `rollup.over` only accepts `30s`/`1m`/`5m`/`30m`/`1h`/`1d`; a bad value wipes every
function on that query.
5. Variables need `description` (use `""`) and a regex-valid `name` ≤ 20 chars — one bad
variable deletes them all.
6. Use real y-axis units (`percentage`, `mCPU`, `bytes/sec`) — `percent`, `short`, `none`
become `auto`.
7. Don't emit `enableThresholds`, `colorPalette`, `mergeTables: false`, `topListLabel`, or
`topListValue` — none exist, and unknown keys are stripped without comment. On a **top
list**, value colouring goes in `visualFormattingRules`, not `thresholds`.
8. Prefer omitting an optional field to guessing it: an omitted field takes its default, a
wrong one can reset its siblings.
9. Validation checks shape, not existence. Discover metric and field names with MCP before
writing queries, and tell the user to confirm on the panel preview.
The one rule that spans both: `preset` is a stringified JSON string in the import envelope,
but the plain object when passed to `create-dashboard` or `validate-dashboard-json`.