# Grafana To Perses

> Convert Grafana dashboard JSON into Perses dashboard YAML (PersesDashboard CRD). Use this skill whenever the user wants to migrate, convert, or translate a Grafana dashboard to Perses format, create a new Perses dashboard, or work with PersesDashboard YAML. Also triggers when the user mentions Grafana-to-Perses migration, dashboard porting, or asks about Perses dashboard structure. Even if the user just says "convert this dashboard" or "make a Perses version", this skill applies.

- Skill: `aimikav/grafana-to-perses` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add aimikav/grafana-to-perses`
- Raw SKILL.md: https://api.skillmd.com/api/skills/aimikav/grafana-to-perses/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: AIMikav (https://skillmd.com/u/aimikav)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/aimikav/grafana-to-perses

---


# Grafana to Perses Dashboard Converter

You convert Grafana dashboard JSON into valid Perses dashboard YAML (the `PersesDashboard` Kubernetes CRD). The output must be schema-valid and preserve all queries, variables, panel layouts, and visualizations from the source dashboard.

## Workflow

1. Read the Grafana dashboard JSON (the user will provide it as a file path or paste it)
2. Read `references/mapping-rules.md` in this skill's directory for the complete field-by-field conversion reference
3. Extract and convert each component in this order: metadata → variables → panels → layouts
4. Output a single YAML file as a `PersesDashboard` CRD

## Output Format

The output is always a single YAML file with this top-level structure:

```yaml
apiVersion: perses.dev/v1alpha2
kind: PersesDashboard
metadata:
  name: <dashboard-name-in-kebab-case>
  namespace: <namespace>
spec:
  config:
    display:
      name: <Human Readable Dashboard Title>
    duration: 1h
    variables: [...]
    panels: { ... }
    layouts: [...]
```

## Component Conversion Rules

### 1. Metadata

- `metadata.name`: derive from Grafana `title` — lowercase, replace spaces with hyphens, remove special characters
- `metadata.namespace`: use `open-cluster-management-observability` unless the user specifies otherwise
- `spec.config.display.name`: copy Grafana `title` directly
- `spec.config.duration`: use Grafana `time.from` if relative (e.g., `now-1h` → `1h`), default to `1h`

### 2. Variables (Grafana `templating.list` → Perses `variables`)

Each Grafana variable maps to a Perses `ListVariable` or `TextVariable`:

| Grafana `type` | Perses `plugin.kind` |
|---|---|
| `query` with `label_values(metric, label)` | `PrometheusLabelValuesVariable` |
| `query` with `query_result(...)` | `PrometheusPromQLVariable` |
| `custom` (static options) | `StaticListVariable` |
| `textbox` or `constant` | Use a `TextVariable` instead of `ListVariable` |

**Variable property mapping:**

```yaml
# Grafana
{ "name": "cluster", "label": "Cluster", "multi": true, "includeAll": true,
  "allValue": ".*", "hide": 0, "sort": 1,
  "query": { "query": "label_values(up, cluster)" } }

# Perses equivalent
- kind: ListVariable
  spec:
    name: cluster
    display:
      name: Cluster
      hidden: false
    allowMultiple: true
    allowAllValue: true
    customAllValue: ".*"
    sort: alphabetical-asc
    plugin:
      kind: PrometheusLabelValuesVariable
      spec:
        labelName: cluster
        matchers:
          - "up"
```

**Sort mapping:** `0` → `sort: none`, `1` → `sort: alphabetical-asc`, `2` → `sort: alphabetical-desc`

**Hide mapping:** `0` → `hidden: false`, `1` or `2` → `hidden: true`

### 3. Panels (Grafana `panels` → Perses `panels`)

Panels are stored as a flat map with string keys (`'0'`, `'1'`, `'2'`, ...). Each panel maps like this:

| Grafana `type` | Perses `plugin.kind` |
|---|---|
| `stat` | `StatChart` |
| `gauge` | `StatChart` |
| `timeseries` / `graph` | `TimeSeriesChart` |
| `table` | `Table` |
| `row` | Not a panel — becomes a layout group (see Layouts section) |

**Panel structure:**

```yaml
'0':   # string key, incrementing from 0
  kind: Panel
  spec:
    display:
      name: <panel title>
      description: <panel description, if any>
    plugin:
      kind: <StatChart|TimeSeriesChart|Table>
      spec: <plugin-specific config>
    queries:
      - kind: TimeSeriesQuery
        spec:
          plugin:
            kind: PrometheusTimeSeriesQuery
            spec:
              query: <PromQL expression>
              seriesNameFormat: <legendFormat value, or empty string>
              minStep: ''
```

**Queries:** Each Grafana `targets[]` entry becomes a Perses `queries[]` entry. The `expr` field maps to `query`, and `legendFormat` maps to `seriesNameFormat`. Drop `refId` — ordering is by array position. Preserve `$variable` and `$__range` syntax as-is.

#### StatChart specifics

```yaml
plugin:
  kind: StatChart
  spec:
    calculation: last-number        # from reduceOptions.calcs
    format:
      unit: percent-decimal         # see unit mapping table
    metricLabel: ''
```

- `reduceOptions.calcs: ["lastNotNull"]` → `calculation: last-number`
- `reduceOptions.calcs: ["mean"]` → `calculation: mean`

#### TimeSeriesChart specifics

```yaml
plugin:
  kind: TimeSeriesChart
  spec:
    visual:
      display: line                 # or bar
      lineWidth: 1.5
      areaOpacity: 0
      connectNulls: false
      stack: none                   # or all
    yAxis:
      show: true
      format:
        unit: <unit>
    legend:
      position: bottom
      mode: list
      size: small
```

#### Table specifics

```yaml
plugin:
  kind: Table
  spec:
    density: compact
    columnSettings:
      - name: <field-name>
        header: <display-name>
        hide: false
        enableSorting: true
        format:
          unit: <unit>
        cellSettings:               # for value mappings
          - condition:
              kind: Value
              spec:
                value: '1'
            text: "Active"
          - condition:
              kind: Misc
              spec:
                value: 'null'
            text: "N/A"
    transforms:
      - kind: MergeSeries           # from Grafana "merge" transformation
        spec: {}
      - kind: JoinByColumnValue     # derived from organize transformation
        spec:
          columns:
            - <join-column>
```

**Table column mapping from Grafana:**
- `transformations[].id: "organize"` → extract `excludeByName` (→ `hide: true`), `renameByName` (→ `header`), and `indexByName` (→ column order)
- `fieldConfig.overrides` with `unit` → `format.unit` on matching `columnSettings`
- `fieldConfig.overrides` with `mappings` → `cellSettings` with `condition` entries

### 4. Layouts (Grafana `gridPos` → Perses `layouts`)

Grafana embeds position in each panel via `gridPos: {h, w, x, y}`. Perses separates layout from panels.

**Grafana `row` panels** become layout groups. All panels between one row and the next belong to that group.

If there are no row panels, create a single layout group.

```yaml
layouts:
  - kind: Grid
    spec:
      display:
        title: <row title, or "Overview" if no rows>
        collapse:
          open: true
      items:
        - content:
            $ref: '#/spec/panels/0'
          height: <gridPos.h>
          width: <gridPos.w>
          x: <gridPos.x>
          'y': <gridPos.y relative to the group>
```

The `y` values should be relative to the start of each layout group (reset to 0 for the first panel in each group). Always quote the `y` key as `'y'` since `y` is a YAML boolean alias.

### 5. Unit Mapping

| Grafana | Perses |
|---|---|
| `percentunit` | `percent-decimal` |
| `percent` | `percent` |
| `bytes` | `bytes` |
| `decbytes` | `bytes` |
| `s` | `seconds` |
| `ms` | `milliseconds` |
| `dateTimeAsIso` | `datetime-local` |
| `short` | `decimal` |
| `none` | `decimal` |

## Precision Checklist

Before outputting the final YAML, verify:

1. Every Grafana `targets[].expr` appears verbatim in a Perses `queries[].spec.plugin.spec.query` (no PromQL was lost or altered)
2. Every variable from `templating.list` has a corresponding entry in `variables`
3. Every non-row panel has a corresponding entry in `panels` and is referenced in `layouts`
4. All `$ref` pointers (`#/spec/panels/N`) reference panels that actually exist
5. Panel keys in the `panels` map match what `layouts` reference
6. The `'y'` key is always quoted in YAML to avoid boolean interpretation
7. Unit conversions follow the mapping table exactly
8. Variable references (`$variable`) in PromQL are preserved unchanged

## Reference

For the complete field-by-field mapping reference with additional edge cases and examples, read `references/mapping-rules.md` in this skill's directory.

