# dsh-grafana

[简体中文](./README.zh-CN.md)

A DeepSeek Harness plugin for agent-native Grafana observability — dashboards, live metrics, trends, alerts and cross-source investigation, with safe, reviewable dashboard updates. It operates on Grafana APIs and dashboard JSON directly; no screenshots required.

> Project status: pre-1.0. The safety controls and automated tests cover the core update path, but Grafana 12+ compatibility is not yet certified.

## Why dsh-grafana

- Fetch a dashboard by browser URL or UID, or request a compact structural summary for large dashboards.
- Search dashboards by title and tag.
- Query the live data behind panels by pasting the dashboard or panel-view URL.
- Duplicate a dashboard into a brand-new copy and get its URL back.
- Edit panels, queries, thresholds, variables, and layout through conversation.
- Preserve the dashboard folder automatically.
- Detect concurrent edits before writing.
- Require native DSH user approval for every write.
- Keep service-account credentials in the local DSH credential store.
- Configure several named Grafana sources and target one per tool call.

## Requirements

| Component | Supported baseline |
| --- | --- |
| Node.js | 20.11 or newer |
| DeepSeek Harness | Default development dependency: `dsh-tools@0.2.0-rc.2`; additional automated dependency checks cover `0.2.1-alpha.1`. Historical baselines: `0.1.0-rc.6`, `0.1.1-rc.2`, `0.1.2-rc.1`, `0.1.3-alpha.2`, `0.1.5-rc.1`, `0.1.7-rc.2`. Real host and browser acceptance is recorded separately in `INTEGRATION.md`. |
| Grafana | Legacy Dashboard HTTP API as documented for Grafana 10/11 |

Grafana 12 introduced a new dashboard API. The legacy endpoints used by this plugin may remain available, but Grafana 12+ is not part of the certified matrix yet.

Standard npm dependency resolution and DSH host preflight handle prereleases differently: the `0.2.0-rc.2` and `0.2.1-alpha.1` host checks enable `includePrerelease`. The plugin still declares the `0.2.1` tuple explicitly for standard dependency resolution; this does not certify the alpha host, Desktop or browser experience.

There is no build step: the plugin is plain ESM JavaScript, so installing it is enough — nothing to compile or bundle before it loads.

## Installation

Install a released, immutable tag whenever possible:

```bash
dsh plugin --profile <profile> add github:guhanfei-ai/dsh-grafana#v<version>
```

Install the mutable development branch only for testing:

```bash
dsh plugin --profile <profile> add github:guhanfei-ai/dsh-grafana
```

For local development:

```bash
npm ci
dsh plugin --profile <profile> add link:/absolute/path/to/dsh-grafana
```

Restart the selected DSH profile after installation.

On Windows, use an absolute `link:C:/path/to/dsh-grafana` path. The plugin itself is cross-platform.

## Upgrade notes

### 0.12.0: renamed tools and older host UI

**Two tools were renamed.** `grafana_query` is now `grafana_panel_query`, and `grafana_health` is now `grafana_status`. Parameters, outputs, timeouts, and approval behavior are unchanged.

| Before | After |
| --- | --- |
| `grafana_query` | `grafana_panel_query` |
| `grafana_health` | `grafana_status` |

The old names stay registered as error-only stubs: calling `grafana_query` or `grafana_health` fails immediately with a message naming the new tool, so an ongoing conversation recovers in one step. Custom prompts and saved workflows that reference the old names should still be updated, and per-agent tool allowlists need the new names.

**The browser settings card requires DSH 0.1.2 or newer.** On older hosts (0.1.0–0.1.1) the card shows an explicit "host too old" notice instead of the source list — that is a version gate, not lost configuration. All host-side tools keep working on those hosts, and the plugin still installs on `0.1.0-rc.6` and newer.

### 0.17.x: settings UI and configuration migration

Hosts exposing declaration inspection and the `settings.section` slot provide an independent **Grafana** item in the Settings left navigation, with the configuration card expanded by default. Hosts without that section fall back to the Grafana plugin tab or legacy embedded card; hosts without declaration inspection retain the original compatibility registrations. Selection follows actual host capabilities, not version strings, and inspecting hosts register only one supported entry. Moving the entry does not change configuration, source UIDs or token storage.

**Configuration migrates automatically.** Existing named sources in the pre-upgrade `settings.yaml.imported` take priority over a legacy single-source credential; otherwise a single-source configuration is materialized as a named `default` source (same base URL, same stored token). The importer reads `$DSH_HOME/settings.yaml.imported`, or `~/.dsh/settings.yaml.imported` when `DSH_HOME` is unset. This maintenance cycle keeps the optional `js-yaml` dependency on v4 to preserve existing aliases and `<<` merges; only the absence of that package itself enables the restricted parser for the known basic Grafana section shape.

File-reading, YAML-parsing or import-write failures stop migration with a redacted reason, without inferring a legacy source, changing credentials or rewriting the import file. After repairing the configuration or dependency, the maintainer can reload the plugin to retry. Migration does not overwrite a configuration saved concurrently.

## Configuration

Open **Settings → Grafana in the left navigation** (after Built-in plugins) to see the expanded configuration panel. If the host cannot provide an independent section, use **Settings → Plugins (or Built-in plugins) → Grafana**; hosts supporting only the legacy slot use the embedded Grafana assistant card.

The Grafana configuration page defaults to Simplified Chinese in both web and desktop installations, independently of the browser or system language. An explicit English selection in DSH language settings is honored; an absent, unsupported or unreadable preference uses Simplified Chinese.

> Note: the independent section takes precedence over the plugin tab, which takes precedence over the legacy embedded card. Compatibility cards keep their collapsed default. After upgrading, the maintainer can reload the selected profile and refresh the page to load the new plugin code. Development-directory watching/HMR depends on the actual host setup; an edited source file alone does not prove the running UI has updated.

Configure:

- **Service Account Token**: a Grafana service-account token such as `glsa_...`.
- **Grafana URL**: the absolute base URL, for example `https://grafana.example.com` or `https://example.com/grafana`.

The token uses DSH's privileged loopback credential RPC — write-only, the stored value is never read back or displayed. The URL is stored in the `grafana` settings namespace as a non-secret field, so it is read back in plaintext and shown in the card for verification. The UI supports replacing and removing each value.

HTTP and HTTPS both work out of the box — internal deployments without TLS certificates can use an `http://` URL with no extra setup. Note that plain HTTP sends the service-account token in cleartext; always use HTTPS over untrusted networks. To enforce HTTPS only, disable it in plugin configuration:

```yaml
allowInsecureHttp: false
```

Read-only mode is available for monitoring and troubleshooting roles that must never modify dashboards:

```yaml
readOnly: true
```

When enabled, `grafana_push` and `grafana_clone` are not registered at all — no dashboard write tool exists for the model to call. The settings card shows the current mode and lets you switch it with the **Read-only mode** toggle at the top of the card (ON = read-only, OFF = read-write). Toggling writes the plugin-level `readOnly` setting only; the configured sources and the default source are untouched.

Switching to read-only takes effect immediately: even when the write tools were registered at startup, the approval gate denies them at runtime. Switching back to read-write may require restarting DSH (or reloading the plugin), depending on when the plugin was loaded: a plugin that started in read-only mode never registered the write tools, and they only come back after a restart or reload.

The settings `baseUrl` is the authoritative source; a legacy `GRAFANA_BASE_URL` credential (from earlier versions) is migrated into settings on startup and then used only as a fallback. The token reference defaults to `GRAFANA_TOKEN` and can be changed with `tokenRef`.

### Multiple Grafana sources

The settings card manages a **list of named Grafana sources** rather than a single URL/token pair. Each source has:

- **Source name** (required, unique): any language. This is the value you pass as the `source` argument to target that instance.
- **UID** (read-only): an auto-generated, globally unique id shown in faint text right under the name. It is the stable internal key — renaming a source never changes its UID or its stored token, and it can never be edited.
- **Grafana URL** and **Service Account Token**: independent per source. Tokens are stored write-only in the DSH credential store. New sources start with a `GRAFANA_TOKEN_<uid>` reference; rotating a saved token creates a fresh reference. Migration preserves the existing reference, including custom references.

Click **Add source** to create one, **Remove source** to delete it, and **Set as default** to choose the source used when a tool call omits `source`. **Save this source** saves one card; **Save all sources** saves every card. Names and URLs are validated before writing. Removal deletes the source configuration in the current profile only; its credential is retained. Profiles may share the DSH credential store, and the current settings cannot prove that another profile no longer uses a reference. Automatic credential cleanup and its retry action are therefore disabled.

Saving requires a successfully read configuration. Failed or malformed reads leave the card read-only with a **Reload** button. When the host provides a configuration revision, writes carry it so stale pages cannot overwrite newer changes.

Token changes switch to a fresh credential reference in the settings write. A definite rejection leaves the old configuration active; a lost reply is not treated as proof that nothing was committed. Every path retains credentials written or possibly written and explains the retention; successful rotation also retains the old credential. Successfully saved token drafts are cleared while other unsaved edits are preserved. Retained credentials may accumulate. Removing a source does not revoke its Grafana token; revoke the relevant token in Grafana when access must end.

Token status is `configured`, `missing` (confirmed absent), or `unknown` (unconfirmed). A missing credential facade, failed read or malformed reply shows **Status unavailable**, with **Reload** always available; `grafana_sources` reports `token=unknown` without exposing credential values or provider errors. Configured means a credential exists, not that Grafana authentication succeeds. Draft persistence is tracked by the settings baseline, independently of token status.

Every tool accepts an optional `source` argument (a configured source name); omit it to use the default source. Call `grafana_sources` to list the configured names, UIDs, base URLs, which token is configured, and which source is the default. Write approvals always show the target source name and URL on the first line so you can confirm which instance is modified, and the write is bound to that source: if the default source changes while the approval is pending, the write is refused instead of going to whichever instance happens to resolve at execution time. A read/approval/write chain is also bound to the source's URL and credential reference, so changing a source's address invalidates snapshots taken before the change — matching `id`/`uid`/`version` alone does not prove two responses came from the same instance. A single-source configuration from earlier versions is migrated on startup into one source named `default`, keeping its custom `tokenRef` if it had one. `allowInsecureHttp` remains a global setting that applies to all sources.

### Grafana permissions

Prefer least-privilege RBAC with only the required dashboard and folder scopes:

- `dashboards:read`
- `dashboards:write`
- `folders:read` for the folders being edited
- `datasources:query` plus access to the datasources queried by `grafana_panel_query`

When fine-grained RBAC is unavailable, Grafana's Editor role is the fallback. Avoid Admin tokens.

The exact scope each tool needs:

| Tool | Grafana permission |
| --- | --- |
| `grafana_get` | `dashboards:read` |
| `grafana_push` | `dashboards:read` + `dashboards:write` |
| `grafana_clone` | `dashboards:read` + `dashboards:write` |
| `grafana_panel_query` | `dashboards:read` + `datasources:query` |
| `grafana_datasources` | `datasources:read` |
| `grafana_metric` | `datasources:read` + `datasources:query` |
| `grafana_compare` | `datasources:read` + `datasources:query` (per source) |
| `grafana_trend` | `dashboards:read` + `datasources:query` |
| `grafana_alerts` | `alert.instances:read`; `definitions: true` also needs `alert.provisioning:read`; `ruleStates: true` needs its own rules-read permission (a refusal is reported in place with the missing scope) |
| `grafana_search` | `dashboards:read` |
| `grafana_status` | `dashboards:read` (see note) |
| `grafana_sources` | none (reads local plugin configuration only) |

A read-mostly setup is the Viewer basic role plus Grafana's fixed read-only Alerting role; add `dashboards:write` (or the Editor basic role) only for the token that runs `grafana_push` / `grafana_clone`. Note on `grafana_status`: `/api/health` needs no authentication, so the tool confirms the credential by calling `GET /api/search` and reading the instance health from the `database` field of `/api/health` (there is no `status` field).

## Coverage

What sets this plugin apart from a single-purpose Grafana bridge:

- **Multiple named sources** (up to 50): every tool takes an optional `source` argument and write approvals name the target instance, so one plugin serves a whole fleet of Grafanas.
- **Credentials never touch the settings document**: tokens live write-only in the DSH credential store and are reached through a privileged loopback RPC, so they are never read back, displayed, or synced.
- **A browser settings card**: sources are added, edited, and validated from a native settings UI — no hand-editing of config files.

The tool surface covers the full read-to-write loop:

| Capability | Tool |
| --- | --- |
| Read a dashboard (full JSON or structural summary) | `grafana_get` |
| Edit a dashboard | `grafana_push` |
| Write / create | `grafana_push`, `grafana_clone` |
| Clone a dashboard | `grafana_clone` |
| Search dashboards | `grafana_search` |
| Live panel values | `grafana_panel_query` |
| Trend of a dashboard's series | `grafana_trend` |
| Ad-hoc bare query (PromQL / LogQL) | `grafana_metric` |
| Cross-source metric comparison | `grafana_compare` |
| Datasource discovery | `grafana_datasources` |
| Active alerts & rule definitions | `grafana_alerts` |
| Source & credential health | `grafana_status`, `grafana_sources` |
| Multiple sources | every tool via `source` |

## Tools

Every tool accepts an optional `source` argument (a configured source name) to pick the target Grafana instance; omit it to use the default source. See [Multiple Grafana sources](#multiple-grafana-sources).

| Tool | Behavior |
| --- | --- |
| `grafana_get` | Fetches the complete dashboard and records a short-lived trusted version/folder snapshot. With `summary: true` it returns a compact structural overview (panels, queries, thresholds, variables) instead of the full JSON and records no write snapshot — preferred for large dashboards. |
| `grafana_push` | Updates a recently fetched dashboard after approval, identity checks, version checks, and folder preservation. |
| `grafana_clone` | Duplicates a dashboard into a brand-new dashboard (fresh UID, version 1), keeps the source folder by default, and returns the new dashboard URL. Requires approval and a subsequent `grafana_get` before further writes. |
| `grafana_panel_query` | Executes the panel datasource queries behind a pasted dashboard or panel-view URL (`?viewPanel=` limits the query to that single panel; the URL `from`/`to` range is honored) and returns a bounded summary of the live values. Template variables use saved dashboard state by default; override with the `variables` argument — single values (`{"env":"prod"}`), multi-values (`{"host":["www","m"]}`, expanded per the query's format modifier), or adhoc filters (see [Template variable overrides](#template-variable-overrides-grafana_panel_query)). Adhoc filters are translated per datasource type: Elasticsearch targets get Lucene clauses, Prometheus/Loki see label matchers injected into every vector/stream selector, and SQL datasources get the `${__adhoc}` placeholder replaced with a WHERE clause; other datasource types with active adhoc filters throw an explicit error listing the support matrix. Adhoc overrides replace saved filters entirely — `[]` clears them — and are applied per target datasource uid, so a variable bound to one datasource never touches another. Unsupported operator/datasource combinations throw instead of being silently dropped. Only `query`/`custom`/`interval`/`adhoc`/`textbox`/`constant`/`datasource` variable types can be overridden (datasource variables take a uid string); unsupported types throw an error. For Prometheus/Loki targets a bare multi-value variable renders as `(a|b)` so it works inside `=~` matchers. Legacy datasource references are resolved automatically: plain string uids and `{"uid":"$datasource"}` references to datasource-type variables are resolved via `GET /api/datasources` (the saved `"default"` maps to the default datasource). Server-side expressions (`$__expr__`, e.g. `$A / 60`) pass through untouched, panels that fail variable interpolation are skipped instead of aborting the whole dashboard — with each skipped panel's id, title, and reason listed when nothing remains — and a failed batch request automatically falls back to per-panel queries (the whole selection stays a single batch POST whenever possible, keeping `$A`-style expression references intact). Read-only; records no write snapshot. |
| `grafana_datasources` | Lists the datasources provisioned on a source (uid, plugin type, display name, whether it is the default, access mode, and the configured URL — a `url="(empty)"` row usually means a misconfigured datasource whose queries will fail, so pick another uid). Filter by exact plugin type or a case-insensitive name substring. Results are paged (default 40 rows per page; `limit` changes the page size, `page` fetches later pages), with a final line reporting the page, the total, and how to continue. Call it before `grafana_metric` to learn which uid or name to query. Read-only. |
| `grafana_metric` | Runs one bare-text query (PromQL such as `up` or `rate(http_requests_total[5m])`, or a LogQL stream selector) directly against a Prometheus or Loki datasource, addressed by uid or exact display name — no dashboard needed. `mode: "instant"` (default) evaluates once at the range end; `mode: "range"` samples the series and reports per-series stats, a rising/falling/flat verdict, and a sparkline — with the actual sampling step on the header line (`step=`) and the returned point count per series (`points=`), so the precision and coverage of the answer are visible (a range query against loki returns log lines rather than numeric samples, so those series report a line count and the last line instead; on loki, instant mode accepts metric queries only — use range for log-stream selectors). Other plugin types and server-side expressions are rejected with a pointer to `grafana_panel_query`. Read-only; records no write snapshot. |
| `grafana_compare` | Runs the same Prometheus query against the corresponding datasource on multiple configured Grafana sources (2-10 at a time) and returns a compact side-by-side comparison — addresses the datasource by display name or uid, but resolves it independently per source so two Grafana instances with different UIDs for the same logical datasource still line up. When every requested source succeeds and returns one comparable scalar (in `range` mode, a series with complete first/last/min/max/avg stats), the output ends with a small numeric summary (highest, lowest, average, max/min ratio); a per-source failure, no-data, multi-series, or non-comparable-shape result never drops the successful ones — those sources are listed individually with the reason direct comparison is unavailable, and every error is sanitized and bounded (no credentials, no upstream paths). Read-only; records no write snapshot and triggers no approval. |
| `grafana_trend` | Answers "is it going up or down?" for a dashboard's panels in one call: every visible query target is re-run as a coarse range query and each series is reported with the returned point count, bucket count, first/last/min/max/avg, a direction verdict, and a sparkline; the header line discloses the actual sampling step. Table-shaped results report rows and stats with `trend=n/a` rather than a fabricated direction. Uses the same panel pipeline as `grafana_panel_query` (variables, adhoc filters, legacy datasource references, per-panel fallback). The range may span at most 90 days. Read-only; records no write snapshot. |
| `grafana_alerts` | Lists the alerts currently firing on a source from the built-in Alertmanager (default `state=firing`; `"suppressed"` for silenced/inhibited, `"all"` for both). The upstream `active` state (Alertmanager v2's "firing") is reported as `state=firing`, so a normally firing alert is never dropped as unknown. Filter by folder, a case-insensitive substring across labels and annotations, or a dashboard URL/uid. Active alerts are capped at the `limit` argument (default 30, max 100) with anything dropped disclosed on a final budget line. `definitions: true` appends the provisioned alert rule definitions in a second request (its own permission, its own failure isolation). `ruleStates: true` appends the evaluation state of every rule (`rule-state` lines: `inactive`, `pending`, `firing`, `recording`, `unknown`) from the Prometheus-compatible rules API — `pending` means the condition is met but the `for` duration has not elapsed, which the Alertmanager view cannot answer; filter that section with `ruleState`. Both rule sections are paged with `rulesPage` (100 rows per page) and disclose the total and the next page. Read-only; alert text is untrusted data. |
| `grafana_search` | Searches by optional title text and exact tag, returning at most 50 rows. |
| `grafana_status` | Checks connectivity and service-account validity. |
| `grafana_sources` | Lists the configured Grafana sources: each name, its read-only UID, base URL, whether its token is configured, and which one is the default. Read-only; never returns token values. Use it to discover valid source names before passing `source` to other tools. |

### Cross-source comparison examples (`grafana_compare`)

`grafana_compare` runs the same PromQL query against the corresponding datasource on every listed source and returns one compact comparison view, so a multi-source question takes one tool call instead of three plus manual comparison. Common patterns:

Region comparison — same P99 latency across regions:

```json
{
  "sources": ["tokyo", "singapore", "us"],
  "datasource": "Prometheus",
  "query": "histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket{service=\"payment\"}[5m])))"
}
```

Environment comparison — error rate between production and staging:

```json
{
  "sources": ["prod", "staging"],
  "datasource": "Prometheus",
  "query": "sum(rate(http_requests_total{status=~\"5..\"}[5m])) / sum(rate(http_requests_total[5m]))"
}
```

Cluster comparison — node CPU across Grafana clusters A, B, C with a longer range:

```json
{
  "sources": ["cluster-a", "cluster-b", "cluster-c"],
  "datasource": "Prometheus",
  "query": "100 - (avg by (instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[2m])) * 100)",
  "mode": "range",
  "from": "now-15m",
  "to": "now",
  "points": 60
}
```

When every requested source succeeds and returns one comparable scalar the output ends with a small summary (highest, lowest, average, max/min ratio); in `range` mode that means a series carrying complete first/last/min/max/avg stats. If any source returns multiple series, fails, returns no data, or returns a shape the current mode cannot compare (a table or log frame with no time axis in `range` mode), the summary is omitted and each source is shown with its per-series breakdown or sanitized error instead, along with why direct comparison is unavailable — a high-cardinality or partial result can never be misread as a direct scalar comparison.

### Template variable overrides (`grafana_panel_query`)

The `variables` argument is a JSON object keyed by variable name. Every overrideable variable in the dashboard (`query`/`custom`/`interval`/`adhoc`/`textbox`/`constant`/`datasource` types) can be overridden; unsupported types throw an explicit error.

Single value — replaces the variable everywhere it appears (`$env`, `${env}`):

```json
{ "env": "prod" }
```

Multi-value — pass an array. The expansion follows the Grafana format modifier used in the query itself, so dashboards written for multi-select variables keep working:

```json
{ "host": ["www.example.com", "m.example.com"] }
```

For **Prometheus and Loki targets** a bare multi-value reference (`$host` with no modifier) renders as `(www.example.com|m.example.com)` — the alternation form that works inside `=~` label matchers, matching Grafana's own rendering. Values are not regex-escaped (Grafana does not escape them either; escaping a `.` as `\.` inside a double-quoted PromQL string is a syntax error). Use the explicit `${host:regex}` modifier when you need exact matching.

| Query placeholder | Expands to |
| --- | --- |
| `$host` / `${host}` | `www.example.com,m.example.com` (CSV, Grafana default) |
| `${host:csv}` | `www.example.com,m.example.com` |
| `${host:doublequote}` | `"www.example.com","m.example.com"` |
| `${host:singlequote}` | `'www.example.com','m.example.com'` |
| `${host:json}` | `["www.example.com","m.example.com"]` |
| `${host:raw}` | `www.example.com,m.example.com` |
| `${host:pipe}` | `www.example.com\|m.example.com` |
| `${host:percent}` | each value URL-encoded, comma-joined (`www.example.com,m.example.com`; `["a b"]` → `a%20b`) |
| `${host:querystring}` | `host=www.example.com&host=m.example.com` (keyed by the variable name) |
| `${host:regex}` | `www\.example\.com\|m\.example\.com` (each value regex-escaped, joined with `\|`) |
| `${host:lucene}` | each value Lucene-escaped, space-joined |
| `${host:sqlstring}` | `'www.example.com','m.example.com'` (single quotes doubled inside values) |

A single-value variable without a modifier expands to the bare value (byte-for-byte `String(value)`); modifiers apply to single values too (`${host:json}` → `"www.example.com"`, `${host:pipe}` → `www.example.com`). Unknown format modifiers throw an error. Built-in variables (`$__interval`, `$__rate_interval`, `${__from:date}`, …) always pass through untouched.

Adhoc filter override — replace the dashboard's saved adhoc filters entirely (`[]` clears them):

```json
{
  "adhoc": [
    { "key": "host.keyword", "operator": "=", "value": "www.example.com" },
    { "key": "status", "operator": "!=", "value": "404" }
  ]
}
```

An unbound adhoc entry applies to every datasource; add `"datasourceUid": "<uid>"` to bind it to one datasource. Translation depends on the datasource type:

| Datasource type | Translation | Supported operators |
| --- | --- | --- |
| Elasticsearch | Lucene clause merged into each target's query string (`host.keyword:"www.example.com"`; a non-empty panel query is wrapped in parentheses and combined with `AND`) | `=` `!=` always; `>` `<` numeric only; `=~` `!~` as Lucene regex `field:/pattern/` (`/` inside the pattern is escaped; an empty pattern throws) |
| Prometheus | Label matchers injected into every vector selector (`host="www.example.com"`; bare metric names get `{...}` added) | `=` `!=` `=~` `!~`; `>` `<` throw |
| Loki | Matchers injected into the stream selector (`{app="api", host="www.example.com"}`); pipeline stages are left untouched | `=` `!=` `=~` `!~`; `>` `<` throw |
| SQL (MySQL/Postgres/MSSQL/MariaDB/SQLite/ClickHouse) | `${__adhoc}` / `$__adhoc` placeholder in `rawSql` replaced with a `WHERE`-style clause (`host = 'www.example.com'`; values single-quote-escaped) | `=` `!=` `>` `<` (numeric) `=~` `!~` (mapped to `LIKE`/`NOT LIKE`) |
| Anything else | Explicit error listing the supported types | — |

Datasource-type variables — override with a datasource uid string:

```json
{ "datasource": "prom-prod" }
```

Panels whose datasource references the variable (`{"type":"prometheus","uid":"$datasource"}`) are re-pointed at the given uid. A non-string value (number, array) throws an explicit error.

### Legacy datasource references (`grafana_panel_query`)

Older dashboards reference datasources in shapes that `/api/ds/query` cannot use directly. `grafana_panel_query` resolves them transparently:

| Panel datasource shape | Resolution |
| --- | --- |
| Plain string uid (`"fixture-dash-0002"`-style, Grafana 8 and earlier) | Looked up via `GET /api/datasources`; the resolved `{type, uid}` is sent with each query |
| `{"uid":"$datasource"}` / `{"type":"prometheus","uid":"$datasource"}` (datasource-type template variable) | The variable's saved `current` value is interpolated into the uid |
| Saved value `"default"` | Mapped to the server's default datasource (`"default"` is a reserved pseudo-uid that `/api/ds/query` rejects) |
| Index unavailable (403) or uid unknown | The raw `{uid}` is passed through so Grafana itself reports the problem; if active adhoc filters cannot be translated for an untyped datasource, an explicit error is thrown instead |

The datasource index is fetched lazily — only when a dashboard actually contains references that need resolution. When every panel is skipped, the error lists each panel's id, title, and skip reason instead of a bare "no executable query".

A real-machine verification matrix (variable overrides × operators × datasource types, legacy dashboard shapes) is recorded in [INTEGRATION.md](INTEGRATION.md).

### Safe update workflow

1. Ask DSH to fetch a dashboard URL or UID.
2. Describe the requested changes.
3. Review the write approval prompt, including the dashboard identity and change summary.
4. Approve or reject the update.
5. Refresh Grafana and fetch again before another write.

`grafana_push` defaults to `overwrite: false`. It preserves the current folder, re-fetches the dashboard immediately before writing, and rejects stale versions. Folder moves require `allowFolderMove: true`. Forced overwrite requires `forceOverwrite: true` and still triggers approval.

The dashboard identity shown in the approval prompt (uid, title, version, folder) and the "fetched X minutes ago" label come entirely from the server-trusted snapshot recorded by `grafana_get`, never from the model-submitted dashboard JSON, so a hallucinated or tampered title cannot mislead the approver. Without a recent trusted snapshot the prompt states that the write will be rejected and asks for `grafana_get` first. Right before the prompt appears, the plugin also re-checks the live Grafana state: version or folder mismatches are surfaced as prominent warnings with both version numbers, and an unreachable Grafana is called out as "unable to confirm the current state". This live check only enriches the approval copy; the final validation always runs again at write time. When the live check succeeds, the prompt also includes a bounded, sanitized content diff between the current Grafana-side dashboard and the proposed JSON (panels, template variables, and top-level fields added, removed, or changed), so the approver can verify the actual change instead of relying on the model-supplied change summary alone.

## Security and data boundaries

- Tokens never enter tool arguments, model messages, logs, or Git.
- Authenticated requests reject HTTP redirects to avoid forwarding credentials to another origin.
- Non-loopback plain HTTP is allowed by default (can be disabled in configuration).
- Requests have cooperative cancellation, timeouts, bounded responses, and bounded dashboard input.
- Error responses expose only a bounded status/message description.
- Grafana content is treated as untrusted data, not model instructions.

Dashboard JSON can still contain sensitive SQL, internal hostnames, links, labels, and business metadata. Fetching a dashboard sends that JSON to the configured model provider as tool context. Review your model provider's data policy before using this plugin with confidential dashboards.

See [SECURITY.md](./SECURITY.md) for vulnerability reporting and supported-version policy.

## Development

```bash
npm ci
npm run verify
npm pack --dry-run --ignore-scripts
```

Tests use Node's built-in test runner and mocked Grafana responses. The `0.2.0-rc.2` CI baseline covers Node 20, 22 and 24; Node 22 additionally checks `dsh-tools@0.2.1-alpha.1`. CI builds an actual `.tgz` and scans its contents for unexpected identifiers. These checks do not replace real DSH/Grafana acceptance.

See [CONTRIBUTING.md](./CONTRIBUTING.md) and [CHANGELOG.md](./CHANGELOG.md).

## License

[MIT](./LICENSE)
