fix(grafana): validate against the API docs, add data source querying and contact-point CRUD (#6712)

* fix(azure-data-explorer): correct the tags ingestion-property example

The example rendered as tags="[''daily'']" — doubled single quotes from an
escaping slip, which is not valid Kusto. The reference writes a tags list
as tags='["TagA","TagB"]': single outer quotes with the JSON array's own
double quotes inside.

The clause builder already handled that form; only the example text was
wrong. A template literal avoids the escaping entirely, since the metadata
generator reads the source verbatim and would otherwise carry the
backslashes into the description the model sees.

Adds a test asserting the reference's exact multi-property clause
round-trips, including the comma inside the quoted array.

* fix(grafana): correct response contracts, required alert fields, and outbound request hardening

Validated against Grafana's HTTP API reference and, where the docs
contradict themselves, against the Go wire structs.

Response shapes the tools got wrong:
- update_annotation declared an `id` that was always 0; a patch returns only
  a message, so the request's annotation id is echoed and labelled as such
- delete_folder discarded the numeric id Grafana returns and presented an
  input-echoed uid as if it came from the API
- delete_dashboard fabricated `id: 0` / `title: ''` via `||` on absent fields
- the contact-point `provenance` description was inverted: "api" means
  API-managed, empty means it stayed UI-editable

Requests that could not succeed:
- create_alert_rule left noDataState and execErrState unset and invisible to
  the model, but Grafana's validator rejects an empty value outright, so every
  model-driven create failed. Both are now sent with Grafana's own defaults,
  and skipped for recording rules, which take a different validator
- get_data_source routed a numeric input at /api/datasources/:id, which exists
  only behind an off-by-default feature toggle. UID only now
- list_annotations did not trim the dashboard UID, so a padded value matched
  nothing

Outbound hardening on the three proxy routes:
- the service-account token was re-sent to redirect targets; the shared fetch
  only drops it when asked, so stripAuthOnRedirect is now set
- no timeout was passed, leaving two sequential hops at the 5-minute default
- upstream error bodies were interpolated whole into the tool result, putting
  up to 10MB of HTML into logs and traces; now truncated
- UID path segments are URL-encoded so they cannot re-target the request
- update_folder sent both `version` and `overwrite: true`, which Grafana treats
  as alternatives, making the freshly fetched version decorative and silently
  clobbering a concurrent rename
- replaced the `any` casts with narrowed types

Block surface:
- 25 outputs the tools emit were undeclared and so unreferenceable downstream;
  get_data_source had 13 of its 18 unreachable
- `version` was typed string though the dashboard, folder, and data-source
  producers all emit a number
- the dashboard title field was shown only for create, so a dashboard could
  never be renamed through Update Dashboard
- six list outputs were typed json rather than array

* fix(grafana): let the health check report ill-health, and disambiguate block outputs

The data source health check could only ever report health. Grafana answers an
unhealthy source with HTTP 400 carrying the same {status, message} payload as a
healthy one, and the tool framework converts any non-2xx into an opaque tool
error — so the diagnostic the caller actually wants was unreachable. The check
now goes through an internal route that reads the verdict off either status and
reports it as a successful check, while a failure carrying no verdict (missing
data source, bad token, plugin with no health endpoint) stays a real error. The
plugin's `details` payload is surfaced too.

Also on that route, matching the other three: an outbound timeout, redirect
auth stripping, a truncated upstream error, and a URL-encoded UID.

Block output descriptions: ten keys are emitted by several tools with different
meanings and were described for only one producer — `database` meant both a
data source name and a health status, `annotations` both an annotation list and
an alert rule's summary map. Eleven `json` outputs were opaque although the
tools already document their inner fields. All rewritten to name every producer.

Smaller alignment fixes:
- the same EmbeddedContactPoint.settings field was typed `object` in list and
  `json` in create
- list_contact_points mapped non-nullable uid/name/type through `?? null`;
  Grafana returns an empty string, which is what create already assumed
- create_alert_rule sent `orgID`, which Grafana overwrites from the
  authenticated context, and `Number()` on a non-numeric value put NaN -> null
  in the body
- the three update routes declared `output` as required though the auth
  short-circuit omits it, and did not declare the `details` they emit on a
  validation error

* feat(grafana): complete contact-point CRUD, and add folder move and rule-group read

Four operations the integration was missing, taking it to 29.

update_contact_point / delete_contact_point close a real gap: contact points
could be listed and created but never corrected or removed. Two things worth
recording, because the published docs get both wrong:

- both verbs answer 202 with only a message, not the object. The rendered docs
  claim delete returns 204; the current spec and handler both say 202. So the
  UID is echoed from the request, the way delete_folder and update_annotation
  already do
- update is a full replace with no PATCH counterpart, so name, type, and
  settings are all required and the description says so. Omitting
  disableResolveMessage resets it

X-Disable-Provenance is exposed on update only. Its polarity is the opposite of
the alert-rule case: omitting it always succeeds, while sending it against an
API-provisioned contact point is rejected — with 403, not the 409 rules use. It
is not exposed on delete at all, because that handler never reads stored
provenance and the endpoint takes no such parameter.

move_folder reuses get_folder's mapping verbatim — same DTO. It always sends
the parentUid key, since Grafana reads an empty value as "move to the root",
which a conditionally-omitted field could not express.

get_alert_rule_group surfaces the group evaluation interval, the one alerting
knob the per-rule operations cannot reach. It reuses the shared mapAlertRule for
the nested rules, and the interval is documented as an integer of seconds.

* feat(grafana): add data source querying, and ground the skill and templates in real tools

query_data_source closes the largest gap in the integration: 29 tools could
read dashboards, folders, and alert configuration, but none could read a metric
value. It posts to /api/ds/query and returns both the raw response and the
frames flattened into rows.

The flattening is derived from the documented layout rather than any data
source's field names: a frame carries schema.fields[] alongside data.values[],
where values[i] is the whole column for fields[i], so zipping them by position
works for Prometheus, SQL, or anything else with a backend.

A failed query is a 400 by Grafana's own status table, so it stays a tool
error — unlike the health check, where the failure status carries the answer.

That also lets four templates and the review-firing-alerts skill stop promising
things the integration could not do. Three templates assumed a metric-query
tool, which now exists. The fourth, and the skill, assumed live alert instance
state, which the provisioning API never returns — they now derive firing rules
from alert-state annotations, which are documented to carry newState and
prevState, and say so explicitly rather than implying a live snapshot.

Deliberately not added: a tool over /api/prometheus/grafana/api/v1/rules for
live instance state. That endpoint appears on no Grafana HTTP API doc page, its
response is only readable from Go internals and test assertions, and the
instance-level state casing differs from the rule level with no documented
contract. Not something to build an output schema on.

* fix(grafana): declare the two block outputs the earlier fixes introduced

Renaming update_annotation's phantom `id` to `annotationId` and adding
`details` to the health check both created outputs the block never declared, so
neither was referenceable downstream. Caught by re-running the output-coverage
check over both integrations; the block now covers all 64 keys the 30 tools emit.

* fix(grafana): make Update Contact Point actually usable from the block

The new replace operation could never succeed. contactPointType and
contactPointSettings were widened to cover it, but contactPointNameNew was
left create-only — and the update maps `name` from that field, so the required
parameter was never supplied.

disableResolveMessage had the same gap, and it matters more than it looks:
the update is a full replace, so a block-driven update was silently clearing
resolve suppression on every contact point it touched. Both fields are now
shown, and required where the API requires them.

Also states a reason on each intentionally-unconstrained response field —
Zod issue objects, alert query stages, notification settings, recording-rule
config, and data-source health detail are all genuinely opaque, but that was
left implicit.
This commit is contained in:
Waleed
2026-08-14 15:58:17 -07:00
committed by GitHub
parent 41923b8e95
commit 3848f97b4c
33 changed files with 1798 additions and 138 deletions
@@ -349,7 +349,7 @@ Materialize the result of a KQL query into a table with .set, .append, .set-or-a
| `mode` | string | No | set \(create, fail if it exists\), append \(add to an existing table\), set-or-append \(default\), or set-or-replace \(replace all data\) |
| `sourceQuery` | string | Yes | KQL query whose result becomes the ingested data \(e.g., LogsTable \| where Level == "Error" \| where Timestamp > ago\(1h\)\). Project the columns in the target table\'s order — matching is positional, not by name |
| `async` | boolean | No | Return immediately with an OperationId and keep ingesting in the background. Check progress with Show Operations |
| `ingestionProperties` | string | No | Optional ingestion properties clause contents, e.g. distributed=true, tags=\"\[''daily''\]\" |
| `ingestionProperties` | string | No | Optional ingestion properties clause contents, e.g. distributed=true, tags='\["daily"\]' |
#### Output
@@ -207,7 +207,7 @@ List all alert rules in the Grafana instance
| ↳ `folderUID` | string | Parent folder UID |
| ↳ `ruleGroup` | string | Rule group name |
| ↳ `orgID` | number | Organization ID |
| ↳ `provenance` | string | Provisioning source \(empty if API-managed\) |
| ↳ `provenance` | string | Provisioning source — "api" for API-managed, empty when created with X-Disable-Provenance and therefore still editable in the Grafana UI |
| ↳ `notification_settings` | json | Per-rule notification settings \(overrides\) |
| ↳ `record` | json | Recording rule configuration \(recording rules only\) |
@@ -245,7 +245,7 @@ Get a specific alert rule by its UID
| `folderUID` | string | Parent folder UID |
| `ruleGroup` | string | Rule group name |
| `orgID` | number | Organization ID |
| `provenance` | string | Provisioning source \(empty if API-managed\) |
| `provenance` | string | Provisioning source — "api" for API-managed, empty when created with X-Disable-Provenance and therefore still editable in the Grafana UI |
| `notification_settings` | json | Per-rule notification settings \(overrides\) |
| `record` | json | Recording rule configuration \(recording rules only\) |
@@ -266,8 +266,8 @@ Create a new alert rule
| `condition` | string | No | The refId of the query or expression to use as the alert condition \(required for alerting rules; omit for recording rules\) |
| `data` | string | Yes | JSON array of query/expression data objects |
| `forDuration` | string | No | Duration to wait before firing \(e.g., 5m, 1h\) |
| `noDataState` | string | No | State when no data is returned \(NoData, Alerting, OK\) |
| `execErrState` | string | No | State on execution error \(Error, Alerting, OK\) |
| `noDataState` | string | No | State when no data is returned: NoData \(default\), Alerting, OK, or KeepLast. Ignored for recording rules |
| `execErrState` | string | No | State on execution error: Error \(default\), Alerting, OK, or KeepLast. Ignored for recording rules |
| `annotations` | string | No | JSON object of annotations |
| `labels` | string | No | JSON object of labels |
| `uid` | string | No | Optional custom UID for the alert rule |
@@ -299,7 +299,7 @@ Create a new alert rule
| `folderUID` | string | Parent folder UID |
| `ruleGroup` | string | Rule group name |
| `orgID` | number | Organization ID |
| `provenance` | string | Provisioning source \(empty if API-managed\) |
| `provenance` | string | Provisioning source — "api" for API-managed, empty when created with X-Disable-Provenance and therefore still editable in the Grafana UI |
| `notification_settings` | json | Per-rule notification settings \(overrides\) |
| `record` | json | Recording rule configuration \(recording rules only\) |
@@ -353,7 +353,7 @@ Update an existing alert rule. Fetches the current rule and merges your changes.
| `folderUID` | string | Parent folder UID |
| `ruleGroup` | string | Rule group name |
| `orgID` | number | Organization ID |
| `provenance` | string | Provisioning source \(empty if API-managed\) |
| `provenance` | string | Provisioning source — "api" for API-managed, empty when created with X-Disable-Provenance and therefore still editable in the Grafana UI |
| `notification_settings` | json | Per-rule notification settings \(overrides\) |
| `record` | json | Recording rule configuration \(recording rules only\) |
@@ -397,9 +397,9 @@ List all alert notification contact points
| ↳ `uid` | string | Contact point UID |
| ↳ `name` | string | Contact point name |
| ↳ `type` | string | Notification type \(email, slack, etc.\) |
| ↳ `settings` | object | Type-specific settings |
| ↳ `settings` | json | Type-specific settings |
| ↳ `disableResolveMessage` | boolean | Whether resolve messages are disabled |
| ↳ `provenance` | string | Provisioning source \(empty if API-managed\) |
| ↳ `provenance` | string | Provisioning source — "api" for API-managed, empty when created with X-Disable-Provenance and therefore still editable in the Grafana UI |
### Grafana Create Contact Point
@@ -427,7 +427,7 @@ Create a notification contact point (e.g., Slack, email, PagerDuty)
| `type` | string | Receiver type |
| `settings` | json | Type-specific settings |
| `disableResolveMessage` | boolean | Whether resolve notifications are suppressed |
| `provenance` | string | Provisioning source \(empty if API-managed\) |
| `provenance` | string | Provisioning source — "api" for API-managed, empty when created with X-Disable-Provenance and therefore still editable in the Grafana UI |
### Grafana Create Annotation
@@ -474,7 +474,7 @@ Query annotations by time range, dashboard, or tags
| `userId` | number | No | Filter by ID of the user who created the annotation |
| `tags` | string | No | Comma-separated list of tags to filter by |
| `type` | string | No | Filter by type \(alert or annotation\) |
| `limit` | number | No | Maximum number of annotations to return |
| `limit` | number | No | Maximum number of annotations to return \(Grafana defaults to 100\) |
#### Output
@@ -518,8 +518,8 @@ Update an existing annotation
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | number | The ID of the updated annotation |
| `message` | string | Confirmation message |
| `annotationId` | number | The annotation that was updated, echoed from the request — Grafana answers a patch with only a message and returns no id |
| `message` | string | Confirmation message from Grafana, e.g. "Annotation patched" |
### Grafana Delete Annotation
@@ -587,7 +587,7 @@ Get a data source by its ID or UID
| `apiKey` | string | Yes | Grafana Service Account Token |
| `baseUrl` | string | Yes | Grafana instance URL \(e.g., https://your-grafana.com\) |
| `organizationId` | string | No | Organization ID for multi-org Grafana instances \(e.g., 1, 2\) |
| `dataSourceId` | string | Yes | The ID or UID of the data source to retrieve \(e.g., prometheus, P1234AB5678\) |
| `dataSourceId` | string | Yes | The UID of the data source to retrieve \(e.g., P1234AB5678\). Numeric ids are not supported — Grafana serves those only behind a disabled-by-default feature toggle |
#### Output
@@ -629,8 +629,9 @@ Test connectivity to a data source by its UID
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `status` | string | Health status of the data source \(e.g., OK\) |
| `message` | string | Detailed health message from the data source |
| `status` | string | Verdict Grafana returned for the data source, e.g. OK or ERROR. An unhealthy source reports here rather than failing the tool |
| `message` | string | The plugin's diagnostic detail, which carries the reason on a failed check |
| `details` | json | Extra structured detail, when the data source plugin supplies any |
### Grafana List Folders
@@ -788,8 +789,9 @@ Delete a folder by its UID
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `uid` | string | The UID of the deleted folder |
| `message` | string | Confirmation message |
| `id` | number | Numeric id of the deleted folder, as returned by Grafana |
| `uid` | string | The UID that was deleted, echoed from the request |
| `message` | string | Grafana's confirmation message |
### Grafana Get Health
@@ -811,4 +813,154 @@ Check the health of the Grafana instance (version, database status)
| `database` | string | Database health status \(e.g., ok\) |
| `version` | string | Grafana version |
### Grafana Update Contact Point
Replace a contact point by its UID. Grafana has no partial update for contact points, so every field is rewritten — resend the name, type, and full settings, or the omitted ones are reset.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Grafana Service Account Token |
| `baseUrl` | string | Yes | Grafana instance URL \(e.g., https://your-grafana.com\) |
| `organizationId` | string | No | Organization ID for multi-org Grafana instances \(e.g., 1, 2\) |
| `contactPointUid` | string | Yes | UID of the contact point to replace |
| `name` | string | Yes | Contact point name. Grafana groups receivers that share a name |
| `type` | string | Yes | Receiver type, e.g. slack, email, pagerduty, webhook, opsgenie, teams, discord, telegram |
| `settings` | string | Yes | JSON object of receiver settings for this type, e.g. \{"url":"https://hooks.slack.com/..."\} for slack |
| `disableResolveMessage` | boolean | No | Suppress the resolved notification. Omitting this resets it to false |
| `disableProvenance` | boolean | No | Send X-Disable-Provenance. Use only on a contact point whose provenance is already empty \(UI-created, or created by Sim with this on\) — sending it against an API-provisioned contact point is rejected with 403 |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `uid` | string | The UID that was updated, echoed from the request — Grafana answers a contact point update with only a message and returns no object |
| `message` | string | Confirmation message from Grafana, e.g. "contactpoint updated" |
### Grafana Delete Contact Point
Permanently delete a contact point by its UID. Grafana refuses the delete while the contact point is still referenced by the notification policy tree or by an alert rule.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Grafana Service Account Token |
| `baseUrl` | string | Yes | Grafana instance URL \(e.g., https://your-grafana.com\) |
| `organizationId` | string | No | Organization ID for multi-org Grafana instances \(e.g., 1, 2\) |
| `contactPointUid` | string | Yes | UID of the contact point to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `uid` | string | The UID that was deleted, echoed from the request |
| `message` | string | Confirmation message from Grafana, e.g. "contactpoint deleted" |
### Grafana Move Folder
Move a folder under a different parent folder, or to the root by leaving the parent empty. Returns the folder with its new ancestry.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Grafana Service Account Token |
| `baseUrl` | string | Yes | Grafana instance URL \(e.g., https://your-grafana.com\) |
| `organizationId` | string | No | Organization ID for multi-org Grafana instances \(e.g., 1, 2\) |
| `folderUid` | string | Yes | UID of the folder to move |
| `parentUid` | string | No | UID of the new parent folder. Leave empty to move the folder to the root |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | number | The numeric ID of the folder |
| `uid` | string | The UID of the folder |
| `title` | string | The title of the folder |
| `url` | string | The URL path of the folder |
| `parentUid` | string | UID of the new parent folder, absent once moved to the root |
| `parents` | array | Folder ancestry from the root down to the parent \(uid, title, url\) |
| `hasAcl` | boolean | Whether the folder has custom ACL permissions |
| `canSave` | boolean | Whether the caller can save the folder |
| `canEdit` | boolean | Whether the caller can edit the folder |
| `canAdmin` | boolean | Whether the caller can administer the folder |
| `createdBy` | string | Login that created the folder |
| `created` | string | Creation timestamp |
| `updatedBy` | string | Login that last updated the folder |
| `updated` | string | Last update timestamp |
| `version` | number | Folder revision number |
### Grafana Get Alert Rule Group
Read an alert rule group: its evaluation interval and every rule in it. The interval is the group-level knob that decides how often those rules are evaluated, which the individual alert rule operations do not expose.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Grafana Service Account Token |
| `baseUrl` | string | Yes | Grafana instance URL \(e.g., https://your-grafana.com\) |
| `organizationId` | string | No | Organization ID for multi-org Grafana instances \(e.g., 1, 2\) |
| `folderUid` | string | Yes | UID of the folder holding the rule group |
| `ruleGroup` | string | Yes | Name of the rule group |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `title` | string | Name of the rule group |
| `folderUid` | string | UID of the folder holding the group |
| `interval` | number | How often the group is evaluated, as an integer. Grafana returns seconds here rather than a duration string |
| `rules` | array | Provisioned alert rules in the group |
| ↳ `id` | number | Alert rule numeric ID |
| ↳ `uid` | string | Alert rule UID |
| ↳ `title` | string | Alert rule title |
| ↳ `condition` | string | RefId of the query used as the alert condition |
| ↳ `data` | json | Alert rule query/expression data array |
| ↳ `updated` | string | Last update timestamp |
| ↳ `noDataState` | string | State when no data is returned |
| ↳ `execErrState` | string | State on execution error |
| ↳ `for` | string | Duration the condition must hold before firing |
| ↳ `keepFiringFor` | string | Duration to keep firing after condition stops |
| ↳ `missingSeriesEvalsToResolve` | number | Number of missing series evaluations before resolving |
| ↳ `annotations` | json | Alert annotations |
| ↳ `labels` | json | Alert labels |
| ↳ `isPaused` | boolean | Whether the rule is paused |
| ↳ `folderUID` | string | Parent folder UID |
| ↳ `ruleGroup` | string | Rule group name |
| ↳ `orgID` | number | Organization ID |
| ↳ `provenance` | string | Provisioning source — "api" for API-managed, empty when created with X-Disable-Provenance and therefore still editable in the Grafana UI |
| ↳ `notification_settings` | json | Per-rule notification settings \(overrides\) |
| ↳ `record` | json | Recording rule configuration \(recording rules only\) |
### Grafana Query Data Source
Run one or more queries against a Grafana data source that has a backend implementation, and read the values back. This is how you get actual metric numbers out of Grafana rather than dashboard or alert configuration.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Grafana Service Account Token |
| `baseUrl` | string | Yes | Grafana instance URL \(e.g., https://your-grafana.com\) |
| `organizationId` | string | No | Organization ID for multi-org Grafana instances \(e.g., 1, 2\) |
| `queries` | string | Yes | JSON array of at least one query. Each needs a datasource.uid and a refId, plus the fields that data source expects — expr for Prometheus, rawSql for SQL. Example: \[\{"refId":"A","datasource":\{"uid":"P123"\},"expr":"up","format":"time_series"\}\] |
| `from` | string | No | Start of the time range, either epoch milliseconds or Grafana relative time \(e.g., now-5m\). Defaults to now-1h |
| `to` | string | No | End of the time range, epoch milliseconds or relative \(e.g., now\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `results` | json | Raw Grafana response, keyed by each query refId, each holding the frames that query produced |
| `series` | array | The same frames flattened into rows, so values can be read without walking the columnar layout |
| ↳ `refId` | string | The query this frame came from |
| ↳ `fields` | array | Field metadata in column order |
| ↳ `name` | string | Field name, e.g. time or A-series |
| ↳ `type` | string | Field type, e.g. time or number |
| ↳ `rowCount` | number | Number of rows in the frame |
| ↳ `rows` | array | Rows keyed by field name |
@@ -0,0 +1,131 @@
/**
* @vitest-environment node
*/
import { createMockRequest, hybridAuthMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockSecureFetch, mockValidateUrl, MOCK_MAX_JSON_BYTES } = vi.hoisted(() => ({
mockSecureFetch: vi.fn(),
mockValidateUrl: vi.fn(),
MOCK_MAX_JSON_BYTES: 10 * 1024 * 1024,
}))
vi.mock('@/lib/core/security/input-validation.server', () => ({
secureFetchWithPinnedIP: mockSecureFetch,
validateUrlWithDNS: mockValidateUrl,
MAX_JSON_API_RESPONSE_BYTES: MOCK_MAX_JSON_BYTES,
}))
import { POST } from '@/app/api/tools/grafana/check_data_source_health/route'
const baseBody = {
apiKey: 'glsa_token',
baseUrl: 'https://grafana.example.com',
dataSourceUid: 'P1234AB5678',
}
function grafanaResponse(body: unknown, status: number) {
return {
ok: status >= 200 && status < 300,
status,
statusText: '',
headers: new Headers(),
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
}
}
function post(body: Record<string, unknown> = baseBody) {
return POST(createMockRequest('POST', body) as never, undefined as never)
}
describe('POST /api/tools/grafana/check_data_source_health', () => {
beforeEach(() => {
vi.clearAllMocks()
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ success: true, userId: 'user-1' })
mockValidateUrl.mockResolvedValue({ isValid: true, resolvedIP: '203.0.113.10' })
})
it('reports a healthy data source', async () => {
mockSecureFetch.mockResolvedValue(
grafanaResponse({ status: 'OK', message: 'Data source is working' }, 200)
)
const response = await post()
const data = await response.json()
expect(data.success).toBe(true)
expect(data.output).toEqual({ status: 'OK', message: 'Data source is working' })
})
it('reports an UNHEALTHY data source, which Grafana answers with HTTP 400', async () => {
mockSecureFetch.mockResolvedValue(
grafanaResponse({ status: 'ERROR', message: 'dial tcp: connection refused' }, 400)
)
const response = await post()
const data = await response.json()
expect(data.success).toBe(true)
expect(data.output.status).toBe('ERROR')
expect(data.output.message).toBe('dial tcp: connection refused')
})
it('surfaces the plugin details when Grafana supplies them', async () => {
mockSecureFetch.mockResolvedValue(
grafanaResponse(
{ status: 'ERROR', message: 'bad query', details: { verboseMessage: 'x' } },
400
)
)
const response = await post()
const data = await response.json()
expect(data.output.details).toEqual({ verboseMessage: 'x' })
})
it('treats a failure with no health verdict as a real request failure', async () => {
mockSecureFetch.mockResolvedValue(grafanaResponse({ message: 'Data source not found' }, 404))
const response = await post()
const data = await response.json()
expect(data.success).toBe(false)
expect(data.error).toContain('404')
})
it('bounds and protects the outbound call', async () => {
mockSecureFetch.mockResolvedValue(grafanaResponse({ status: 'OK', message: 'ok' }, 200))
await post()
const [url, resolvedIP, options] = mockSecureFetch.mock.calls[0]
expect(resolvedIP).toBe('203.0.113.10')
expect(url).toBe('https://grafana.example.com/api/datasources/uid/P1234AB5678/health')
expect(options.maxResponseBytes).toBe(MOCK_MAX_JSON_BYTES)
expect(options.timeout).toBeGreaterThan(0)
expect(options.stripAuthOnRedirect).toBe(true)
expect(options.headers.Authorization).toBe('Bearer glsa_token')
})
it('encodes the UID so it cannot re-target the request path', async () => {
mockSecureFetch.mockResolvedValue(grafanaResponse({ status: 'OK', message: 'ok' }, 200))
await post({ ...baseBody, dataSourceUid: 'a/../../admin' })
const [url] = mockSecureFetch.mock.calls[0]
expect(url).toBe('https://grafana.example.com/api/datasources/uid/a%2F..%2F..%2Fadmin/health')
})
it('rejects an unauthenticated request before reaching Grafana', async () => {
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
success: false,
error: 'Authentication required',
})
const response = await post()
expect(response.status).toBe(401)
expect(mockSecureFetch).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,139 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { truncate } from '@sim/utils/string'
import { type NextRequest, NextResponse } from 'next/server'
import { grafanaCheckDataSourceHealthContract } from '@/lib/api/contracts/tools/grafana'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import {
MAX_JSON_API_RESPONSE_BYTES,
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
export const dynamic = 'force-dynamic'
const logger = createLogger('GrafanaCheckDataSourceHealthAPI')
const OUTBOUND_FETCH_TIMEOUT_MS = 30_000
const MAX_ERROR_MESSAGE_LENGTH = 2000
/**
* Runs a data source health check.
*
* Grafana answers an *unhealthy* data source with HTTP 400 carrying the same
* `{status, message}` payload it uses for a healthy one, so the diagnostic the
* caller actually wants only exists on the failure status. A plain tool would
* have that converted into an opaque tool error, making the check able to report
* health and never ill-health — hence this route, which reads the payload off
* either status and reports it as a successful check.
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized Grafana health check: ${authResult.error}`)
return NextResponse.json(
{ success: false, error: authResult.error || 'Authentication required' },
{ status: 401 }
)
}
const parsed = await parseRequest(
grafanaCheckDataSourceHealthContract,
request,
{},
{
validationErrorResponse: (error) => {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues })
return NextResponse.json(
{
success: false,
error: getValidationErrorMessage(error, 'Invalid request data'),
details: error.issues,
},
{ status: 400 }
)
},
}
)
if (!parsed.success) return parsed.response
const params = parsed.data.body
const baseUrl = params.baseUrl.replace(/\/$/, '')
const healthUrl = `${baseUrl}/api/datasources/uid/${encodeURIComponent(
params.dataSourceUid.trim()
)}/health`
const urlValidation = await validateUrlWithDNS(healthUrl, 'baseUrl')
if (!urlValidation.isValid || !urlValidation.resolvedIP) {
return NextResponse.json({
success: false,
error: `Invalid Grafana baseUrl: ${urlValidation.error}`,
})
}
const headers: Record<string, string> = {
Accept: 'application/json',
Authorization: `Bearer ${params.apiKey}`,
}
if (params.organizationId) {
headers['X-Grafana-Org-Id'] = params.organizationId
}
const response = await secureFetchWithPinnedIP(healthUrl, urlValidation.resolvedIP, {
method: 'GET',
headers,
maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES,
timeout: OUTBOUND_FETCH_TIMEOUT_MS,
stripAuthOnRedirect: true,
})
const raw = await response.text()
let body: unknown = null
if (raw.length > 0) {
try {
body = JSON.parse(raw)
} catch {
body = null
}
}
const payload =
body && typeof body === 'object'
? (body as { status?: unknown; message?: unknown; details?: unknown })
: null
/**
* A `status` in the body means Grafana ran the check and reported a verdict,
* whatever the HTTP status. Anything else — an auth failure, a missing data
* source, a plugin with no health endpoint — is a genuine request failure.
*/
if (payload && typeof payload.status === 'string') {
return NextResponse.json({
success: true,
output: {
status: payload.status,
message: typeof payload.message === 'string' ? payload.message : null,
...(payload.details === undefined ? {} : { details: payload.details }),
},
})
}
logger.warn(`[${requestId}] Grafana health check did not report a status (${response.status})`)
return NextResponse.json({
success: false,
error: `Failed to check data source health: HTTP ${response.status} ${truncate(
raw,
MAX_ERROR_MESSAGE_LENGTH
)}`,
})
} catch (error) {
logger.error(`[${requestId}] Error checking Grafana data source health:`, error)
return NextResponse.json({ success: false, error: getErrorMessage(error) })
}
})
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { truncate } from '@sim/utils/string'
import { type NextRequest, NextResponse } from 'next/server'
import { grafanaUpdateAlertRuleContract } from '@/lib/api/contracts/tools/grafana'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
@@ -17,6 +18,11 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('GrafanaUpdateAlertRuleAPI')
/** Grafana is reached over two sequential hops, so each one needs its own bound. */
const OUTBOUND_FETCH_TIMEOUT_MS = 30_000
/** Upstream error bodies can be a full HTML page; only a prefix is useful. */
const MAX_ERROR_MESSAGE_LENGTH = 2000
export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
@@ -64,7 +70,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
getHeaders['X-Grafana-Org-Id'] = params.organizationId
}
const getUrl = `${baseUrl}/api/v1/provisioning/alert-rules/${params.alertRuleUid.trim()}`
const getUrl = `${baseUrl}/api/v1/provisioning/alert-rules/${encodeURIComponent(params.alertRuleUid.trim())}`
const getValidation = await validateUrlWithDNS(getUrl, 'baseUrl')
if (!getValidation.isValid || !getValidation.resolvedIP) {
return NextResponse.json({
@@ -78,10 +84,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
method: 'GET',
headers: getHeaders,
maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES,
timeout: OUTBOUND_FETCH_TIMEOUT_MS,
stripAuthOnRedirect: true,
})
if (!getResponse.ok) {
const errorText = await getResponse.text()
const errorText = truncate(await getResponse.text(), MAX_ERROR_MESSAGE_LENGTH)
return NextResponse.json({
success: false,
output: {},
@@ -89,7 +97,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
})
}
const existingRule = (await getResponse.json()) as any
const existingRule = (await getResponse.json()) as Record<string, unknown>
if (!existingRule || !existingRule.uid) {
return NextResponse.json({
@@ -193,7 +201,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
headers['X-Disable-Provenance'] = 'true'
}
const updateUrl = `${baseUrl}/api/v1/provisioning/alert-rules/${params.alertRuleUid.trim()}`
const updateUrl = `${baseUrl}/api/v1/provisioning/alert-rules/${encodeURIComponent(params.alertRuleUid.trim())}`
const urlValidation = await validateUrlWithDNS(updateUrl, 'baseUrl')
if (!urlValidation.isValid || !urlValidation.resolvedIP) {
return NextResponse.json({
@@ -208,10 +216,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
headers,
body: JSON.stringify(updatedRule),
maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES,
timeout: OUTBOUND_FETCH_TIMEOUT_MS,
stripAuthOnRedirect: true,
})
if (!updateResponse.ok) {
const errorText = await updateResponse.text()
const errorText = truncate(await updateResponse.text(), MAX_ERROR_MESSAGE_LENGTH)
return NextResponse.json({
success: false,
output: {},
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { truncate } from '@sim/utils/string'
import { type NextRequest, NextResponse } from 'next/server'
import { grafanaUpdateDashboardContract } from '@/lib/api/contracts/tools/grafana'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
@@ -16,6 +17,11 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('GrafanaUpdateDashboardAPI')
/** Grafana is reached over two sequential hops, so each one needs its own bound. */
const OUTBOUND_FETCH_TIMEOUT_MS = 30_000
/** Upstream error bodies can be a full HTML page; only a prefix is useful. */
const MAX_ERROR_MESSAGE_LENGTH = 2000
export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
@@ -63,7 +69,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
getHeaders['X-Grafana-Org-Id'] = params.organizationId
}
const getUrl = `${baseUrl}/api/dashboards/uid/${params.dashboardUid.trim()}`
const getUrl = `${baseUrl}/api/dashboards/uid/${encodeURIComponent(params.dashboardUid.trim())}`
const getValidation = await validateUrlWithDNS(getUrl, 'baseUrl')
if (!getValidation.isValid || !getValidation.resolvedIP) {
return NextResponse.json({
@@ -77,10 +83,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
method: 'GET',
headers: getHeaders,
maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES,
timeout: OUTBOUND_FETCH_TIMEOUT_MS,
stripAuthOnRedirect: true,
})
if (!getResponse.ok) {
const errorText = await getResponse.text()
const errorText = truncate(await getResponse.text(), MAX_ERROR_MESSAGE_LENGTH)
return NextResponse.json({
success: false,
output: {},
@@ -88,7 +96,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
})
}
const existing = (await getResponse.json()) as any
/**
* `GET /api/dashboards/uid/:uid` answers `{dashboard, meta}`. Only the few
* fields this route reads are narrowed — the rest of the dashboard is
* arbitrary user JSON that is spread through untouched.
*/
const existing = (await getResponse.json()) as {
dashboard?: Record<string, unknown>
meta?: { folderUid?: string }
}
const existingDashboard = existing.dashboard
const existingMeta = existing.meta
@@ -100,7 +116,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
})
}
const updatedDashboard: Record<string, any> = {
const updatedDashboard: Record<string, unknown> = {
...existingDashboard,
}
@@ -131,7 +147,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
updatedDashboard.version = existingDashboard.version
}
const body: Record<string, any> = {
const body: Record<string, unknown> = {
dashboard: updatedDashboard,
overwrite: params.overwrite === true,
}
@@ -169,10 +185,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
headers,
body: JSON.stringify(body),
maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES,
timeout: OUTBOUND_FETCH_TIMEOUT_MS,
stripAuthOnRedirect: true,
})
if (!updateResponse.ok) {
const errorText = await updateResponse.text()
const errorText = truncate(await updateResponse.text(), MAX_ERROR_MESSAGE_LENGTH)
return NextResponse.json({
success: false,
output: {},
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { truncate } from '@sim/utils/string'
import { type NextRequest, NextResponse } from 'next/server'
import { grafanaUpdateFolderContract } from '@/lib/api/contracts/tools/grafana'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
@@ -16,6 +17,11 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('GrafanaUpdateFolderAPI')
/** Grafana is reached over two sequential hops, so each one needs its own bound. */
const OUTBOUND_FETCH_TIMEOUT_MS = 30_000
/** Upstream error bodies can be a full HTML page; only a prefix is useful. */
const MAX_ERROR_MESSAGE_LENGTH = 2000
export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
@@ -61,7 +67,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
headers['X-Grafana-Org-Id'] = params.organizationId
}
const folderUrl = `${baseUrl}/api/folders/${params.folderUid.trim()}`
const folderUrl = `${baseUrl}/api/folders/${encodeURIComponent(params.folderUid.trim())}`
const urlValidation = await validateUrlWithDNS(folderUrl, 'baseUrl')
if (!urlValidation.isValid || !urlValidation.resolvedIP) {
return NextResponse.json({
@@ -75,10 +81,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
method: 'GET',
headers,
maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES,
timeout: OUTBOUND_FETCH_TIMEOUT_MS,
stripAuthOnRedirect: true,
})
if (!getResponse.ok) {
const errorText = await getResponse.text()
const errorText = truncate(await getResponse.text(), MAX_ERROR_MESSAGE_LENGTH)
return NextResponse.json({
success: false,
output: {},
@@ -86,7 +94,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
})
}
const existingFolder = (await getResponse.json()) as any
const existingFolder = (await getResponse.json()) as Record<string, unknown>
if (!existingFolder || !existingFolder.uid) {
return NextResponse.json({
@@ -96,10 +104,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
})
}
/**
* Grafana treats `version` and `overwrite` as alternatives: `version` is
* "not needed if overwrite=true". Sending both made the version we just
* fetched decorative and silently clobbered a concurrent rename, so only
* the version is sent and a conflicting edit surfaces as Grafana's 412
* instead of being lost.
*/
const body: Record<string, unknown> = {
title: params.title ?? existingFolder.title,
title: params.title,
version: existingFolder.version,
overwrite: true,
}
const updateResponse = await secureFetchWithPinnedIP(folderUrl, urlValidation.resolvedIP, {
@@ -107,10 +121,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
headers,
body: JSON.stringify(body),
maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES,
timeout: OUTBOUND_FETCH_TIMEOUT_MS,
stripAuthOnRedirect: true,
})
if (!updateResponse.ok) {
const errorText = await updateResponse.text()
const errorText = truncate(await updateResponse.text(), MAX_ERROR_MESSAGE_LENGTH)
return NextResponse.json({
success: false,
output: {},
+325 -34
View File
@@ -52,6 +52,7 @@ export const GrafanaBlock: BlockConfig<GrafanaResponse> = {
grafana_delete_alert_rule: [
{ text: 'Delete alert rule', field: 'alertRuleUid', core: true },
],
grafana_query_data_source: ['Query a data source', { text: ', over', field: 'queryFrom' }],
grafana_list_contact_points: [
'List contact points',
{ text: ', named', field: 'contactPointName' },
@@ -60,6 +61,21 @@ export const GrafanaBlock: BlockConfig<GrafanaResponse> = {
{ text: 'Create contact point', field: 'contactPointNameNew', core: true },
{ text: ', of type', field: 'contactPointType' },
],
grafana_update_contact_point: [
{ text: 'Replace contact point', field: 'contactPointUid', core: true },
{ text: ', as type', field: 'contactPointType' },
],
grafana_delete_contact_point: [
{ text: 'Delete contact point', field: 'contactPointUid', core: true },
],
grafana_move_folder: [
{ text: 'Move folder', field: 'manageFolderUid', core: true },
{ text: ', under', field: 'newParentUid' },
],
grafana_get_alert_rule_group: [
{ text: 'Read alert rule group', field: 'ruleGroupName', core: true },
{ text: ', in folder', field: 'manageFolderUid' },
],
grafana_create_annotation: [
{ text: 'Create annotation', field: 'text', core: true },
{ text: ', on dashboard', field: 'annotationDashboardUid' },
@@ -112,23 +128,28 @@ export const GrafanaBlock: BlockConfig<GrafanaResponse> = {
{ label: 'Delete Dashboard', id: 'grafana_delete_dashboard' },
{ label: 'List Alert Rules', id: 'grafana_list_alert_rules' },
{ label: 'Get Alert Rule', id: 'grafana_get_alert_rule' },
{ label: 'Get Alert Rule Group', id: 'grafana_get_alert_rule_group' },
{ label: 'Create Alert Rule', id: 'grafana_create_alert_rule' },
{ label: 'Update Alert Rule', id: 'grafana_update_alert_rule' },
{ label: 'Delete Alert Rule', id: 'grafana_delete_alert_rule' },
{ label: 'List Contact Points', id: 'grafana_list_contact_points' },
{ label: 'Create Contact Point', id: 'grafana_create_contact_point' },
{ label: 'Update Contact Point', id: 'grafana_update_contact_point' },
{ label: 'Delete Contact Point', id: 'grafana_delete_contact_point' },
{ label: 'Create Annotation', id: 'grafana_create_annotation' },
{ label: 'List Annotations', id: 'grafana_list_annotations' },
{ label: 'Update Annotation', id: 'grafana_update_annotation' },
{ label: 'Delete Annotation', id: 'grafana_delete_annotation' },
{ label: 'List Data Sources', id: 'grafana_list_data_sources' },
{ label: 'Get Data Source', id: 'grafana_get_data_source' },
{ label: 'Query Data Source', id: 'grafana_query_data_source' },
{ label: 'Check Data Source Health', id: 'grafana_check_data_source_health' },
{ label: 'List Folders', id: 'grafana_list_folders' },
{ label: 'Create Folder', id: 'grafana_create_folder' },
{ label: 'Get Folder', id: 'grafana_get_folder' },
{ label: 'Update Folder', id: 'grafana_update_folder' },
{ label: 'Delete Folder', id: 'grafana_delete_folder' },
{ label: 'Move Folder', id: 'grafana_move_folder' },
{ label: 'Get Health', id: 'grafana_get_health' },
],
value: () => 'grafana_list_dashboards',
@@ -268,8 +289,12 @@ Return ONLY the search query - no explanations, no quotes, no extra text.`,
title: 'Dashboard Title',
type: 'short-input',
placeholder: 'Enter dashboard title',
required: true,
condition: { field: 'operation', value: 'grafana_create_dashboard' },
/** Update accepts a new title too, but only create demands one. */
required: { field: 'operation', value: 'grafana_create_dashboard' },
condition: {
field: 'operation',
value: ['grafana_create_dashboard', 'grafana_update_dashboard'],
},
wandConfig: {
enabled: true,
prompt: `Generate a professional Grafana dashboard title based on the user's description.
@@ -624,6 +649,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
'grafana_create_alert_rule',
'grafana_update_alert_rule',
'grafana_create_contact_point',
'grafana_update_contact_point',
],
},
},
@@ -871,7 +897,13 @@ Return ONLY the folder title - no explanations, no quotes, no extra text.`,
required: true,
condition: {
field: 'operation',
value: ['grafana_get_folder', 'grafana_update_folder', 'grafana_delete_folder'],
value: [
'grafana_get_folder',
'grafana_update_folder',
'grafana_delete_folder',
'grafana_move_folder',
'grafana_get_alert_rule_group',
],
},
},
{
@@ -890,6 +922,76 @@ Return ONLY the folder title - no explanations, no quotes, no extra text.`,
condition: { field: 'operation', value: 'grafana_delete_folder' },
},
{
id: 'dataSourceQueries',
title: 'Queries (JSON)',
type: 'long-input',
placeholder: '[{"refId":"A","datasource":{"uid":"P123"},"expr":"up","format":"time_series"}]',
required: { field: 'operation', value: 'grafana_query_data_source' },
condition: { field: 'operation', value: 'grafana_query_data_source' },
wandConfig: {
enabled: true,
prompt: `Generate a Grafana /api/ds/query queries array based on the user's request.
Rules:
- Always a JSON array with at least one query object
- Every query needs a refId (e.g. "A") and datasource.uid
- Add the fields that data source expects: expr for Prometheus/Loki, rawSql for SQL sources
- format is "time_series" or "table"
Examples:
- [{"refId":"A","datasource":{"uid":"PROM_UID"},"expr":"rate(http_requests_total[5m])","format":"time_series"}]
- [{"refId":"A","datasource":{"uid":"PG_UID"},"rawSql":"SELECT now() AS time, count(*) AS c FROM orders","format":"table"}]
Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
placeholder: 'Describe the metric or query you want...',
generationType: 'json-array',
},
},
{
id: 'queryFrom',
title: 'From',
type: 'short-input',
placeholder: 'now-1h or epoch milliseconds',
condition: { field: 'operation', value: 'grafana_query_data_source' },
},
{
id: 'queryTo',
title: 'To',
type: 'short-input',
placeholder: 'now or epoch milliseconds',
mode: 'advanced',
condition: { field: 'operation', value: 'grafana_query_data_source' },
},
{
id: 'contactPointUid',
title: 'Contact Point UID',
type: 'short-input',
placeholder: 'Enter contact point UID',
required: {
field: 'operation',
value: ['grafana_update_contact_point', 'grafana_delete_contact_point'],
},
condition: {
field: 'operation',
value: ['grafana_update_contact_point', 'grafana_delete_contact_point'],
},
},
{
id: 'newParentUid',
title: 'New Parent Folder UID',
type: 'short-input',
placeholder: 'Leave empty to move to the root',
condition: { field: 'operation', value: 'grafana_move_folder' },
},
{
id: 'ruleGroupName',
title: 'Rule Group',
type: 'short-input',
placeholder: 'Enter rule group name',
required: { field: 'operation', value: 'grafana_get_alert_rule_group' },
condition: { field: 'operation', value: 'grafana_get_alert_rule_group' },
},
{
id: 'contactPointName',
title: 'Contact Point Name',
@@ -903,8 +1005,14 @@ Return ONLY the folder title - no explanations, no quotes, no extra text.`,
title: 'Contact Point Name',
type: 'short-input',
placeholder: 'Enter contact point name',
required: true,
condition: { field: 'operation', value: 'grafana_create_contact_point' },
required: {
field: 'operation',
value: ['grafana_create_contact_point', 'grafana_update_contact_point'],
},
condition: {
field: 'operation',
value: ['grafana_create_contact_point', 'grafana_update_contact_point'],
},
},
{
id: 'contactPointType',
@@ -920,8 +1028,14 @@ Return ONLY the folder title - no explanations, no quotes, no extra text.`,
{ label: 'Discord', id: 'discord' },
],
value: () => 'slack',
required: true,
condition: { field: 'operation', value: 'grafana_create_contact_point' },
required: {
field: 'operation',
value: ['grafana_create_contact_point', 'grafana_update_contact_point'],
},
condition: {
field: 'operation',
value: ['grafana_create_contact_point', 'grafana_update_contact_point'],
},
},
{
id: 'contactPointSettings',
@@ -929,7 +1043,10 @@ Return ONLY the folder title - no explanations, no quotes, no extra text.`,
type: 'long-input',
placeholder: 'JSON object of receiver settings (e.g., {"url":"https://hooks.slack.com/..."})',
required: true,
condition: { field: 'operation', value: 'grafana_create_contact_point' },
condition: {
field: 'operation',
value: ['grafana_create_contact_point', 'grafana_update_contact_point'],
},
wandConfig: {
enabled: true,
prompt: `Generate a Grafana contact point settings JSON object based on the user's description and receiver type.
@@ -950,7 +1067,10 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
title: 'Disable Resolve Message',
type: 'switch',
mode: 'advanced',
condition: { field: 'operation', value: 'grafana_create_contact_point' },
condition: {
field: 'operation',
value: ['grafana_create_contact_point', 'grafana_update_contact_point'],
},
},
],
tools: {
@@ -980,6 +1100,11 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
'grafana_update_folder',
'grafana_delete_folder',
'grafana_get_health',
'grafana_update_contact_point',
'grafana_delete_contact_point',
'grafana_move_folder',
'grafana_get_alert_rule_group',
'grafana_query_data_source',
],
config: {
tool: (params) => params.operation,
@@ -1011,6 +1136,24 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
if (params.contactPointType) result.type = params.contactPointType
if (params.contactPointSettings) result.settings = params.contactPointSettings
break
case 'grafana_update_contact_point':
if (params.contactPointNameNew) result.name = params.contactPointNameNew
if (params.contactPointType) result.type = params.contactPointType
if (params.contactPointSettings) result.settings = params.contactPointSettings
break
case 'grafana_query_data_source':
result.queries = params.dataSourceQueries
if (params.queryFrom) result.from = params.queryFrom
if (params.queryTo) result.to = params.queryTo
break
case 'grafana_move_folder':
result.folderUid = params.manageFolderUid
result.parentUid = params.newParentUid ?? ''
break
case 'grafana_get_alert_rule_group':
result.folderUid = params.manageFolderUid
result.ruleGroup = params.ruleGroupName
break
case 'grafana_create_annotation':
if (params.annotationTags) result.tags = params.annotationTags
if (params.annotationDashboardUid) result.dashboardUid = params.annotationDashboardUid
@@ -1062,6 +1205,18 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
},
},
inputs: {
dataSourceQueries: { type: 'string', description: 'JSON array of data source queries' },
queryFrom: { type: 'string', description: 'Query range start, relative or epoch ms' },
queryTo: { type: 'string', description: 'Query range end, relative or epoch ms' },
contactPointUid: {
type: 'string',
description: 'UID of the contact point to update or delete',
},
newParentUid: {
type: 'string',
description: 'UID of the new parent folder, empty for the root',
},
ruleGroupName: { type: 'string', description: 'Name of the alert rule group' },
operation: { type: 'string', description: 'Operation to perform' },
baseUrl: { type: 'string', description: 'Grafana instance URL' },
apiKey: { type: 'string', description: 'Service Account Token' },
@@ -1071,7 +1226,11 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
folderUid: { type: 'string', description: 'Folder UID' },
tags: { type: 'string', description: 'Comma-separated tags' },
panels: { type: 'string', description: 'JSON array of panels' },
message: { type: 'string', description: 'Commit message' },
message: {
type: 'string',
description:
'Message returned by Grafana — a confirmation for writes, or the diagnostic detail on a health check',
},
query: { type: 'string', description: 'Search query' },
tag: { type: 'string', description: 'Filter by tag' },
folderUIDs: {
@@ -1104,12 +1263,19 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
type: 'string',
description: 'JSON of per-rule notification settings',
},
record: { type: 'string', description: 'JSON of recording rule configuration' },
record: {
type: 'string',
description: 'Recording rule configuration (metric, from, target_datasource_uid)',
},
disableProvenance: {
type: 'boolean',
description: 'Disable provenance tracking so the rule remains UI-editable',
},
annotations: { type: 'string', description: 'JSON of alert annotations' },
annotations: {
type: 'string',
description:
'For annotation operations, the matched annotations (id, dashboardUID, panelId, time, timeEnd, text, tags, newState, prevState, ...). For alert rules, the rule annotation map (summary, description, runbook_url)',
},
labels: { type: 'string', description: 'JSON of alert labels' },
overwrite: { type: 'boolean', description: 'Overwrite existing dashboard on version conflict' },
text: { type: 'string', description: 'Annotation text' },
@@ -1155,20 +1321,134 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
dataSourceUid: { type: 'string', description: 'Data source UID for health checks' },
},
outputs: {
version: { type: 'string', description: 'Grafana version' },
database: { type: 'string', description: 'Database health status' },
annotationId: {
type: 'number',
description:
'The annotation that was updated, echoed from the request — Grafana answers a patch with only a message',
},
details: {
type: 'json',
description:
'Extra structured detail from a data source health check, when the plugin supplies any',
},
results: {
type: 'json',
description: 'Raw data source query response, keyed by query refId',
},
series: {
type: 'array',
description:
'Query frames flattened into rows (refId, fields, rowCount, rows) so values can be read directly',
},
interval: {
type: 'number',
description: 'Evaluation interval of an alert rule group, in seconds',
},
folderUid: { type: 'string', description: 'UID of the folder holding the alert rule group' },
title: {
type: 'string',
description: 'Title of the affected dashboard, folder, or alert rule',
},
slug: { type: 'string', description: 'URL slug of the dashboard' },
data: {
type: 'json',
description: 'Alert rule query and expression stages (refId, model, ...)',
},
labels: { type: 'json', description: 'Alert rule labels used for routing and grouping' },
parentUid: { type: 'string', description: 'UID of the parent folder, when nested' },
parents: {
type: 'array',
description: 'Folder ancestry from the root down to the parent (uid, title, url)',
},
created: { type: 'string', description: 'Creation timestamp of the folder' },
createdBy: { type: 'string', description: 'Login that created the folder' },
updatedBy: { type: 'string', description: 'Login that last updated the folder' },
hasAcl: {
type: 'boolean',
description: 'Whether the folder carries an explicit permission list',
},
canSave: { type: 'boolean', description: 'Whether the caller may save the folder' },
canEdit: { type: 'boolean', description: 'Whether the caller may edit the folder' },
canAdmin: { type: 'boolean', description: 'Whether the caller may administer the folder' },
orgId: { type: 'number', description: 'Organization the data source belongs to' },
access: { type: 'string', description: 'Data source access mode (proxy or direct)' },
user: { type: 'string', description: 'Data source basic-auth-adjacent user field' },
typeLogoUrl: { type: 'string', description: 'Logo URL for the data source type' },
basicAuth: { type: 'boolean', description: 'Whether the data source uses basic auth' },
basicAuthUser: { type: 'string', description: 'Basic-auth user for the data source' },
withCredentials: {
type: 'boolean',
description: 'Whether the data source sends credentials cross-origin',
},
isDefault: { type: 'boolean', description: 'Whether this is the default data source' },
jsonData: { type: 'json', description: 'Non-secret data source configuration' },
secureJsonFields: {
type: 'json',
description: 'Which secret data source fields are set (names only, never values)',
},
readOnly: { type: 'boolean', description: 'Whether the data source is provisioned read-only' },
disableResolveMessage: {
type: 'boolean',
description: 'Whether the contact point suppresses resolve notifications',
},
version: {
type: 'number',
description:
'Revision number of the dashboard, folder, or data source. Get Health instead returns the Grafana version as a string',
},
database: {
type: 'string',
description:
'Database name of the data source; for Get Health, the Grafana database status (e.g. ok)',
},
commit: { type: 'string', description: 'Git commit hash of the Grafana build' },
status: { type: 'string', description: 'Health status (e.g., data source health)' },
dashboard: { type: 'json', description: 'Dashboard JSON' },
meta: { type: 'json', description: 'Dashboard metadata' },
dashboards: { type: 'json', description: 'List of dashboards' },
uid: { type: 'string', description: 'Created/updated UID' },
url: { type: 'string', description: 'Dashboard URL' },
rules: { type: 'json', description: 'Alert rules list' },
contactPoints: { type: 'json', description: 'Contact points list' },
name: { type: 'string', description: 'Name of the created contact point' },
type: { type: 'string', description: 'Type of the created contact point' },
settings: { type: 'json', description: 'Contact point receiver settings' },
status: {
type: 'string',
description:
'Outcome reported by Grafana — a data source health verdict, or the save status of a dashboard write',
},
dashboard: {
type: 'json',
description: 'Full dashboard JSON as stored by Grafana (panels, templating, time, ...)',
},
meta: {
type: 'json',
description: 'Dashboard metadata (isStarred, url, folderId, folderUid, slug)',
},
dashboards: {
type: 'array',
description:
'Matched dashboards (id, uid, title, uri, url, type, tags, isStarred, folderId, folderUid, folderTitle, folderUrl)',
},
uid: {
type: 'string',
description:
'UID of the affected resource — dashboard, folder, alert rule, data source, or contact point, depending on the operation',
},
url: {
type: 'string',
description: 'URL of the affected dashboard or folder; the connection URL for a data source',
},
rules: {
type: 'array',
description:
'Provisioned alert rules (uid, title, folderUID, ruleGroup, condition, data, for, labels, annotations, isPaused, noDataState, execErrState, provenance, ...)',
},
contactPoints: {
type: 'array',
description: 'Contact points (uid, name, type, settings, disableResolveMessage, provenance)',
},
name: { type: 'string', description: 'Name of the affected contact point or data source' },
type: {
type: 'string',
description:
'Type of the affected contact point (e.g. slack) or data source (e.g. prometheus)',
},
settings: {
type: 'json',
description:
'Contact point receiver settings — the shape depends on the receiver type, e.g. url and recipient for a Slack receiver',
},
condition: { type: 'string', description: 'Alert condition refId' },
for: { type: 'string', description: 'Duration the condition must hold before firing' },
keepFiringFor: {
@@ -1189,10 +1469,21 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
notification_settings: { type: 'json', description: 'Per-rule notification settings' },
record: { type: 'json', description: 'Recording rule configuration' },
updated: { type: 'string', description: 'Last update timestamp' },
annotations: { type: 'json', description: 'Annotations list' },
id: { type: 'number', description: 'Annotation ID' },
dataSources: { type: 'json', description: 'Data sources list' },
folders: { type: 'json', description: 'Folders list' },
annotations: { type: 'array', description: 'Annotations list' },
id: {
type: 'number',
description:
'Numeric id of the affected resource — annotation, alert rule, dashboard, folder, or data source, depending on the operation',
},
dataSources: {
type: 'array',
description:
'Data sources (id, uid, orgId, name, type, typeLogoUrl, access, url, database, isDefault, jsonData, readOnly, ...)',
},
folders: {
type: 'array',
description: 'Folders (id, uid, title, and parentUid when nested folders are enabled)',
},
message: { type: 'string', description: 'Status message' },
},
}
@@ -1205,7 +1496,7 @@ export const GrafanaBlockMeta = {
icon: GrafanaIcon,
title: 'Grafana alert auto-context',
prompt:
'Build a scheduled workflow that polls Grafana for firing alert rules, pulls related logs and recent deploys, summarizes them with an agent, and posts the enriched alert to PagerDuty and Slack.',
'Build a scheduled workflow that reads Grafana alert-state annotations to find rules that just started firing, queries the underlying data source for the current metric value, summarizes the two together with an agent, and posts the enriched alert to PagerDuty and Slack.',
modules: ['scheduled', 'agent', 'workflows'],
category: 'engineering',
tags: ['devops', 'monitoring'],
@@ -1215,7 +1506,7 @@ export const GrafanaBlockMeta = {
icon: GrafanaIcon,
title: 'Grafana SLO scorecard',
prompt:
'Create a scheduled weekly workflow that queries Grafana for SLO compliance across services, calculates burn rates, and writes a scorecard to a tables-based SRE review board.',
'Create a scheduled weekly workflow that runs SLI queries against a Grafana data source for each service, calculates error budget burn rates from the returned series, and writes a scorecard to a tables-based SRE review board.',
modules: ['scheduled', 'tables', 'agent', 'workflows'],
category: 'engineering',
tags: ['devops', 'reporting'],
@@ -1233,7 +1524,7 @@ export const GrafanaBlockMeta = {
icon: GrafanaIcon,
title: 'Grafana metric export',
prompt:
'Create a workflow that exports Grafana metric queries on schedule into a Sim table, so the data can be combined with business metrics for unified reporting.',
'Create a workflow that runs a set of Grafana data source queries on schedule and writes the returned series into a Sim table, so the metrics can be combined with business data for unified reporting.',
modules: ['scheduled', 'tables', 'agent', 'workflows'],
category: 'engineering',
tags: ['analysis', 'sync'],
@@ -1262,7 +1553,7 @@ export const GrafanaBlockMeta = {
icon: GrafanaIcon,
title: 'Grafana + Linear feature-impact',
prompt:
'Build a scheduled workflow that polls Grafana for metric regressions correlated with recent Linear releases and posts a regression review to the team Slack with the suspected change.',
'Build a scheduled workflow that queries a Grafana data source for latency and error rates, compares each series against the prior period to spot regressions, correlates them with recent Linear releases, and posts a regression review to the team Slack with the suspected change.',
modules: ['scheduled', 'agent', 'workflows'],
category: 'engineering',
tags: ['engineering', 'analysis'],
@@ -1282,7 +1573,7 @@ export const GrafanaBlockMeta = {
description:
'List Grafana alert rules and surface those currently firing with their contact points.',
content:
'# Review Firing Alerts\n\nProduce a snapshot of alerting health for an on-call handoff or incident triage.\n\n## Steps\n1. List alert rules and capture each rule name, condition, and current state.\n2. Get details on rules that are firing or in a pending state.\n3. List contact points so each firing rule can be mapped to who gets notified.\n4. Group findings by severity or folder.\n\n## Output\nReturn a list of firing and pending alerts with rule name, state, and notification target, plus a count of healthy rules. Suitable for an on-call digest.',
"# Review Firing Alerts\n\nProduce a snapshot of alerting health for an on-call handoff or incident triage.\n\n## Steps\n1. Run List Annotations with `type: 'alert'` over the window you care about. Alert-state transitions are recorded as annotations and carry `newState` and `prevState`, which is how you find what actually fired — the alert rule operations return rule *definitions*, never live instance state.\n2. Run List Alert Rules to join each firing rule id back to its title, folder, condition, and labels.\n3. Optionally run Query Data Source on the rule's own query to see how far the metric is from its threshold right now.\n4. Run List Contact Points, and Get Alert Rule Group for the evaluation interval, so each firing rule maps to who gets notified and how often it is checked.\n5. Group findings by severity label or folder.\n\n## Output\nReturn the rules that transitioned into a firing state in the window, each with its title, the transition, its notification target, and its group evaluation interval. Say explicitly that this is derived from state-change annotations rather than a live instance snapshot, and give the window covered.",
},
{
name: 'audit-dashboards',
+44 -3
View File
@@ -28,8 +28,11 @@ const grafanaUpdateDashboardOutputSchema = z.object({
export const grafanaUpdateDashboardResponseSchema = z.object({
success: z.boolean(),
output: grafanaUpdateDashboardOutputSchema,
/** Absent on the auth short-circuit, `{}` on handled failures. */
output: grafanaUpdateDashboardOutputSchema.partial().optional(),
error: z.string().optional(),
/** untyped-response: Zod issue objects, whose shape is Zod's, not ours to pin. */
details: z.array(z.unknown()).optional(),
})
const grafanaUpdateAlertRuleBodySchema = z.object({
@@ -60,6 +63,7 @@ const grafanaUpdateAlertRuleOutputSchema = z.object({
uid: z.string().nullable(),
title: z.string().nullable(),
condition: z.string().nullable(),
/** untyped-response: alert query stages are opaque, data-source-specific payloads. */
data: z.array(z.unknown()),
updated: z.string().nullable(),
noDataState: z.string().nullable(),
@@ -74,14 +78,19 @@ const grafanaUpdateAlertRuleOutputSchema = z.object({
ruleGroup: z.string().nullable(),
orgID: z.number().nullable(),
provenance: z.string(),
/** untyped-response: Grafana's notification settings shape is undocumented. */
notification_settings: z.record(z.string(), z.unknown()).nullable(),
/** untyped-response: recording-rule config is passed through opaquely. */
record: z.record(z.string(), z.unknown()).nullable(),
})
export const grafanaUpdateAlertRuleResponseSchema = z.object({
success: z.boolean(),
output: z.union([grafanaUpdateAlertRuleOutputSchema, z.object({})]),
/** Absent on the auth short-circuit, `{}` on handled failures. */
output: z.union([grafanaUpdateAlertRuleOutputSchema, z.object({})]).optional(),
error: z.string().optional(),
/** untyped-response: Zod issue objects, whose shape is Zod's, not ours to pin. */
details: z.array(z.unknown()).optional(),
})
const grafanaUpdateFolderBodySchema = z.object({
@@ -112,8 +121,40 @@ const grafanaUpdateFolderOutputSchema = z.object({
export const grafanaUpdateFolderResponseSchema = z.object({
success: z.boolean(),
output: z.union([grafanaUpdateFolderOutputSchema, z.object({})]),
/** Absent on the auth short-circuit, `{}` on handled failures. */
output: z.union([grafanaUpdateFolderOutputSchema, z.object({})]).optional(),
error: z.string().optional(),
/** untyped-response: Zod issue objects, whose shape is Zod's, not ours to pin. */
details: z.array(z.unknown()).optional(),
})
const grafanaCheckDataSourceHealthBodySchema = z.object({
apiKey: z.string().min(1, 'Grafana Service Account Token is required'),
baseUrl: z.string().min(1, 'Grafana instance URL is required'),
organizationId: z.string().optional(),
dataSourceUid: z.string().min(1, 'Data source UID is required').max(40, 'UID is too long'),
})
const grafanaCheckDataSourceHealthOutputSchema = z.object({
status: z.string(),
message: z.string().nullable(),
/** untyped-response: health detail is whatever the data source plugin chooses to attach. */
details: z.unknown().optional(),
})
export const grafanaCheckDataSourceHealthResponseSchema = z.object({
success: z.boolean(),
output: grafanaCheckDataSourceHealthOutputSchema.optional(),
error: z.string().optional(),
/** untyped-response: Zod issue objects, whose shape is Zod's, not ours to pin. */
details: z.array(z.unknown()).optional(),
})
export const grafanaCheckDataSourceHealthContract = defineRouteContract({
method: 'POST',
path: '/api/tools/grafana/check_data_source_health',
body: grafanaCheckDataSourceHealthBodySchema,
response: { mode: 'json', schema: grafanaCheckDataSourceHealthResponseSchema },
})
export const grafanaUpdateDashboardContract = defineRouteContract({
+21 -1
View File
@@ -9026,6 +9026,10 @@
"name": "Get Alert Rule",
"description": "Get a specific alert rule by its UID"
},
{
"name": "Get Alert Rule Group",
"description": "Read an alert rule group: its evaluation interval and every rule in it. The interval is the group-level knob that decides how often those rules are evaluated, which the individual alert rule operations do not expose."
},
{
"name": "Create Alert Rule",
"description": "Create a new alert rule"
@@ -9046,6 +9050,14 @@
"name": "Create Contact Point",
"description": "Create a notification contact point (e.g., Slack, email, PagerDuty)"
},
{
"name": "Update Contact Point",
"description": "Replace a contact point by its UID. Grafana has no partial update for contact points, so every field is rewritten — resend the name, type, and full settings, or the omitted ones are reset."
},
{
"name": "Delete Contact Point",
"description": "Permanently delete a contact point by its UID. Grafana refuses the delete while the contact point is still referenced by the notification policy tree or by an alert rule."
},
{
"name": "Create Annotation",
"description": "Create an annotation on a dashboard or as a global annotation"
@@ -9070,6 +9082,10 @@
"name": "Get Data Source",
"description": "Get a data source by its ID or UID"
},
{
"name": "Query Data Source",
"description": "Run one or more queries against a Grafana data source that has a backend implementation, and read the values back. This is how you get actual metric numbers out of Grafana rather than dashboard or alert configuration."
},
{
"name": "Check Data Source Health",
"description": "Test connectivity to a data source by its UID"
@@ -9094,12 +9110,16 @@
"name": "Delete Folder",
"description": "Delete a folder by its UID"
},
{
"name": "Move Folder",
"description": "Move a folder under a different parent folder, or to the root by leaving the parent empty. Returns the folder with its new ancestry."
},
{
"name": "Get Health",
"description": "Check the health of the Grafana instance (version, database status)"
}
],
"operationCount": 25,
"operationCount": 30,
"triggers": [],
"triggerCount": 0,
"authType": "api-key",
@@ -89,8 +89,7 @@ export const azureDataExplorerIngestFromQueryTool: ToolConfig<
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
"Optional ingestion properties clause contents, e.g. distributed=true, tags=\"[''daily'']\"",
description: `Optional ingestion properties clause contents, e.g. distributed=true, tags='["daily"]'`,
},
},
request: {
@@ -77,6 +77,13 @@ describe('buildWithClause', () => {
)
})
it('accepts the exact multi-property clause the Kusto reference shows', () => {
// .append OldExtents with(tags='["TagA","TagB"]', ingestIfNotExists='["myTag"]')
expect(
buildWithClause(`tags='["TagA","TagB"]', ingestIfNotExists='["myTag"]'`, 'distributed=true')
).toBe(` with (tags='["TagA","TagB"]', ingestIfNotExists='["myTag"]')`)
})
it('rejects a value that would close the clause and extend the command', () => {
expect(() => buildWithClause('format="json") <| evil', 'format="json"')).toThrow(
/Invalid property/
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -41,35 +41,60 @@ export const checkDataSourceHealthTool: ToolConfig<
},
request: {
url: (params) =>
`${params.baseUrl.replace(/\/$/, '')}/api/datasources/uid/${params.dataSourceUid.trim()}/health`,
method: 'GET',
headers: (params) => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: `Bearer ${params.apiKey}`,
}
if (params.organizationId) {
headers['X-Grafana-Org-Id'] = params.organizationId
}
return headers
},
/**
* Routed through Sim rather than called directly: Grafana reports an
* unhealthy data source with HTTP 400 carrying the same `{status, message}`
* payload as a healthy one, and the tool framework would turn that into an
* opaque error — so the check could only ever report health, never
* ill-health.
*/
url: '/api/tools/grafana/check_data_source_health',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => ({
apiKey: params.apiKey,
baseUrl: params.baseUrl,
dataSourceUid: params.dataSourceUid,
...(params.organizationId ? { organizationId: params.organizationId } : {}),
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
const data = (await response.json()) as {
success?: boolean
output?: { status?: string; message?: string | null; details?: unknown }
error?: string
}
if (!response.ok || data.success === false || !data.output) {
throw new Error(data.error || `Grafana health check failed: HTTP ${response.status}`)
}
return {
success: true,
output: {
status: (data.status as string) ?? 'UNKNOWN',
message: (data.message as string) ?? '',
status: data.output.status ?? 'UNKNOWN',
message: data.output.message ?? null,
...(data.output.details === undefined ? {} : { details: data.output.details }),
},
}
},
outputs: {
status: { type: 'string', description: 'Health status of the data source (e.g., OK)' },
message: { type: 'string', description: 'Detailed health message from the data source' },
status: {
type: 'string',
description:
'Verdict Grafana returned for the data source, e.g. OK or ERROR. An unhealthy source reports here rather than failing the tool',
},
message: {
type: 'string',
description: "The plugin's diagnostic detail, which carries the reason on a failed check",
nullable: true,
},
details: {
type: 'json',
description: 'Extra structured detail, when the data source plugin supplies any',
optional: true,
},
},
}
+16 -7
View File
@@ -74,14 +74,16 @@ export const createAlertRuleTool: ToolConfig<
noDataState: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'State when no data is returned (NoData, Alerting, OK)',
visibility: 'user-or-llm',
description:
'State when no data is returned: NoData (default), Alerting, OK, or KeepLast. Ignored for recording rules',
},
execErrState: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'State on execution error (Error, Alerting, OK)',
visibility: 'user-or-llm',
description:
'State on execution error: Error (default), Alerting, OK, or KeepLast. Ignored for recording rules',
},
annotations: {
type: 'string',
@@ -169,13 +171,20 @@ export const createAlertRuleTool: ToolConfig<
ruleGroup: params.ruleGroup,
data: dataArray,
}
if (params.organizationId) body.orgID = Number(params.organizationId)
if (params.condition) body.condition = params.condition
if (params.uid) body.uid = params.uid.trim()
if (params.forDuration) body.for = params.forDuration
if (params.noDataState) body.noDataState = params.noDataState
if (params.execErrState) body.execErrState = params.execErrState
/**
* Grafana validates an alerting rule with `NoDataStateFromString` and
* `ErrStateFromString`, both of which reject the empty string — so an
* omitted value is a 400, not a default. Recording rules skip that
* validator entirely and must not carry either field.
*/
if (!params.record) {
body.noDataState = params.noDataState ?? 'NoData'
body.execErrState = params.execErrState ?? 'Error'
}
if (params.isPaused !== undefined) body.isPaused = params.isPaused
if (params.keepFiringFor) body.keep_firing_for = params.keepFiringFor
if (params.missingSeriesEvalsToResolve !== undefined) {
@@ -127,6 +127,10 @@ export const createContactPointTool: ToolConfig<
type: 'boolean',
description: 'Whether resolve notifications are suppressed',
},
provenance: { type: 'string', description: 'Provisioning source (empty if API-managed)' },
provenance: {
type: 'string',
description:
'Provisioning source — "api" for API-managed, empty when created with X-Disable-Provenance and therefore still editable in the Grafana UI',
},
},
}
@@ -0,0 +1,91 @@
import type {
GrafanaDeleteContactPointParams,
GrafanaDeleteContactPointResponse,
} from '@/tools/grafana/types'
import type { ToolConfig } from '@/tools/types'
export const deleteContactPointTool: ToolConfig<
GrafanaDeleteContactPointParams,
GrafanaDeleteContactPointResponse
> = {
id: 'grafana_delete_contact_point',
name: 'Grafana Delete Contact Point',
description:
'Permanently delete a contact point by its UID. Grafana refuses the delete while the contact point is still referenced by the notification policy tree or by an alert rule.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Grafana Service Account Token',
},
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Grafana instance URL (e.g., https://your-grafana.com)',
},
organizationId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Organization ID for multi-org Grafana instances (e.g., 1, 2)',
},
contactPointUid: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'UID of the contact point to delete',
},
},
request: {
url: (params) =>
`${params.baseUrl.replace(/\/$/, '')}/api/v1/provisioning/contact-points/${encodeURIComponent(
params.contactPointUid.trim()
)}`,
method: 'DELETE',
/**
* No X-Disable-Provenance here. The delete handler never reads stored
* provenance, so an API-provisioned contact point deletes without it — and
* the endpoint accepts no such parameter.
*/
headers: (params) => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: `Bearer ${params.apiKey}`,
}
if (params.organizationId) {
headers['X-Grafana-Org-Id'] = params.organizationId
}
return headers
},
},
transformResponse: async (response: Response, params) => {
const data = (await response.json().catch(() => ({}))) as { message?: string }
return {
success: true,
output: {
uid: params?.contactPointUid?.trim() ?? null,
message: data.message ?? null,
},
}
},
outputs: {
uid: {
type: 'string',
description: 'The UID that was deleted, echoed from the request',
nullable: true,
},
message: {
type: 'string',
description: 'Confirmation message from Grafana, e.g. "contactpoint deleted"',
nullable: true,
},
},
}
+4 -2
View File
@@ -62,9 +62,9 @@ export const deleteDashboardTool: ToolConfig<
return {
success: true,
output: {
title: data.title || '',
title: (data.title as string) ?? null,
message: data.message || 'Dashboard deleted',
id: data.id || 0,
id: (data.id as number) ?? null,
},
}
},
@@ -73,6 +73,7 @@ export const deleteDashboardTool: ToolConfig<
title: {
type: 'string',
description: 'The title of the deleted dashboard',
nullable: true,
},
message: {
type: 'string',
@@ -81,6 +82,7 @@ export const deleteDashboardTool: ToolConfig<
id: {
type: 'number',
description: 'The ID of the deleted dashboard',
nullable: true,
},
},
}
+14 -4
View File
@@ -66,14 +66,24 @@ export const deleteFolderTool: ToolConfig<GrafanaDeleteFolderParams, GrafanaDele
return {
success: true,
output: {
uid: params?.folderUid?.trim() ?? '',
message: (data.message as string) ?? 'Folder deleted',
id: (data.id as number) ?? null,
uid: params?.folderUid?.trim() ?? null,
message: (data.message as string) ?? null,
},
}
},
outputs: {
uid: { type: 'string', description: 'The UID of the deleted folder' },
message: { type: 'string', description: 'Confirmation message' },
id: {
type: 'number',
description: 'Numeric id of the deleted folder, as returned by Grafana',
nullable: true,
},
uid: {
type: 'string',
description: 'The UID that was deleted, echoed from the request',
nullable: true,
},
message: { type: 'string', description: "Grafana's confirmation message", nullable: true },
},
}
@@ -0,0 +1,105 @@
import {
ALERT_RULE_OUTPUT_FIELDS,
type GrafanaGetAlertRuleGroupParams,
type GrafanaGetAlertRuleGroupResponse,
} from '@/tools/grafana/types'
import { mapAlertRule } from '@/tools/grafana/utils'
import type { ToolConfig } from '@/tools/types'
export const getAlertRuleGroupTool: ToolConfig<
GrafanaGetAlertRuleGroupParams,
GrafanaGetAlertRuleGroupResponse
> = {
id: 'grafana_get_alert_rule_group',
name: 'Grafana Get Alert Rule Group',
description:
'Read an alert rule group: its evaluation interval and every rule in it. The interval is the group-level knob that decides how often those rules are evaluated, which the individual alert rule operations do not expose.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Grafana Service Account Token',
},
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Grafana instance URL (e.g., https://your-grafana.com)',
},
organizationId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Organization ID for multi-org Grafana instances (e.g., 1, 2)',
},
folderUid: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'UID of the folder holding the rule group',
},
ruleGroup: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Name of the rule group',
},
},
request: {
url: (params) =>
`${params.baseUrl.replace(/\/$/, '')}/api/v1/provisioning/folder/${encodeURIComponent(
params.folderUid.trim()
)}/rule-groups/${encodeURIComponent(params.ruleGroup.trim())}`,
method: 'GET',
headers: (params) => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: `Bearer ${params.apiKey}`,
}
if (params.organizationId) {
headers['X-Grafana-Org-Id'] = params.organizationId
}
return headers
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
const rules = Array.isArray(data.rules) ? (data.rules as Record<string, unknown>[]) : []
return {
success: true,
output: {
/** The group name; this endpoint carries it as `title`, not `name`. */
title: (data.title as string) ?? null,
folderUid: (data.folderUid as string) ?? null,
interval: (data.interval as number) ?? null,
rules: rules.map(mapAlertRule),
},
}
},
outputs: {
title: { type: 'string', description: 'Name of the rule group', nullable: true },
folderUid: {
type: 'string',
description: 'UID of the folder holding the group',
nullable: true,
},
interval: {
type: 'number',
description:
'How often the group is evaluated, as an integer. Grafana returns seconds here rather than a duration string',
nullable: true,
},
rules: {
type: 'array',
description: 'Provisioned alert rules in the group',
items: { type: 'object', properties: ALERT_RULE_OUTPUT_FIELDS },
},
},
}
+8 -7
View File
@@ -36,19 +36,20 @@ export const getDataSourceTool: ToolConfig<
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID or UID of the data source to retrieve (e.g., prometheus, P1234AB5678)',
description:
'The UID of the data source to retrieve (e.g., P1234AB5678). Numeric ids are not supported — Grafana serves those only behind a disabled-by-default feature toggle',
},
},
request: {
url: (params) => {
const baseUrl = params.baseUrl.replace(/\/$/, '')
const id = params.dataSourceId.trim()
const isNumericId = /^\d+$/.test(id) && id.length <= 18
if (isNumericId) {
return `${baseUrl}/api/datasources/${id}`
}
return `${baseUrl}/api/datasources/uid/${id}`
/**
* UID only. The numeric-id route `/api/datasources/:id` exists solely
* behind Grafana's off-by-default `datasourceLegacyIdApi` feature toggle,
* so routing a numeric input there 404s on a stock instance.
*/
return `${baseUrl}/api/datasources/uid/${encodeURIComponent(params.dataSourceId.trim())}`
},
method: 'GET',
headers: (params) => {
+10
View File
@@ -6,9 +6,11 @@ import { createDashboardTool } from '@/tools/grafana/create_dashboard'
import { createFolderTool } from '@/tools/grafana/create_folder'
import { deleteAlertRuleTool } from '@/tools/grafana/delete_alert_rule'
import { deleteAnnotationTool } from '@/tools/grafana/delete_annotation'
import { deleteContactPointTool } from '@/tools/grafana/delete_contact_point'
import { deleteDashboardTool } from '@/tools/grafana/delete_dashboard'
import { deleteFolderTool } from '@/tools/grafana/delete_folder'
import { getAlertRuleTool } from '@/tools/grafana/get_alert_rule'
import { getAlertRuleGroupTool } from '@/tools/grafana/get_alert_rule_group'
import { getDashboardTool } from '@/tools/grafana/get_dashboard'
import { getDataSourceTool } from '@/tools/grafana/get_data_source'
import { getFolderTool } from '@/tools/grafana/get_folder'
@@ -19,8 +21,11 @@ import { listContactPointsTool } from '@/tools/grafana/list_contact_points'
import { listDashboardsTool } from '@/tools/grafana/list_dashboards'
import { listDataSourcesTool } from '@/tools/grafana/list_data_sources'
import { listFoldersTool } from '@/tools/grafana/list_folders'
import { moveFolderTool } from '@/tools/grafana/move_folder'
import { queryDataSourceTool } from '@/tools/grafana/query_data_source'
import { updateAlertRuleTool } from '@/tools/grafana/update_alert_rule'
import { updateAnnotationTool } from '@/tools/grafana/update_annotation'
import { updateContactPointTool } from '@/tools/grafana/update_contact_point'
import { updateDashboardTool } from '@/tools/grafana/update_dashboard'
import { updateFolderTool } from '@/tools/grafana/update_folder'
@@ -37,6 +42,11 @@ export const grafanaUpdateAlertRuleTool = updateAlertRuleTool
export const grafanaDeleteAlertRuleTool = deleteAlertRuleTool
export const grafanaListContactPointsTool = listContactPointsTool
export const grafanaCreateContactPointTool = createContactPointTool
export const grafanaUpdateContactPointTool = updateContactPointTool
export const grafanaDeleteContactPointTool = deleteContactPointTool
export const grafanaMoveFolderTool = moveFolderTool
export const grafanaQueryDataSourceTool = queryDataSourceTool
export const grafanaGetAlertRuleGroupTool = getAlertRuleGroupTool
export const grafanaCreateAnnotationTool = createAnnotationTool
export const grafanaListAnnotationsTool = listAnnotationsTool
+2 -2
View File
@@ -91,7 +91,7 @@ export const listAnnotationsTool: ToolConfig<
type: 'number',
required: false,
visibility: 'user-only',
description: 'Maximum number of annotations to return',
description: 'Maximum number of annotations to return (Grafana defaults to 100)',
},
},
@@ -102,7 +102,7 @@ export const listAnnotationsTool: ToolConfig<
if (params.from) searchParams.set('from', String(params.from))
if (params.to) searchParams.set('to', String(params.to))
if (params.dashboardUid) searchParams.set('dashboardUID', params.dashboardUid)
if (params.dashboardUid) searchParams.set('dashboardUID', params.dashboardUid.trim())
if (params.dashboardId) searchParams.set('dashboardId', String(params.dashboardId))
if (params.panelId) searchParams.set('panelId', String(params.panelId))
if (params.alertId) searchParams.set('alertId', String(params.alertId))
@@ -69,9 +69,9 @@ export const listContactPointsTool: ToolConfig<
output: {
contactPoints: Array.isArray(data)
? data.map((cp: Record<string, unknown>) => ({
uid: (cp.uid as string) ?? null,
name: (cp.name as string) ?? null,
type: (cp.type as string) ?? null,
uid: (cp.uid as string) ?? '',
name: (cp.name as string) ?? '',
type: (cp.type as string) ?? '',
settings: (cp.settings as Record<string, unknown>) ?? {},
disableResolveMessage: (cp.disableResolveMessage as boolean) ?? false,
provenance: (cp.provenance as string) ?? '',
@@ -91,14 +91,15 @@ export const listContactPointsTool: ToolConfig<
uid: { type: 'string', description: 'Contact point UID' },
name: { type: 'string', description: 'Contact point name' },
type: { type: 'string', description: 'Notification type (email, slack, etc.)' },
settings: { type: 'object', description: 'Type-specific settings' },
settings: { type: 'json', description: 'Type-specific settings' },
disableResolveMessage: {
type: 'boolean',
description: 'Whether resolve messages are disabled',
},
provenance: {
type: 'string',
description: 'Provisioning source (empty if API-managed)',
description:
'Provisioning source — "api" for API-managed, empty when created with X-Disable-Provenance and therefore still editable in the Grafana UI',
},
},
},
+136
View File
@@ -0,0 +1,136 @@
import type { GrafanaMoveFolderParams, GrafanaMoveFolderResponse } from '@/tools/grafana/types'
import type { ToolConfig } from '@/tools/types'
export const moveFolderTool: ToolConfig<GrafanaMoveFolderParams, GrafanaMoveFolderResponse> = {
id: 'grafana_move_folder',
name: 'Grafana Move Folder',
description:
'Move a folder under a different parent folder, or to the root by leaving the parent empty. Returns the folder with its new ancestry.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Grafana Service Account Token',
},
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Grafana instance URL (e.g., https://your-grafana.com)',
},
organizationId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Organization ID for multi-org Grafana instances (e.g., 1, 2)',
},
folderUid: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'UID of the folder to move',
},
parentUid: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'UID of the new parent folder. Leave empty to move the folder to the root',
},
},
request: {
url: (params) =>
`${params.baseUrl.replace(/\/$/, '')}/api/folders/${encodeURIComponent(
params.folderUid.trim()
)}/move`,
method: 'POST',
headers: (params) => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: `Bearer ${params.apiKey}`,
}
if (params.organizationId) {
headers['X-Grafana-Org-Id'] = params.organizationId
}
return headers
},
/**
* Grafana requires a body, and reads an empty `parentUid` as "move to the
* root", so the key is always present rather than conditionally omitted.
*/
body: (params) => ({ parentUid: params.parentUid?.trim() ?? '' }),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
id: (data.id as number) ?? null,
uid: (data.uid as string) ?? null,
title: (data.title as string) ?? null,
url: (data.url as string) ?? null,
parentUid: (data.parentUid as string) ?? null,
parents: (data.parents as { uid: string; title: string; url: string }[]) ?? [],
hasAcl: (data.hasAcl as boolean) ?? null,
canSave: (data.canSave as boolean) ?? null,
canEdit: (data.canEdit as boolean) ?? null,
canAdmin: (data.canAdmin as boolean) ?? null,
createdBy: (data.createdBy as string) ?? null,
created: (data.created as string) ?? null,
updatedBy: (data.updatedBy as string) ?? null,
updated: (data.updated as string) ?? null,
version: (data.version as number) ?? null,
},
}
},
outputs: {
id: { type: 'number', description: 'The numeric ID of the folder', nullable: true },
uid: { type: 'string', description: 'The UID of the folder', nullable: true },
title: { type: 'string', description: 'The title of the folder', nullable: true },
url: { type: 'string', description: 'The URL path of the folder', nullable: true },
parentUid: {
type: 'string',
description: 'UID of the new parent folder, absent once moved to the root',
nullable: true,
},
parents: {
type: 'array',
description: 'Folder ancestry from the root down to the parent (uid, title, url)',
},
hasAcl: {
type: 'boolean',
description: 'Whether the folder has custom ACL permissions',
nullable: true,
},
canSave: {
type: 'boolean',
description: 'Whether the caller can save the folder',
nullable: true,
},
canEdit: {
type: 'boolean',
description: 'Whether the caller can edit the folder',
nullable: true,
},
canAdmin: {
type: 'boolean',
description: 'Whether the caller can administer the folder',
nullable: true,
},
createdBy: { type: 'string', description: 'Login that created the folder', nullable: true },
created: { type: 'string', description: 'Creation timestamp', nullable: true },
updatedBy: {
type: 'string',
description: 'Login that last updated the folder',
nullable: true,
},
updated: { type: 'string', description: 'Last update timestamp', nullable: true },
version: { type: 'number', description: 'Folder revision number', nullable: true },
},
}
+197
View File
@@ -0,0 +1,197 @@
import type {
GrafanaQueryDataSourceParams,
GrafanaQueryDataSourceResponse,
GrafanaQuerySeries,
} from '@/tools/grafana/types'
import type { ToolConfig } from '@/tools/types'
interface QueryFrameField {
name?: string
type?: string
}
interface QueryFrame {
schema?: { refId?: string; fields?: QueryFrameField[] }
data?: { values?: unknown[][] }
}
/**
* Flattens Grafana's columnar frames into row objects.
*
* A frame carries `schema.fields[]` alongside `data.values[]`, where
* `values[i]` is the whole column for `fields[i]`. Zipping them by position is
* mechanically derived from that documented layout, so nothing here depends on
* knowing a particular data source's field names.
*/
function flattenFrames(results: Record<string, { frames?: QueryFrame[] }>): GrafanaQuerySeries[] {
const series: GrafanaQuerySeries[] = []
for (const [refId, result] of Object.entries(results)) {
for (const frame of result?.frames ?? []) {
const fields = (frame.schema?.fields ?? []).map((field) => ({
name: field.name ?? '',
type: field.type ?? null,
}))
const columns = frame.data?.values ?? []
const rowCount = columns.reduce((max, column) => Math.max(max, column?.length ?? 0), 0)
const rows: Record<string, unknown>[] = []
for (let row = 0; row < rowCount; row++) {
const record: Record<string, unknown> = {}
fields.forEach((field, index) => {
if (field.name) record[field.name] = columns[index]?.[row] ?? null
})
rows.push(record)
}
series.push({
refId: frame.schema?.refId ?? refId,
fields,
rowCount,
rows,
})
}
}
return series
}
export const queryDataSourceTool: ToolConfig<
GrafanaQueryDataSourceParams,
GrafanaQueryDataSourceResponse
> = {
id: 'grafana_query_data_source',
name: 'Grafana Query Data Source',
description:
'Run one or more queries against a Grafana data source that has a backend implementation, and read the values back. This is how you get actual metric numbers out of Grafana rather than dashboard or alert configuration.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Grafana Service Account Token',
},
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Grafana instance URL (e.g., https://your-grafana.com)',
},
organizationId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Organization ID for multi-org Grafana instances (e.g., 1, 2)',
},
queries: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'JSON array of at least one query. Each needs a datasource.uid and a refId, plus the fields that data source expects — expr for Prometheus, rawSql for SQL. Example: [{"refId":"A","datasource":{"uid":"P123"},"expr":"up","format":"time_series"}]',
},
from: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Start of the time range, either epoch milliseconds or Grafana relative time (e.g., now-5m). Defaults to now-1h',
},
to: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'End of the time range, epoch milliseconds or relative (e.g., now)',
},
},
request: {
url: (params) => `${params.baseUrl.replace(/\/$/, '')}/api/ds/query`,
method: 'POST',
headers: (params) => {
const headers: Record<string, string> = {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params.apiKey}`,
}
if (params.organizationId) {
headers['X-Grafana-Org-Id'] = params.organizationId
}
return headers
},
body: (params) => {
let queries: unknown
try {
queries = JSON.parse(params.queries)
} catch {
throw new Error('Invalid JSON for queries parameter')
}
if (!Array.isArray(queries) || queries.length === 0) {
throw new Error('queries must be a JSON array with at least one query')
}
return {
queries,
from: params.from?.trim() || 'now-1h',
to: params.to?.trim() || 'now',
}
},
},
transformResponse: async (response: Response) => {
const data = (await response.json()) as {
results?: Record<string, { frames?: QueryFrame[] }>
}
const results = data.results ?? {}
return {
success: true,
output: {
results,
series: flattenFrames(results),
},
}
},
outputs: {
results: {
type: 'json',
description:
'Raw Grafana response, keyed by each query refId, each holding the frames that query produced',
},
series: {
type: 'array',
description:
'The same frames flattened into rows, so values can be read without walking the columnar layout',
items: {
type: 'object',
properties: {
refId: { type: 'string', description: 'The query this frame came from' },
fields: {
type: 'array',
description: 'Field metadata in column order',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Field name, e.g. time or A-series' },
type: {
type: 'string',
description: 'Field type, e.g. time or number',
nullable: true,
},
},
},
},
rowCount: { type: 'number', description: 'Number of rows in the frame' },
rows: {
type: 'array',
description: 'Rows keyed by field name',
items: { type: 'object' },
},
},
},
},
},
}
+88 -6
View File
@@ -29,7 +29,11 @@ export const ALERT_RULE_OUTPUT_FIELDS: Record<string, OutputProperty> = {
folderUID: { type: 'string', description: 'Parent folder UID' },
ruleGroup: { type: 'string', description: 'Rule group name' },
orgID: { type: 'number', description: 'Organization ID' },
provenance: { type: 'string', description: 'Provisioning source (empty if API-managed)' },
provenance: {
type: 'string',
description:
'Provisioning source — "api" for API-managed, empty when created with X-Disable-Provenance and therefore still editable in the Grafana UI',
},
notification_settings: {
type: 'json',
description: 'Per-rule notification settings (overrides)',
@@ -65,7 +69,8 @@ export interface GrafanaDataSourceHealthParams extends GrafanaBaseParams {
export interface GrafanaDataSourceHealthResponse extends ToolResponse {
output: {
status: string
message: string
message: string | null
details?: unknown
}
}
@@ -371,8 +376,9 @@ export interface GrafanaUpdateAnnotationParams extends GrafanaBaseParams {
export interface GrafanaUpdateAnnotationResponse extends ToolResponse {
output: {
id: number
message: string
/** Echoed from the request — Grafana's patch response carries no id. */
annotationId: number | null
message: string | null
}
}
@@ -493,8 +499,11 @@ export interface GrafanaDeleteFolderParams extends GrafanaBaseParams {
export interface GrafanaDeleteFolderResponse extends ToolResponse {
output: {
uid: string
message: string
/** Numeric id Grafana returns for the deleted folder. */
id: number | null
/** Echoed from the request. */
uid: string | null
message: string | null
}
}
@@ -562,3 +571,76 @@ export type GrafanaResponse =
| GrafanaDeleteFolderResponse
| GrafanaListContactPointsResponse
| GrafanaCreateContactPointResponse
export interface GrafanaUpdateContactPointParams extends GrafanaBaseParams {
contactPointUid: string
name: string
type: string
settings: string
disableResolveMessage?: boolean
disableProvenance?: boolean
}
export interface GrafanaUpdateContactPointResponse extends ToolResponse {
output: {
/** Echoed from the request — the update answers with only a message. */
uid: string | null
message: string | null
}
}
export interface GrafanaDeleteContactPointParams extends GrafanaBaseParams {
contactPointUid: string
}
export interface GrafanaDeleteContactPointResponse extends ToolResponse {
output: {
/** Echoed from the request. */
uid: string | null
message: string | null
}
}
export interface GrafanaMoveFolderParams extends GrafanaBaseParams {
folderUid: string
parentUid?: string
}
export interface GrafanaMoveFolderResponse extends ToolResponse {
output: GrafanaFolder
}
export interface GrafanaGetAlertRuleGroupParams extends GrafanaBaseParams {
folderUid: string
ruleGroup: string
}
export interface GrafanaGetAlertRuleGroupResponse extends ToolResponse {
output: {
title: string | null
folderUid: string | null
/** Evaluation interval in seconds. */
interval: number | null
rules: ReturnType<typeof import('@/tools/grafana/utils').mapAlertRule>[]
}
}
export interface GrafanaQueryDataSourceParams extends GrafanaBaseParams {
queries: string
from?: string
to?: string
}
export interface GrafanaQuerySeries {
refId: string
fields: { name: string; type: string | null }[]
rowCount: number
rows: Record<string, unknown>[]
}
export interface GrafanaQueryDataSourceResponse extends ToolResponse {
output: {
results: Record<string, unknown>
series: GrafanaQuerySeries[]
}
}
+9 -6
View File
@@ -95,26 +95,29 @@ export const updateAnnotationTool: ToolConfig<
},
},
transformResponse: async (response: Response) => {
transformResponse: async (response: Response, params) => {
const data = await response.json()
return {
success: true,
output: {
id: data.id || 0,
message: data.message || 'Annotation updated successfully',
annotationId: params?.annotationId ?? null,
message: (data.message as string) ?? null,
},
}
},
outputs: {
id: {
annotationId: {
type: 'number',
description: 'The ID of the updated annotation',
description:
'The annotation that was updated, echoed from the request — Grafana answers a patch with only a message and returns no id',
nullable: true,
},
message: {
type: 'string',
description: 'Confirmation message',
description: `Confirmation message from Grafana, e.g. "Annotation patched"`,
nullable: true,
},
},
}
@@ -0,0 +1,150 @@
import type {
GrafanaUpdateContactPointParams,
GrafanaUpdateContactPointResponse,
} from '@/tools/grafana/types'
import type { ToolConfig } from '@/tools/types'
export const updateContactPointTool: ToolConfig<
GrafanaUpdateContactPointParams,
GrafanaUpdateContactPointResponse
> = {
id: 'grafana_update_contact_point',
name: 'Grafana Update Contact Point',
description:
'Replace a contact point by its UID. Grafana has no partial update for contact points, so every field is rewritten — resend the name, type, and full settings, or the omitted ones are reset.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Grafana Service Account Token',
},
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Grafana instance URL (e.g., https://your-grafana.com)',
},
organizationId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Organization ID for multi-org Grafana instances (e.g., 1, 2)',
},
contactPointUid: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'UID of the contact point to replace',
},
name: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Contact point name. Grafana groups receivers that share a name',
},
type: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'Receiver type, e.g. slack, email, pagerduty, webhook, opsgenie, teams, discord, telegram',
},
settings: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'JSON object of receiver settings for this type, e.g. {"url":"https://hooks.slack.com/..."} for slack',
},
disableResolveMessage: {
type: 'boolean',
required: false,
visibility: 'user-only',
description: 'Suppress the resolved notification. Omitting this resets it to false',
},
disableProvenance: {
type: 'boolean',
required: false,
visibility: 'user-only',
description:
'Send X-Disable-Provenance. Use only on a contact point whose provenance is already empty (UI-created, or created by Sim with this on) — sending it against an API-provisioned contact point is rejected with 403',
},
},
request: {
url: (params) =>
`${params.baseUrl.replace(/\/$/, '')}/api/v1/provisioning/contact-points/${encodeURIComponent(
params.contactPointUid.trim()
)}`,
method: 'PUT',
headers: (params) => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: `Bearer ${params.apiKey}`,
}
if (params.organizationId) {
headers['X-Grafana-Org-Id'] = params.organizationId
}
/** Grafana tests this header by presence, so it is only ever sent when wanted. */
if (params.disableProvenance) {
headers['X-Disable-Provenance'] = 'true'
}
return headers
},
body: (params) => {
let settings: Record<string, unknown> = {}
try {
settings = JSON.parse(params.settings)
} catch {
throw new Error('Invalid JSON for settings parameter')
}
/**
* The UID is taken from the path — Grafana overwrites any UID in the body —
* so it is deliberately not sent.
*/
const body: Record<string, unknown> = {
name: params.name,
type: params.type,
settings,
}
if (params.disableResolveMessage !== undefined) {
body.disableResolveMessage = params.disableResolveMessage
}
return body
},
},
/**
* The update answers 202 with only a message — unlike create, which returns the
* object — so the UID is echoed from the request.
*/
transformResponse: async (response: Response, params) => {
const data = (await response.json().catch(() => ({}))) as { message?: string }
return {
success: true,
output: {
uid: params?.contactPointUid?.trim() ?? null,
message: data.message ?? null,
},
}
},
outputs: {
uid: {
type: 'string',
description:
'The UID that was updated, echoed from the request — Grafana answers a contact point update with only a message and returns no object',
nullable: true,
},
message: {
type: 'string',
description: 'Confirmation message from Grafana, e.g. "contactpoint updated"',
nullable: true,
},
},
}
+10
View File
@@ -1785,8 +1785,10 @@ import {
grafanaCreateFolderTool,
grafanaDeleteAlertRuleTool,
grafanaDeleteAnnotationTool,
grafanaDeleteContactPointTool,
grafanaDeleteDashboardTool,
grafanaDeleteFolderTool,
grafanaGetAlertRuleGroupTool,
grafanaGetAlertRuleTool,
grafanaGetDashboardTool,
grafanaGetDataSourceTool,
@@ -1798,8 +1800,11 @@ import {
grafanaListDashboardsTool,
grafanaListDataSourcesTool,
grafanaListFoldersTool,
grafanaMoveFolderTool,
grafanaQueryDataSourceTool,
grafanaUpdateAlertRuleTool,
grafanaUpdateAnnotationTool,
grafanaUpdateContactPointTool,
grafanaUpdateDashboardTool,
grafanaUpdateFolderTool,
} from '@/tools/grafana'
@@ -5383,23 +5388,28 @@ export const tools: Record<string, ToolConfig> = {
grafana_delete_dashboard: grafanaDeleteDashboardTool,
grafana_list_alert_rules: grafanaListAlertRulesTool,
grafana_get_alert_rule: grafanaGetAlertRuleTool,
grafana_get_alert_rule_group: grafanaGetAlertRuleGroupTool,
grafana_create_alert_rule: grafanaCreateAlertRuleTool,
grafana_update_alert_rule: grafanaUpdateAlertRuleTool,
grafana_delete_alert_rule: grafanaDeleteAlertRuleTool,
grafana_list_contact_points: grafanaListContactPointsTool,
grafana_create_contact_point: grafanaCreateContactPointTool,
grafana_update_contact_point: grafanaUpdateContactPointTool,
grafana_delete_contact_point: grafanaDeleteContactPointTool,
grafana_create_annotation: grafanaCreateAnnotationTool,
grafana_list_annotations: grafanaListAnnotationsTool,
grafana_update_annotation: grafanaUpdateAnnotationTool,
grafana_delete_annotation: grafanaDeleteAnnotationTool,
grafana_list_data_sources: grafanaListDataSourcesTool,
grafana_get_data_source: grafanaGetDataSourceTool,
grafana_query_data_source: grafanaQueryDataSourceTool,
grafana_check_data_source_health: grafanaCheckDataSourceHealthTool,
grafana_list_folders: grafanaListFoldersTool,
grafana_create_folder: grafanaCreateFolderTool,
grafana_get_folder: grafanaGetFolderTool,
grafana_update_folder: grafanaUpdateFolderTool,
grafana_delete_folder: grafanaDeleteFolderTool,
grafana_move_folder: grafanaMoveFolderTool,
grafana_get_health: grafanaGetHealthTool,
google_search: googleSearchTool,
greenhouse_list_candidates: greenhouseListCandidatesTool,
+2 -2
View File
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
const BASELINE = {
totalRoutes: 1107,
zodRoutes: 1107,
totalRoutes: 1108,
zodRoutes: 1108,
nonZodRoutes: 0,
} as const