@marble-sh/backstage-plugin-grafana-backend 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,88 @@
1
1
  # @marble-sh/backstage-plugin-grafana-backend
2
2
 
3
+ ## 1.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 33ad02e: Hardening fixes from a cross-package audit:
8
+
9
+ - **Backend**: a read that fans out over all instances now skips (and logs)
10
+ an unreachable instance instead of failing the whole request; a named
11
+ instance still propagates its error. The panel routes accept
12
+ `refresh=true` (gated by `allowOnDemandRefresh`) to bypass the panel
13
+ cache, and the frontend's "Refresh panels" button uses it — previously
14
+ the button silently served cached data. Startup warns when
15
+ `store: database` is combined with no `schedule` (snapshots would never
16
+ expire).
17
+ - **Frontend**: `GrafanaApi.listPanels`/`getPanelData` are now optional, as
18
+ the changelog already promised — pre-existing custom implementations
19
+ compile again; the dashboards tab falls back to a Grafana link when they
20
+ are absent.
21
+ - **Node library**: the per-instance dashboard-model cache evicts expired
22
+ entries instead of growing forever; an empty `__panelId__` annotation is
23
+ no longer parsed as panel `0`; generated fallback refIds can no longer
24
+ collide with an explicitly declared refId (Grafana keys query results by
25
+ refId); failed _hidden_ queries no longer surface user-facing warnings.
26
+ - **Catalog module**: discovered dashboard entities now carry only
27
+ `grafana/dashboard-uid`, not a title-based `grafana/dashboard-selector` —
28
+ the selectors AND together, so a stale title (dashboard renamed in
29
+ Grafana between discovery runs) hid the dashboard its own uid still
30
+ matched.
31
+ - **Scaffolder module**: `grafana.scaffolder.allowedInstances` is validated
32
+ at startup (like the catalog module) instead of on every run; an
33
+ `overwrite` update reads the current dashboard first, carries its
34
+ `metadata.resourceVersion` into the `PUT`, and degrades to a plain create
35
+ when the dashboard does not exist yet, keeping template runs idempotent.
36
+
37
+ ### Patch Changes
38
+
39
+ - c9c9f23: Documentation: a full entity-annotation reference (exact matching semantics,
40
+ how the annotations combine, visibility gating, and error behavior for
41
+ unknown instance names) in the frontend and common READMEs, and a new
42
+ "Creating the Grafana service account and token" walkthrough in the backend
43
+ README — UI steps, the Grafana Cloud `glsa_` vs `glc_` token distinction,
44
+ and a per-feature permission table (Viewer covers all read paths;
45
+ `datasources:query` caveat for panel graphs under Enterprise/Cloud data
46
+ source permissions; Editor / `fixed:dashboards:writer` for the scaffolder
47
+ module).
48
+ - 9664e66: Documentation consistency pass: the backend README no longer claims the
49
+ catalog module consumes the REST API (it uses the shared node client
50
+ server-side); the scaffolder README/config schema document startup
51
+ validation of `allowedInstances` and the idempotent
52
+ `overwrite` (resourceVersion carry, create fallback); fan-out
53
+ failure-skipping and panel-route `refresh` are documented; and the
54
+ grafana-node README credits all three consumers.
55
+ - Updated dependencies [c9c9f23]
56
+ - Updated dependencies [33ad02e]
57
+ - Updated dependencies [9664e66]
58
+ - @marble-sh/backstage-plugin-grafana-common@1.2.1
59
+ - @marble-sh/backstage-plugin-grafana-node@1.2.1
60
+
61
+ ## 1.2.0
62
+
63
+ ### Minor Changes
64
+
65
+ - a31b91d: Added: the dashboards tab now renders real graphs and the alerts tab a live
66
+ detail table. The backend gained read-only panel routes
67
+ (`GET …/dashboards/:uid/panels` and `GET …/panels/:panelId/data?from&to`)
68
+ that read a dashboard's model, resolve its template variables' current
69
+ values, query the panel targets through Grafana's `/api/ds/query`, and
70
+ return normalized time series — gated by the new `grafana.allowPanelQueries`
71
+ flag and cached per `grafana.panelDataCacheTtl` (default 30s). The frontend
72
+ draws `timeseries`/`graph` panels as charts and `stat`/`gauge`/`singlestat`
73
+ panels as value tiles, per-dashboard and lazily, with a time-range picker
74
+ and refresh. Alerts are enriched with rule uid (deep links), health,
75
+ active-since, active instance count, dashboard/panel links, and the
76
+ `summary` annotation. `GrafanaClient`/`GrafanaService` gained _optional_
77
+ `getPanels`/`getPanelData` members, so existing custom implementations
78
+ remain compatible.
79
+
80
+ ### Patch Changes
81
+
82
+ - Updated dependencies [a31b91d]
83
+ - @marble-sh/backstage-plugin-grafana-common@1.2.0
84
+ - @marble-sh/backstage-plugin-grafana-node@1.2.0
85
+
3
86
  ## 1.1.0
4
87
 
5
88
  ### Minor Changes
package/README.md CHANGED
@@ -3,13 +3,15 @@
3
3
  A read-only [Grafana](https://grafana.com/) backend plugin for Backstage,
4
4
  targeting the **new backend system**.
5
5
 
6
- The backend performs **all** communication with Grafana. It reads dashboards and
7
- alerts, caches them (in the Backstage cache **or** database), optionally
8
- refreshes them on a schedule, and exposes a small read-only REST API under
9
- `/api/grafana`. The frontend plugin
10
- ([`@marble-sh/backstage-plugin-grafana`](../grafana/README.md)) and the catalog
11
- module talk only to this API — they never contact Grafana directly, so all
12
- credentials stay in the backend.
6
+ The backend performs **all** browser-facing communication with Grafana. It
7
+ reads dashboards and alerts, caches them (in the Backstage cache **or**
8
+ database), optionally refreshes them on a schedule, and exposes a small
9
+ read-only REST API under `/api/grafana`. The frontend plugin
10
+ ([`@marble-sh/backstage-plugin-grafana`](../grafana/README.md)) talks only to
11
+ this API and never contacts Grafana; the catalog and scaffolder modules run
12
+ server-side and reach Grafana through the shared
13
+ [`grafana-node`](../grafana-node/README.md) client. Either way, credentials
14
+ never leave the backend.
13
15
 
14
16
  ## Features
15
17
 
@@ -18,7 +20,14 @@ credentials stay in the backend.
18
20
  `apis.dashboards: legacy-search` — from the classic `/api/search` endpoint
19
21
  for older Grafana versions. Folder titles and links are resolved for both.
20
22
  - Reads alert rules and their live state from the stable Grafana-managed
21
- Prometheus rules API (`/api/prometheus/grafana/api/v1/rules`).
23
+ Prometheus rules API (`/api/prometheus/grafana/api/v1/rules`), including
24
+ rule uid (for deep links), health, active-since time, active instance
25
+ count, dashboard/panel links, and the `summary` annotation.
26
+ - Serves **panel listings and live panel data**: reads a dashboard's model,
27
+ substitutes its template variables' current values, queries the panel's
28
+ targets through `POST /api/ds/query`, and returns plain normalized time
29
+ series — so the frontend can draw real graphs without ever talking to
30
+ Grafana. See [ADR 0006](../../docs/adr/0006-panel-graphs-via-backend-query-proxy.md).
22
31
  - Works with both **Grafana Cloud** and **self-hosted** Grafana, using a
23
32
  service-account token.
24
33
  - Supports **multiple instances**, each addressable by name.
@@ -57,13 +66,19 @@ instance list picks it up.
57
66
  grafana:
58
67
  # Where fetched data is stored between refreshes:
59
68
  # cache (default) – ephemeral, honors cacheTtl
60
- # database – durable, survives restarts and is shared across replicas
69
+ # database – durable, survives restarts and is shared across replicas.
70
+ # Database snapshots never expire, so configure a
71
+ # `schedule` with it — otherwise data only updates on
72
+ # explicit refreshes (the backend warns at startup).
61
73
  store: cache
62
74
  cacheTtl: { minutes: 15 }
63
75
 
64
- # Behavior flags (both default to true; see "Behavior flags" below).
76
+ # Behavior flags (all default to true; see "Behavior flags" below).
65
77
  allowOnDemandRefresh: true
66
78
  fetchOnDemand: true
79
+ allowPanelQueries: true
80
+ # How long live panel data is cached (see "Behavior flags" below).
81
+ panelDataCacheTtl: { seconds: 30 }
67
82
 
68
83
  # Optional background refresh. Omit to fetch lazily on request instead.
69
84
  schedule:
@@ -76,7 +91,7 @@ grafana:
76
91
  - name: production
77
92
  title: Production Grafana
78
93
  baseUrl: https://grafana.internal.example.com
79
- token: ${GRAFANA_PROD_TOKEN} # service-account token, Viewer is enough
94
+ token: ${GRAFANA_PROD_TOKEN} # service-account token, see "Creating the Grafana service account" below
80
95
 
81
96
  # A Grafana Cloud stack (namespace derived as "stacks-<stackId>").
82
97
  - name: cloud
@@ -88,16 +103,16 @@ grafana:
88
103
 
89
104
  ### Instance options
90
105
 
91
- | Key | Required | Description |
92
- | ---------------- | -------- | ------------------------------------------------------------------------------------------------------- |
93
- | `name` | yes | Unique, stable id. Referenced by the `grafana/instance` entity annotation and by the REST API. |
94
- | `baseUrl` | yes | Base URL of the Grafana instance, without a trailing slash. |
95
- | `token` | yes | Service-account token used as a Bearer token. Read-only (Viewer) permissions are enough. Marked secret. |
96
- | `title` | no | Human-readable title. Defaults to `name`. |
97
- | `namespace` | no | App Platform namespace. Defaults to `default` (self-hosted) or `stacks-<stackId>` (cloud). |
98
- | `stackId` | no | **Numeric** Grafana Cloud stack id (not the stack slug), used to derive the namespace (see below). |
99
- | `apis` | no | Override or disable the API used per data type (see below). |
100
- | `resolveFolders` | no | `false` skips the `/api/folders` folder lookup (see below). Defaults to `true`. |
106
+ | Key | Required | Description |
107
+ | ---------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
108
+ | `name` | yes | Unique, stable id. Referenced by the `grafana/instance` entity annotation and by the REST API. |
109
+ | `baseUrl` | yes | Base URL of the Grafana instance, without a trailing slash. |
110
+ | `token` | yes | Service-account token used as a Bearer token — see [Creating the Grafana service account and token](#creating-the-grafana-service-account-and-token). Marked secret. |
111
+ | `title` | no | Human-readable title. Defaults to `name`. |
112
+ | `namespace` | no | App Platform namespace. Defaults to `default` (self-hosted) or `stacks-<stackId>` (cloud). |
113
+ | `stackId` | no | **Numeric** Grafana Cloud stack id (not the stack slug), used to derive the namespace (see below). |
114
+ | `apis` | no | Override or disable the API used per data type (see below). |
115
+ | `resolveFolders` | no | `false` skips the `/api/folders` folder lookup (see below). Defaults to `true`. |
101
116
 
102
117
  ### API selection
103
118
 
@@ -131,32 +146,43 @@ Every state, per data type:
131
146
 
132
147
  ### Behavior flags
133
148
 
134
- Two top-level flags control when the backend talks to Grafana. Both default to
135
- `true`; each can be set independently.
149
+ Three top-level flags control when the backend talks to Grafana. All default
150
+ to `true`; each can be set independently.
136
151
 
137
152
  - **`allowOnDemandRefresh`** — may API callers force live reads?
138
153
 
139
154
  - `true` (default): `?refresh=true` (also `?refresh=1` or the bare flag)
140
- bypasses the store, and `POST /refresh` / `POST /instances/:name/refresh`
141
- trigger immediate refreshes.
155
+ bypasses the store (on the panel routes: the panel cache), and
156
+ `POST /refresh` / `POST /instances/:name/refresh` trigger immediate
157
+ refreshes.
142
158
  - `false`: `refresh` query parameters are silently ignored (the request is
143
159
  served exactly as if the parameter were absent) and both `POST …/refresh`
144
160
  routes respond `403 NotAllowedError`. The scheduled refresh is unaffected.
145
161
 
146
162
  - **`fetchOnDemand`** — does a store miss trigger a live read?
163
+
147
164
  - `true` (default): a miss fetches from Grafana on the spot and stores the
148
165
  snapshot.
149
166
  - `false`: a miss returns empty results and stores nothing; data appears
150
167
  once any refresh runs.
151
168
 
169
+ - **`allowPanelQueries`** — are the panel routes served?
170
+ - `true` (default): `GET …/dashboards/:uid/panels` and
171
+ `GET …/panels/:panelId/data` read live from Grafana (the dashboard model
172
+ plus the `/api/ds/query` datasource API), briefly cached per
173
+ `grafana.panelDataCacheTtl` (default 30 seconds) to absorb bursts.
174
+ - `false`: both routes respond `403`. Panel data cannot come from the
175
+ snapshot store, so deployments that need strictly schedule-only Grafana
176
+ traffic should set this alongside the other two flags.
177
+
152
178
  All four combinations:
153
179
 
154
- | `allowOnDemandRefresh` | `fetchOnDemand` | Resulting behavior |
155
- | ---------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
156
- | `true` | `true` | (Default.) Misses fetch lazily; users may force refreshes. |
157
- | `true` | `false` | Misses return empty, but an explicit `?refresh=true` or `POST …/refresh` still reads live and fills the store. |
158
- | `false` | `true` | Users cannot force refreshes, but a cold store still fills itself lazily on first read. |
159
- | `false` | `false` | Grafana is contacted **only** by the schedule (configure one, or the API serves empty forever). Fully deterministic Grafana traffic. |
180
+ | `allowOnDemandRefresh` | `fetchOnDemand` | Resulting behavior |
181
+ | ---------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
182
+ | `true` | `true` | (Default.) Misses fetch lazily; users may force refreshes. |
183
+ | `true` | `false` | Misses return empty, but an explicit `?refresh=true` or `POST …/refresh` still reads live and fills the store. |
184
+ | `false` | `true` | Users cannot force refreshes, but a cold store still fills itself lazily on first read. |
185
+ | `false` | `false` | Grafana is contacted **only** by the schedule (configure one, or the API serves empty forever). With `allowPanelQueries: false` as well, Grafana traffic is fully deterministic. |
160
186
 
161
187
  ### Deriving the namespace
162
188
 
@@ -181,21 +207,86 @@ is rejected at startup. Two ways to find the id:
181
207
  # → "stacks-1216502" — the number is the stackId
182
208
  ```
183
209
 
210
+ ## Creating the Grafana service account and token
211
+
212
+ Every request to Grafana is authenticated with a Grafana **service account
213
+ token** — the `token` of the instance configuration. Create one per instance
214
+ (creating service accounts requires a Grafana organization administrator):
215
+
216
+ 1. Sign in to the Grafana instance itself — for Grafana Cloud that is the
217
+ stack's own Grafana at `https://<stack>.grafana.net`, **not** the
218
+ grafana.com portal — and open **Administration → Users and access →
219
+ Service accounts**.
220
+ 2. Click **Add service account**, give it a recognizable display name (for
221
+ example `backstage`), and assign it the **Viewer** role (see
222
+ [required permissions](#required-permissions) for when you need more).
223
+ 3. On the new service account, click **Add service account token**, name the
224
+ token, preferably set an expiration date, and click **Generate token**.
225
+ 4. Copy the generated value — it starts with `glsa_` — into the instance's
226
+ `token` (via an environment variable or another secret source; never
227
+ commit it).
228
+
229
+ > **Grafana Cloud:** the plugin needs a per-stack service-account token
230
+ > (`glsa_…`) created inside the stack's Grafana as above. A Cloud _access
231
+ > policy_ token from the grafana.com portal (`glc_…`) authenticates a
232
+ > different API surface and will not work here.
233
+
234
+ ### Required permissions
235
+
236
+ What the token needs depends on the features you use:
237
+
238
+ | Feature (Grafana APIs called) | Required access |
239
+ | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
240
+ | Dashboard listing and catalog discovery (`dashboard.grafana.app` list, `/api/search`) | `dashboards:read` |
241
+ | Folder titles/links, i.e. `resolveFolders` (`/api/folders`) | `folders:read` |
242
+ | Alerts (`/api/prometheus/grafana/api/v1/rules`) | Grafana-managed alert-rule read (the `fixed:alerting.rules:reader` role) |
243
+ | Panel graphs (dashboard model read + `POST /api/ds/query`) | `dashboards:read` plus `datasources:query` on the queried data sources |
244
+ | [Scaffolder action](../scaffolder-backend-module-grafana/README.md) (`dashboard.grafana.app` create/update) | `dashboards:read` + `dashboards:create` (+ `dashboards:write` for `overwrite`) |
245
+
246
+ In practice:
247
+
248
+ - The **Viewer** basic role covers everything this plugin and the catalog
249
+ module read: dashboards, folders, and Grafana-managed alert rules — and, on
250
+ OSS Grafana (and any instance without data source permissions enforced),
251
+ the panel-data queries too, since viewers query data sources whenever they
252
+ view a dashboard.
253
+ - On **Grafana Enterprise / Cloud with data source permissions**, the Viewer
254
+ basic role does not automatically include `datasources:query` on every data
255
+ source. If panel graphs fail with authorization errors, grant the service
256
+ account query access on the relevant data sources (or the RBAC
257
+ `fixed:datasources:reader` role), or disable the panel routes with
258
+ `grafana.allowPanelQueries: false`.
259
+ - The **scaffolder module writes** dashboards: give that instance's service
260
+ account the **Editor** basic role, or (with RBAC) `fixed:dashboards:writer`
261
+ scoped to the folders your templates target.
262
+ - An instance that serves fewer features needs fewer permissions — e.g. with
263
+ `apis.alerts: none` the alerting API is never called, so alert-rule read
264
+ access is unnecessary.
265
+
266
+ To inspect exactly what a token is allowed to do, ask Grafana itself:
267
+
268
+ ```sh
269
+ curl -s -H "Authorization: Bearer $TOKEN" \
270
+ "$BASE_URL/api/access-control/user/permissions" | jq
271
+ ```
272
+
184
273
  ## REST API
185
274
 
186
275
  All routes are mounted under `/api/grafana` and (except `/health`) require a
187
276
  valid Backstage credential.
188
277
 
189
- | Method | Path | Description |
190
- | ------ | ----------------------------- | ---------------------------------------------------------- |
191
- | GET | `/health` | Health check (unauthenticated). |
192
- | GET | `/instances` | List configured instances. |
193
- | GET | `/instances/:name/dashboards` | Dashboards for one instance. |
194
- | GET | `/instances/:name/alerts` | Alerts for one instance. |
195
- | POST | `/instances/:name/refresh` | Force a refresh of one instance. |
196
- | GET | `/dashboards` | Dashboards across all instances (or `?instance=` for one). |
197
- | GET | `/alerts` | Alerts across all instances (or `?instance=` for one). |
198
- | POST | `/refresh` | Force a refresh of all instances. |
278
+ | Method | Path | Description |
279
+ | ------ | ------------------------------------------------------- | ---------------------------------------------------------- |
280
+ | GET | `/health` | Health check (unauthenticated). |
281
+ | GET | `/instances` | List configured instances. |
282
+ | GET | `/instances/:name/dashboards` | Dashboards for one instance. |
283
+ | GET | `/instances/:name/dashboards/:uid/panels` | The panels of one dashboard (live read). |
284
+ | GET | `/instances/:name/dashboards/:uid/panels/:panelId/data` | Normalized time series for one panel (live query). |
285
+ | GET | `/instances/:name/alerts` | Alerts for one instance. |
286
+ | POST | `/instances/:name/refresh` | Force a refresh of one instance. |
287
+ | GET | `/dashboards` | Dashboards across all instances (or `?instance=` for one). |
288
+ | GET | `/alerts` | Alerts across all instances (or `?instance=` for one). |
289
+ | POST | `/refresh` | Force a refresh of all instances. |
199
290
 
200
291
  ### Query parameters
201
292
 
@@ -207,7 +298,12 @@ valid Backstage credential.
207
298
  - `labelSelector` — `key=value,key2=value2`; only alerts matching **all** pairs.
208
299
  - `instance` — (on `/dashboards` and `/alerts`) restrict to a single instance.
209
300
  - `refresh` — `true` or `1` (or the bare flag) to bypass the store and read
210
- live from Grafana.
301
+ live from Grafana. On the panel routes it bypasses the panel cache
302
+ (`panelDataCacheTtl`) instead. Ignored when
303
+ `allowOnDemandRefresh: false`.
304
+ - `from` / `to` — (on the panel data route) the query range, as Grafana time
305
+ expressions: `now`, `now-<n><s|m|h|d|w>`, or epoch milliseconds. Default
306
+ `now-6h` … `now`.
211
307
 
212
308
  Example:
213
309
 
@@ -228,6 +324,20 @@ Each request resolves to a per-instance snapshot (all dashboards + all alerts):
228
324
  Because filtering happens after retrieval, a single cached snapshot serves many
229
325
  entities with different selectors, keeping Grafana API traffic low.
230
326
 
327
+ A request that spans **all** instances (no instance in the path or query)
328
+ skips — and logs — any instance that fails to load, so one unreachable
329
+ Grafana cannot fail reads the other instances can still serve. Naming an
330
+ instance explicitly surfaces its error instead.
331
+
332
+ Panel listings and panel data are different: they are inherently live (a graph
333
+ of a stale range is wrong, not cached), so they bypass the snapshot store
334
+ entirely. Instead they are kept in the cache service for a short
335
+ `panelDataCacheTtl` (default 30 seconds) keyed by instance, dashboard, panel,
336
+ and range — enough to absorb a dashboard opening (which queries every panel at
337
+ once) and several viewers of the same entity, without making graphs stale. The
338
+ Grafana-side dashboard-model read is additionally deduplicated in the client,
339
+ so one burst reads the model once.
340
+
231
341
  ## Local development
232
342
 
233
343
  ```sh
@@ -51,6 +51,40 @@
51
51
  "type": "boolean",
52
52
  "description": "Whether a store miss triggers a live read from Grafana.\n\n - `true` (default): when no snapshot is stored for an instance, the backend fetches from Grafana on the spot and stores the result. - `false`: store misses return empty results without contacting Grafana. Data appears once a refresh runs — the schedule, the `POST …/refresh` routes, or a `refresh=true` read (the latter two only if `allowOnDemandRefresh` permits them). With both flags `false`, Grafana is contacted exclusively by the schedule."
53
53
  },
54
+ "allowPanelQueries": {
55
+ "type": "boolean",
56
+ "description": "Whether the panel routes are served.\n\n - `true` (default): `GET …/dashboards/:uid/panels` and `GET …/panels/:panelId/data` read live from Grafana (the dashboard model and the datasource query API), briefly cached per `panelDataCacheTtl`. - `false`: both routes respond `403`. Set this together with `allowOnDemandRefresh: false` and `fetchOnDemand: false` when Grafana traffic must be strictly schedule-only — panel data cannot be served from the snapshot store."
57
+ },
58
+ "panelDataCacheTtl": {
59
+ "type": "object",
60
+ "properties": {
61
+ "years": {
62
+ "type": "number"
63
+ },
64
+ "months": {
65
+ "type": "number"
66
+ },
67
+ "weeks": {
68
+ "type": "number"
69
+ },
70
+ "days": {
71
+ "type": "number"
72
+ },
73
+ "hours": {
74
+ "type": "number"
75
+ },
76
+ "minutes": {
77
+ "type": "number"
78
+ },
79
+ "seconds": {
80
+ "type": "number"
81
+ },
82
+ "milliseconds": {
83
+ "type": "number"
84
+ }
85
+ },
86
+ "description": "Time-to-live for cached panel listings and panel data. Short by design: it exists to absorb bursts (opening a dashboard queries every panel at once), not to make graphs stale. Defaults to 30 seconds."
87
+ },
54
88
  "schedule": {
55
89
  "type": "object",
56
90
  "properties": {
@@ -5,6 +5,7 @@ var errors = require('@backstage/errors');
5
5
  var backstagePluginGrafanaNode = require('@marble-sh/backstage-plugin-grafana-node');
6
6
 
7
7
  const DEFAULT_CACHE_TTL = { minutes: 15 };
8
+ const DEFAULT_PANEL_DATA_CACHE_TTL = { seconds: 30 };
8
9
  function readGrafanaConfig(rootConfig) {
9
10
  const instances = backstagePluginGrafanaNode.readGrafanaInstances(rootConfig);
10
11
  const config = rootConfig.getOptionalConfig("grafana");
@@ -21,7 +22,9 @@ function readGrafanaConfig(rootConfig) {
21
22
  cacheTtl: config?.getOptional("cacheTtl") ?? DEFAULT_CACHE_TTL,
22
23
  schedule: scheduleConfig ? backendPluginApi.readSchedulerServiceTaskScheduleDefinitionFromConfig(scheduleConfig) : void 0,
23
24
  allowOnDemandRefresh: config?.getOptionalBoolean("allowOnDemandRefresh") ?? true,
24
- fetchOnDemand: config?.getOptionalBoolean("fetchOnDemand") ?? true
25
+ fetchOnDemand: config?.getOptionalBoolean("fetchOnDemand") ?? true,
26
+ allowPanelQueries: config?.getOptionalBoolean("allowPanelQueries") ?? true,
27
+ panelDataCacheTtl: config?.getOptional("panelDataCacheTtl") ?? DEFAULT_PANEL_DATA_CACHE_TTL
25
28
  };
26
29
  }
27
30
 
@@ -1 +1 @@
1
- {"version":3,"file":"config.cjs.js","sources":["../../src/grafana/config.ts"],"sourcesContent":["/*\n * Copyright 2026 Cassidy Marble\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { Config } from '@backstage/config';\nimport {\n readSchedulerServiceTaskScheduleDefinitionFromConfig,\n SchedulerServiceTaskScheduleDefinition,\n} from '@backstage/backend-plugin-api';\nimport { InputError } from '@backstage/errors';\nimport { HumanDuration } from '@backstage/types';\nimport {\n GrafanaInstanceConfig,\n readGrafanaInstances,\n} from '@marble-sh/backstage-plugin-grafana-node';\n\nexport type {\n GrafanaInstanceConfig,\n GrafanaInstanceApis,\n} from '@marble-sh/backstage-plugin-grafana-node';\n\n/**\n * Where fetched data is stored between refreshes.\n *\n * @public\n */\nexport type GrafanaStoreKind = 'cache' | 'database';\n\n/**\n * The fully-resolved backend configuration.\n *\n * @public\n */\nexport type GrafanaBackendConfig = {\n instances: GrafanaInstanceConfig[];\n store: GrafanaStoreKind;\n cacheTtl: HumanDuration;\n schedule?: SchedulerServiceTaskScheduleDefinition;\n /** Whether callers may force live reads (`?refresh=true`, `POST /refresh`). */\n allowOnDemandRefresh: boolean;\n /** Whether a store miss triggers a live Grafana read. */\n fetchOnDemand: boolean;\n};\n\nconst DEFAULT_CACHE_TTL: HumanDuration = { minutes: 15 };\n\n/**\n * Reads and validates the `grafana` section of the app configuration into a\n * fully-resolved {@link GrafanaBackendConfig}.\n *\n * @public\n */\nexport function readGrafanaConfig(rootConfig: Config): GrafanaBackendConfig {\n const instances = readGrafanaInstances(rootConfig);\n const config = rootConfig.getOptionalConfig('grafana');\n\n const store =\n (config?.getOptionalString('store') as GrafanaStoreKind | undefined) ??\n 'cache';\n if (store !== 'cache' && store !== 'database') {\n throw new InputError(\n `Invalid grafana.store '${store}', expected 'cache' or 'database'`,\n );\n }\n\n const scheduleConfig = config?.getOptionalConfig('schedule');\n\n return {\n instances,\n store,\n cacheTtl:\n (config?.getOptional('cacheTtl') as HumanDuration | undefined) ??\n DEFAULT_CACHE_TTL,\n schedule: scheduleConfig\n ? readSchedulerServiceTaskScheduleDefinitionFromConfig(scheduleConfig)\n : undefined,\n allowOnDemandRefresh:\n config?.getOptionalBoolean('allowOnDemandRefresh') ?? true,\n fetchOnDemand: config?.getOptionalBoolean('fetchOnDemand') ?? true,\n };\n}\n"],"names":["readGrafanaInstances","InputError","readSchedulerServiceTaskScheduleDefinitionFromConfig"],"mappings":";;;;;;AAwDA,MAAM,iBAAA,GAAmC,EAAE,OAAA,EAAS,EAAA,EAAG;AAQhD,SAAS,kBAAkB,UAAA,EAA0C;AAC1E,EAAA,MAAM,SAAA,GAAYA,gDAAqB,UAAU,CAAA;AACjD,EAAA,MAAM,MAAA,GAAS,UAAA,CAAW,iBAAA,CAAkB,SAAS,CAAA;AAErD,EAAA,MAAM,KAAA,GACH,MAAA,EAAQ,iBAAA,CAAkB,OAAO,CAAA,IAClC,OAAA;AACF,EAAA,IAAI,KAAA,KAAU,OAAA,IAAW,KAAA,KAAU,UAAA,EAAY;AAC7C,IAAA,MAAM,IAAIC,iBAAA;AAAA,MACR,0BAA0B,KAAK,CAAA,iCAAA;AAAA,KACjC;AAAA,EACF;AAEA,EAAA,MAAM,cAAA,GAAiB,MAAA,EAAQ,iBAAA,CAAkB,UAAU,CAAA;AAE3D,EAAA,OAAO;AAAA,IACL,SAAA;AAAA,IACA,KAAA;AAAA,IACA,QAAA,EACG,MAAA,EAAQ,WAAA,CAAY,UAAU,CAAA,IAC/B,iBAAA;AAAA,IACF,QAAA,EAAU,cAAA,GACNC,qEAAA,CAAqD,cAAc,CAAA,GACnE,MAAA;AAAA,IACJ,oBAAA,EACE,MAAA,EAAQ,kBAAA,CAAmB,sBAAsB,CAAA,IAAK,IAAA;AAAA,IACxD,aAAA,EAAe,MAAA,EAAQ,kBAAA,CAAmB,eAAe,CAAA,IAAK;AAAA,GAChE;AACF;;"}
1
+ {"version":3,"file":"config.cjs.js","sources":["../../src/grafana/config.ts"],"sourcesContent":["/*\n * Copyright 2026 Cassidy Marble\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { Config } from '@backstage/config';\nimport {\n readSchedulerServiceTaskScheduleDefinitionFromConfig,\n SchedulerServiceTaskScheduleDefinition,\n} from '@backstage/backend-plugin-api';\nimport { InputError } from '@backstage/errors';\nimport { HumanDuration } from '@backstage/types';\nimport {\n GrafanaInstanceConfig,\n readGrafanaInstances,\n} from '@marble-sh/backstage-plugin-grafana-node';\n\nexport type {\n GrafanaInstanceConfig,\n GrafanaInstanceApis,\n} from '@marble-sh/backstage-plugin-grafana-node';\n\n/**\n * Where fetched data is stored between refreshes.\n *\n * @public\n */\nexport type GrafanaStoreKind = 'cache' | 'database';\n\n/**\n * The fully-resolved backend configuration.\n *\n * @public\n */\nexport type GrafanaBackendConfig = {\n instances: GrafanaInstanceConfig[];\n store: GrafanaStoreKind;\n cacheTtl: HumanDuration;\n schedule?: SchedulerServiceTaskScheduleDefinition;\n /** Whether callers may force live reads (`?refresh=true`, `POST /refresh`). */\n allowOnDemandRefresh: boolean;\n /** Whether a store miss triggers a live Grafana read. */\n fetchOnDemand: boolean;\n /** Whether the panel routes (live dashboard/datasource queries) are served. */\n allowPanelQueries: boolean;\n /** Time-to-live for cached panel listings and panel data. */\n panelDataCacheTtl: HumanDuration;\n};\n\nconst DEFAULT_CACHE_TTL: HumanDuration = { minutes: 15 };\nconst DEFAULT_PANEL_DATA_CACHE_TTL: HumanDuration = { seconds: 30 };\n\n/**\n * Reads and validates the `grafana` section of the app configuration into a\n * fully-resolved {@link GrafanaBackendConfig}.\n *\n * @public\n */\nexport function readGrafanaConfig(rootConfig: Config): GrafanaBackendConfig {\n const instances = readGrafanaInstances(rootConfig);\n const config = rootConfig.getOptionalConfig('grafana');\n\n const store =\n (config?.getOptionalString('store') as GrafanaStoreKind | undefined) ??\n 'cache';\n if (store !== 'cache' && store !== 'database') {\n throw new InputError(\n `Invalid grafana.store '${store}', expected 'cache' or 'database'`,\n );\n }\n\n const scheduleConfig = config?.getOptionalConfig('schedule');\n\n return {\n instances,\n store,\n cacheTtl:\n (config?.getOptional('cacheTtl') as HumanDuration | undefined) ??\n DEFAULT_CACHE_TTL,\n schedule: scheduleConfig\n ? readSchedulerServiceTaskScheduleDefinitionFromConfig(scheduleConfig)\n : undefined,\n allowOnDemandRefresh:\n config?.getOptionalBoolean('allowOnDemandRefresh') ?? true,\n fetchOnDemand: config?.getOptionalBoolean('fetchOnDemand') ?? true,\n allowPanelQueries: config?.getOptionalBoolean('allowPanelQueries') ?? true,\n panelDataCacheTtl:\n (config?.getOptional('panelDataCacheTtl') as HumanDuration | undefined) ??\n DEFAULT_PANEL_DATA_CACHE_TTL,\n };\n}\n"],"names":["readGrafanaInstances","InputError","readSchedulerServiceTaskScheduleDefinitionFromConfig"],"mappings":";;;;;;AA4DA,MAAM,iBAAA,GAAmC,EAAE,OAAA,EAAS,EAAA,EAAG;AACvD,MAAM,4BAAA,GAA8C,EAAE,OAAA,EAAS,EAAA,EAAG;AAQ3D,SAAS,kBAAkB,UAAA,EAA0C;AAC1E,EAAA,MAAM,SAAA,GAAYA,gDAAqB,UAAU,CAAA;AACjD,EAAA,MAAM,MAAA,GAAS,UAAA,CAAW,iBAAA,CAAkB,SAAS,CAAA;AAErD,EAAA,MAAM,KAAA,GACH,MAAA,EAAQ,iBAAA,CAAkB,OAAO,CAAA,IAClC,OAAA;AACF,EAAA,IAAI,KAAA,KAAU,OAAA,IAAW,KAAA,KAAU,UAAA,EAAY;AAC7C,IAAA,MAAM,IAAIC,iBAAA;AAAA,MACR,0BAA0B,KAAK,CAAA,iCAAA;AAAA,KACjC;AAAA,EACF;AAEA,EAAA,MAAM,cAAA,GAAiB,MAAA,EAAQ,iBAAA,CAAkB,UAAU,CAAA;AAE3D,EAAA,OAAO;AAAA,IACL,SAAA;AAAA,IACA,KAAA;AAAA,IACA,QAAA,EACG,MAAA,EAAQ,WAAA,CAAY,UAAU,CAAA,IAC/B,iBAAA;AAAA,IACF,QAAA,EAAU,cAAA,GACNC,qEAAA,CAAqD,cAAc,CAAA,GACnE,MAAA;AAAA,IACJ,oBAAA,EACE,MAAA,EAAQ,kBAAA,CAAmB,sBAAsB,CAAA,IAAK,IAAA;AAAA,IACxD,aAAA,EAAe,MAAA,EAAQ,kBAAA,CAAmB,eAAe,CAAA,IAAK,IAAA;AAAA,IAC9D,iBAAA,EAAmB,MAAA,EAAQ,kBAAA,CAAmB,mBAAmB,CAAA,IAAK,IAAA;AAAA,IACtE,iBAAA,EACG,MAAA,EAAQ,WAAA,CAAY,mBAAmB,CAAA,IACxC;AAAA,GACJ;AACF;;"}
package/dist/index.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import * as _backstage_backend_plugin_api from '@backstage/backend-plugin-api';
2
- import { LoggerService, SchedulerServiceTaskScheduleDefinition, CacheService, DatabaseService } from '@backstage/backend-plugin-api';
3
- import { GrafanaDashboard, GrafanaAlert, GrafanaInstanceInfo } from '@marble-sh/backstage-plugin-grafana-common';
2
+ import { LoggerService, CacheService, SchedulerServiceTaskScheduleDefinition, DatabaseService } from '@backstage/backend-plugin-api';
3
+ import { HumanDuration } from '@backstage/types';
4
+ import { GrafanaDashboard, GrafanaAlert, GrafanaInstanceInfo, GrafanaPanel, GrafanaPanelData } from '@marble-sh/backstage-plugin-grafana-common';
4
5
  import { GrafanaInstanceConfig, GrafanaClient } from '@marble-sh/backstage-plugin-grafana-node';
5
6
  export { FetchApi, GrafanaClient, GrafanaHttpClient, GrafanaInstanceApis, GrafanaInstanceConfig, ListAlertsOptions, ListDashboardsOptions } from '@marble-sh/backstage-plugin-grafana-node';
6
7
  import { Config } from '@backstage/config';
7
- import { HumanDuration } from '@backstage/types';
8
8
  import { Knex } from 'knex';
9
9
  import express from 'express';
10
10
 
@@ -83,10 +83,45 @@ type GetAlertsOptions = {
83
83
  /** Force a live fetch, bypassing the store. */
84
84
  refresh?: boolean;
85
85
  };
86
+ /**
87
+ * Options for listing the panels of a dashboard through the service.
88
+ *
89
+ * @public
90
+ */
91
+ type GetPanelsOptions = {
92
+ /** The instance to read from. */
93
+ instanceName: string;
94
+ /** The uid of the dashboard whose panels are listed. */
95
+ dashboardUid: string;
96
+ /** Force a live read, bypassing the panel cache. */
97
+ refresh?: boolean;
98
+ };
99
+ /**
100
+ * Options for querying the data of a single panel through the service.
101
+ *
102
+ * @public
103
+ */
104
+ type GetPanelDataOptions = {
105
+ /** The instance to read from. */
106
+ instanceName: string;
107
+ /** The uid of the dashboard containing the panel. */
108
+ dashboardUid: string;
109
+ /** The id of the panel to query. */
110
+ panelId: number;
111
+ /** Range start: `now`, `now-<n><s|m|h|d|w>`, or epoch ms. Defaults to `now-6h`. */
112
+ from?: string;
113
+ /** Range end, same forms as `from`. Defaults to `now`. */
114
+ to?: string;
115
+ /** Force a live read, bypassing the panel cache. */
116
+ refresh?: boolean;
117
+ };
86
118
  /**
87
119
  * Reads dashboards and alerts from the configured Grafana instances, backed by a
88
120
  * {@link GrafanaStore} for caching and periodic refresh.
89
121
  *
122
+ * The panel methods are optional so that custom implementations that predate
123
+ * them stay valid; the router responds 404 when they are absent.
124
+ *
90
125
  * @public
91
126
  */
92
127
  interface GrafanaService {
@@ -98,6 +133,10 @@ interface GrafanaService {
98
133
  getAlerts(options: GetAlertsOptions): Promise<GrafanaAlert[]>;
99
134
  /** Refreshes a single instance, or all instances when no name is given. */
100
135
  refresh(instanceName?: string): Promise<void>;
136
+ /** Returns the panels of a single dashboard, live from Grafana. */
137
+ getPanels?(options: GetPanelsOptions): Promise<GrafanaPanel[]>;
138
+ /** Returns the queried data of a single panel, live from Grafana. */
139
+ getPanelData?(options: GetPanelDataOptions): Promise<GrafanaPanelData>;
101
140
  }
102
141
  /**
103
142
  * The default {@link GrafanaService} implementation.
@@ -109,6 +148,8 @@ declare class DefaultGrafanaService implements GrafanaService {
109
148
  private readonly store;
110
149
  private readonly logger;
111
150
  private readonly fetchOnDemand;
151
+ private readonly cache?;
152
+ private readonly panelCacheTtlMs;
112
153
  constructor(options: {
113
154
  instances: GrafanaInstance[];
114
155
  store: GrafanaStore;
@@ -120,6 +161,14 @@ declare class DefaultGrafanaService implements GrafanaService {
120
161
  * schedule, the refresh endpoints, or a `refresh: true` read option).
121
162
  */
122
163
  fetchOnDemand?: boolean;
164
+ /**
165
+ * When given, panel listings and panel data are cached here for
166
+ * `panelDataCacheTtl` to absorb bursts (a dashboard opening queries every
167
+ * panel at once). Without it, every panel request reads live.
168
+ */
169
+ cache?: CacheService;
170
+ /** Time-to-live for cached panel data (default 30 seconds). */
171
+ panelDataCacheTtl?: HumanDuration;
123
172
  });
124
173
  /** {@inheritDoc GrafanaService.getInstances} */
125
174
  getInstances(): GrafanaInstanceInfo[];
@@ -129,7 +178,18 @@ declare class DefaultGrafanaService implements GrafanaService {
129
178
  getAlerts(options: GetAlertsOptions): Promise<GrafanaAlert[]>;
130
179
  /** {@inheritDoc GrafanaService.refresh} */
131
180
  refresh(instanceName?: string): Promise<void>;
132
- private resolveNames;
181
+ /** {@inheritDoc GrafanaService.getPanels} */
182
+ getPanels(options: GetPanelsOptions): Promise<GrafanaPanel[]>;
183
+ /** {@inheritDoc GrafanaService.getPanelData} */
184
+ getPanelData(options: GetPanelDataOptions): Promise<GrafanaPanelData>;
185
+ private withPanelCache;
186
+ /**
187
+ * Resolves the snapshots a read spans. A read of one named instance
188
+ * propagates that instance's failure; a fan-out over all instances skips
189
+ * (and logs) failing instances instead, so one unreachable Grafana cannot
190
+ * fail reads that other instances can still serve.
191
+ */
192
+ private snapshotsFor;
133
193
  private mustGet;
134
194
  private snapshotFor;
135
195
  private refreshInstance;
@@ -155,6 +215,10 @@ type GrafanaBackendConfig = {
155
215
  allowOnDemandRefresh: boolean;
156
216
  /** Whether a store miss triggers a live Grafana read. */
157
217
  fetchOnDemand: boolean;
218
+ /** Whether the panel routes (live dashboard/datasource queries) are served. */
219
+ allowPanelQueries: boolean;
220
+ /** Time-to-live for cached panel listings and panel data. */
221
+ panelDataCacheTtl: HumanDuration;
158
222
  };
159
223
  /**
160
224
  * Reads and validates the `grafana` section of the app configuration into a
@@ -226,7 +290,13 @@ declare function createRouter(options: {
226
290
  * routes respond 403.
227
291
  */
228
292
  allowOnDemandRefresh?: boolean;
293
+ /**
294
+ * Whether the panel routes are served (default `true`). Panel listings and
295
+ * panel data always read live from Grafana, so `false` disables them (403)
296
+ * for deployments that want schedule-only Grafana traffic.
297
+ */
298
+ allowPanelQueries?: boolean;
229
299
  }): Promise<express.Router>;
230
300
 
231
301
  export { CacheGrafanaStore, DatabaseGrafanaStore, DefaultGrafanaService, createRouter, grafanaPlugin as default, readGrafanaConfig };
232
- export type { GetAlertsOptions, GetDashboardsOptions, GrafanaBackendConfig, GrafanaInstance, GrafanaService, GrafanaSnapshot, GrafanaStore, GrafanaStoreKind };
302
+ export type { GetAlertsOptions, GetDashboardsOptions, GetPanelDataOptions, GetPanelsOptions, GrafanaBackendConfig, GrafanaInstance, GrafanaService, GrafanaSnapshot, GrafanaStore, GrafanaStoreKind };
@@ -27,6 +27,11 @@ const grafanaPlugin = backendPluginApi.createBackendPlugin({
27
27
  "No Grafana instances are configured under `grafana.instances`; the grafana plugin will return empty results"
28
28
  );
29
29
  }
30
+ if (grafanaConfig.store === "database" && !grafanaConfig.schedule) {
31
+ logger.warn(
32
+ "grafana.store is `database` but no `grafana.schedule` is configured; stored snapshots never expire, so data will only update on explicit refreshes (`?refresh=true` or `POST \u2026/refresh`)"
33
+ );
34
+ }
30
35
  const store = grafanaConfig.store === "database" ? await DatabaseGrafanaStore.DatabaseGrafanaStore.create({ database }) : new CacheGrafanaStore.CacheGrafanaStore({ cache, ttl: grafanaConfig.cacheTtl });
31
36
  const instances = grafanaConfig.instances.map(
32
37
  (instance) => ({
@@ -38,12 +43,15 @@ const grafanaPlugin = backendPluginApi.createBackendPlugin({
38
43
  instances,
39
44
  store,
40
45
  logger,
41
- fetchOnDemand: grafanaConfig.fetchOnDemand
46
+ fetchOnDemand: grafanaConfig.fetchOnDemand,
47
+ cache,
48
+ panelDataCacheTtl: grafanaConfig.panelDataCacheTtl
42
49
  });
43
50
  httpRouter.use(
44
51
  await router.createRouter({
45
52
  grafanaService,
46
- allowOnDemandRefresh: grafanaConfig.allowOnDemandRefresh
53
+ allowOnDemandRefresh: grafanaConfig.allowOnDemandRefresh,
54
+ allowPanelQueries: grafanaConfig.allowPanelQueries
47
55
  })
48
56
  );
49
57
  httpRouter.addAuthPolicy({ path: "/health", allow: "unauthenticated" });
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.cjs.js","sources":["../src/plugin.ts"],"sourcesContent":["/*\n * Copyright 2026 Cassidy Marble\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n coreServices,\n createBackendPlugin,\n} from '@backstage/backend-plugin-api';\nimport { readGrafanaConfig } from './grafana/config';\nimport { GrafanaHttpClient } from '@marble-sh/backstage-plugin-grafana-node';\nimport { CacheGrafanaStore } from './store/CacheGrafanaStore';\nimport { DatabaseGrafanaStore } from './store/DatabaseGrafanaStore';\nimport { GrafanaStore } from './store/GrafanaStore';\nimport {\n DefaultGrafanaService,\n GrafanaInstance,\n} from './service/GrafanaService';\nimport { createRouter } from './service/router';\n\n/**\n * The Grafana backend plugin.\n *\n * Reads dashboards and alerts from the configured Grafana instances, caches\n * them (in the cache or the database), optionally refreshes them on a schedule,\n * and exposes a read-only REST API under `/api/grafana`.\n *\n * @public\n */\nexport const grafanaPlugin = createBackendPlugin({\n pluginId: 'grafana',\n register(env) {\n env.registerInit({\n deps: {\n logger: coreServices.logger,\n config: coreServices.rootConfig,\n httpRouter: coreServices.httpRouter,\n cache: coreServices.cache,\n database: coreServices.database,\n scheduler: coreServices.scheduler,\n },\n async init({ logger, config, httpRouter, cache, database, scheduler }) {\n const grafanaConfig = readGrafanaConfig(config);\n\n if (grafanaConfig.instances.length === 0) {\n logger.warn(\n 'No Grafana instances are configured under `grafana.instances`; the grafana plugin will return empty results',\n );\n }\n\n const store: GrafanaStore =\n grafanaConfig.store === 'database'\n ? await DatabaseGrafanaStore.create({ database })\n : new CacheGrafanaStore({ cache, ttl: grafanaConfig.cacheTtl });\n\n const instances: GrafanaInstance[] = grafanaConfig.instances.map(\n instance => ({\n config: instance,\n client: new GrafanaHttpClient({ instance }),\n }),\n );\n\n const grafanaService = new DefaultGrafanaService({\n instances,\n store,\n logger,\n fetchOnDemand: grafanaConfig.fetchOnDemand,\n });\n\n httpRouter.use(\n await createRouter({\n grafanaService,\n allowOnDemandRefresh: grafanaConfig.allowOnDemandRefresh,\n }),\n );\n httpRouter.addAuthPolicy({ path: '/health', allow: 'unauthenticated' });\n\n if (grafanaConfig.schedule) {\n await scheduler.scheduleTask({\n id: 'grafana-refresh',\n ...grafanaConfig.schedule,\n fn: async () => {\n await grafanaService.refresh();\n },\n });\n logger.info('Scheduled periodic Grafana refresh');\n }\n },\n });\n },\n});\n"],"names":["createBackendPlugin","coreServices","config","readGrafanaConfig","DatabaseGrafanaStore","CacheGrafanaStore","GrafanaHttpClient","DefaultGrafanaService","createRouter"],"mappings":";;;;;;;;;;AAwCO,MAAM,gBAAgBA,oCAAA,CAAoB;AAAA,EAC/C,QAAA,EAAU,SAAA;AAAA,EACV,SAAS,GAAA,EAAK;AACZ,IAAA,GAAA,CAAI,YAAA,CAAa;AAAA,MACf,IAAA,EAAM;AAAA,QACJ,QAAQC,6BAAA,CAAa,MAAA;AAAA,QACrB,QAAQA,6BAAA,CAAa,UAAA;AAAA,QACrB,YAAYA,6BAAA,CAAa,UAAA;AAAA,QACzB,OAAOA,6BAAA,CAAa,KAAA;AAAA,QACpB,UAAUA,6BAAA,CAAa,QAAA;AAAA,QACvB,WAAWA,6BAAA,CAAa;AAAA,OAC1B;AAAA,MACA,MAAM,KAAK,EAAE,MAAA,UAAQC,UAAQ,UAAA,EAAY,KAAA,EAAO,QAAA,EAAU,SAAA,EAAU,EAAG;AACrE,QAAA,MAAM,aAAA,GAAgBC,yBAAkBD,QAAM,CAAA;AAE9C,QAAA,IAAI,aAAA,CAAc,SAAA,CAAU,MAAA,KAAW,CAAA,EAAG;AACxC,UAAA,MAAA,CAAO,IAAA;AAAA,YACL;AAAA,WACF;AAAA,QACF;AAEA,QAAA,MAAM,QACJ,aAAA,CAAc,KAAA,KAAU,aACpB,MAAME,yCAAA,CAAqB,OAAO,EAAE,QAAA,EAAU,CAAA,GAC9C,IAAIC,mCAAA,CAAkB,EAAE,OAAO,GAAA,EAAK,aAAA,CAAc,UAAU,CAAA;AAElE,QAAA,MAAM,SAAA,GAA+B,cAAc,SAAA,CAAU,GAAA;AAAA,UAC3D,CAAA,QAAA,MAAa;AAAA,YACX,MAAA,EAAQ,QAAA;AAAA,YACR,MAAA,EAAQ,IAAIC,4CAAA,CAAkB,EAAE,UAAU;AAAA,WAC5C;AAAA,SACF;AAEA,QAAA,MAAM,cAAA,GAAiB,IAAIC,oCAAA,CAAsB;AAAA,UAC/C,SAAA;AAAA,UACA,KAAA;AAAA,UACA,MAAA;AAAA,UACA,eAAe,aAAA,CAAc;AAAA,SAC9B,CAAA;AAED,QAAA,UAAA,CAAW,GAAA;AAAA,UACT,MAAMC,mBAAA,CAAa;AAAA,YACjB,cAAA;AAAA,YACA,sBAAsB,aAAA,CAAc;AAAA,WACrC;AAAA,SACH;AACA,QAAA,UAAA,CAAW,cAAc,EAAE,IAAA,EAAM,SAAA,EAAW,KAAA,EAAO,mBAAmB,CAAA;AAEtE,QAAA,IAAI,cAAc,QAAA,EAAU;AAC1B,UAAA,MAAM,UAAU,YAAA,CAAa;AAAA,YAC3B,EAAA,EAAI,iBAAA;AAAA,YACJ,GAAG,aAAA,CAAc,QAAA;AAAA,YACjB,IAAI,YAAY;AACd,cAAA,MAAM,eAAe,OAAA,EAAQ;AAAA,YAC/B;AAAA,WACD,CAAA;AACD,UAAA,MAAA,CAAO,KAAK,oCAAoC,CAAA;AAAA,QAClD;AAAA,MACF;AAAA,KACD,CAAA;AAAA,EACH;AACF,CAAC;;"}
1
+ {"version":3,"file":"plugin.cjs.js","sources":["../src/plugin.ts"],"sourcesContent":["/*\n * Copyright 2026 Cassidy Marble\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n coreServices,\n createBackendPlugin,\n} from '@backstage/backend-plugin-api';\nimport { readGrafanaConfig } from './grafana/config';\nimport { GrafanaHttpClient } from '@marble-sh/backstage-plugin-grafana-node';\nimport { CacheGrafanaStore } from './store/CacheGrafanaStore';\nimport { DatabaseGrafanaStore } from './store/DatabaseGrafanaStore';\nimport { GrafanaStore } from './store/GrafanaStore';\nimport {\n DefaultGrafanaService,\n GrafanaInstance,\n} from './service/GrafanaService';\nimport { createRouter } from './service/router';\n\n/**\n * The Grafana backend plugin.\n *\n * Reads dashboards and alerts from the configured Grafana instances, caches\n * them (in the cache or the database), optionally refreshes them on a schedule,\n * and exposes a read-only REST API under `/api/grafana`.\n *\n * @public\n */\nexport const grafanaPlugin = createBackendPlugin({\n pluginId: 'grafana',\n register(env) {\n env.registerInit({\n deps: {\n logger: coreServices.logger,\n config: coreServices.rootConfig,\n httpRouter: coreServices.httpRouter,\n cache: coreServices.cache,\n database: coreServices.database,\n scheduler: coreServices.scheduler,\n },\n async init({ logger, config, httpRouter, cache, database, scheduler }) {\n const grafanaConfig = readGrafanaConfig(config);\n\n if (grafanaConfig.instances.length === 0) {\n logger.warn(\n 'No Grafana instances are configured under `grafana.instances`; the grafana plugin will return empty results',\n );\n }\n\n if (grafanaConfig.store === 'database' && !grafanaConfig.schedule) {\n logger.warn(\n 'grafana.store is `database` but no `grafana.schedule` is configured; ' +\n 'stored snapshots never expire, so data will only update on ' +\n 'explicit refreshes (`?refresh=true` or `POST …/refresh`)',\n );\n }\n\n const store: GrafanaStore =\n grafanaConfig.store === 'database'\n ? await DatabaseGrafanaStore.create({ database })\n : new CacheGrafanaStore({ cache, ttl: grafanaConfig.cacheTtl });\n\n const instances: GrafanaInstance[] = grafanaConfig.instances.map(\n instance => ({\n config: instance,\n client: new GrafanaHttpClient({ instance }),\n }),\n );\n\n const grafanaService = new DefaultGrafanaService({\n instances,\n store,\n logger,\n fetchOnDemand: grafanaConfig.fetchOnDemand,\n cache,\n panelDataCacheTtl: grafanaConfig.panelDataCacheTtl,\n });\n\n httpRouter.use(\n await createRouter({\n grafanaService,\n allowOnDemandRefresh: grafanaConfig.allowOnDemandRefresh,\n allowPanelQueries: grafanaConfig.allowPanelQueries,\n }),\n );\n httpRouter.addAuthPolicy({ path: '/health', allow: 'unauthenticated' });\n\n if (grafanaConfig.schedule) {\n await scheduler.scheduleTask({\n id: 'grafana-refresh',\n ...grafanaConfig.schedule,\n fn: async () => {\n await grafanaService.refresh();\n },\n });\n logger.info('Scheduled periodic Grafana refresh');\n }\n },\n });\n },\n});\n"],"names":["createBackendPlugin","coreServices","config","readGrafanaConfig","DatabaseGrafanaStore","CacheGrafanaStore","GrafanaHttpClient","DefaultGrafanaService","createRouter"],"mappings":";;;;;;;;;;AAwCO,MAAM,gBAAgBA,oCAAA,CAAoB;AAAA,EAC/C,QAAA,EAAU,SAAA;AAAA,EACV,SAAS,GAAA,EAAK;AACZ,IAAA,GAAA,CAAI,YAAA,CAAa;AAAA,MACf,IAAA,EAAM;AAAA,QACJ,QAAQC,6BAAA,CAAa,MAAA;AAAA,QACrB,QAAQA,6BAAA,CAAa,UAAA;AAAA,QACrB,YAAYA,6BAAA,CAAa,UAAA;AAAA,QACzB,OAAOA,6BAAA,CAAa,KAAA;AAAA,QACpB,UAAUA,6BAAA,CAAa,QAAA;AAAA,QACvB,WAAWA,6BAAA,CAAa;AAAA,OAC1B;AAAA,MACA,MAAM,KAAK,EAAE,MAAA,UAAQC,UAAQ,UAAA,EAAY,KAAA,EAAO,QAAA,EAAU,SAAA,EAAU,EAAG;AACrE,QAAA,MAAM,aAAA,GAAgBC,yBAAkBD,QAAM,CAAA;AAE9C,QAAA,IAAI,aAAA,CAAc,SAAA,CAAU,MAAA,KAAW,CAAA,EAAG;AACxC,UAAA,MAAA,CAAO,IAAA;AAAA,YACL;AAAA,WACF;AAAA,QACF;AAEA,QAAA,IAAI,aAAA,CAAc,KAAA,KAAU,UAAA,IAAc,CAAC,cAAc,QAAA,EAAU;AACjE,UAAA,MAAA,CAAO,IAAA;AAAA,YACL;AAAA,WAGF;AAAA,QACF;AAEA,QAAA,MAAM,QACJ,aAAA,CAAc,KAAA,KAAU,aACpB,MAAME,yCAAA,CAAqB,OAAO,EAAE,QAAA,EAAU,CAAA,GAC9C,IAAIC,mCAAA,CAAkB,EAAE,OAAO,GAAA,EAAK,aAAA,CAAc,UAAU,CAAA;AAElE,QAAA,MAAM,SAAA,GAA+B,cAAc,SAAA,CAAU,GAAA;AAAA,UAC3D,CAAA,QAAA,MAAa;AAAA,YACX,MAAA,EAAQ,QAAA;AAAA,YACR,MAAA,EAAQ,IAAIC,4CAAA,CAAkB,EAAE,UAAU;AAAA,WAC5C;AAAA,SACF;AAEA,QAAA,MAAM,cAAA,GAAiB,IAAIC,oCAAA,CAAsB;AAAA,UAC/C,SAAA;AAAA,UACA,KAAA;AAAA,UACA,MAAA;AAAA,UACA,eAAe,aAAA,CAAc,aAAA;AAAA,UAC7B,KAAA;AAAA,UACA,mBAAmB,aAAA,CAAc;AAAA,SAClC,CAAA;AAED,QAAA,UAAA,CAAW,GAAA;AAAA,UACT,MAAMC,mBAAA,CAAa;AAAA,YACjB,cAAA;AAAA,YACA,sBAAsB,aAAA,CAAc,oBAAA;AAAA,YACpC,mBAAmB,aAAA,CAAc;AAAA,WAClC;AAAA,SACH;AACA,QAAA,UAAA,CAAW,cAAc,EAAE,IAAA,EAAM,SAAA,EAAW,KAAA,EAAO,mBAAmB,CAAA;AAEtE,QAAA,IAAI,cAAc,QAAA,EAAU;AAC1B,UAAA,MAAM,UAAU,YAAA,CAAa;AAAA,YAC3B,EAAA,EAAI,iBAAA;AAAA,YACJ,GAAG,aAAA,CAAc,QAAA;AAAA,YACjB,IAAI,YAAY;AACd,cAAA,MAAM,eAAe,OAAA,EAAQ;AAAA,YAC/B;AAAA,WACD,CAAA;AACD,UAAA,MAAA,CAAO,KAAK,oCAAoC,CAAA;AAAA,QAClD;AAAA,MACF;AAAA,KACD,CAAA;AAAA,EACH;AACF,CAAC;;"}
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  var errors = require('@backstage/errors');
4
+ var types = require('@backstage/types');
4
5
  var backstagePluginGrafanaNode = require('@marble-sh/backstage-plugin-grafana-node');
5
6
 
6
7
  class DefaultGrafanaService {
@@ -8,6 +9,8 @@ class DefaultGrafanaService {
8
9
  store;
9
10
  logger;
10
11
  fetchOnDemand;
12
+ cache;
13
+ panelCacheTtlMs;
11
14
  constructor(options) {
12
15
  this.instances = new Map(
13
16
  options.instances.map((instance) => [instance.config.name, instance])
@@ -15,6 +18,10 @@ class DefaultGrafanaService {
15
18
  this.store = options.store;
16
19
  this.logger = options.logger;
17
20
  this.fetchOnDemand = options.fetchOnDemand ?? true;
21
+ this.cache = options.cache;
22
+ this.panelCacheTtlMs = types.durationToMilliseconds(
23
+ options.panelDataCacheTtl ?? { seconds: 30 }
24
+ );
18
25
  }
19
26
  /** {@inheritDoc GrafanaService.getInstances} */
20
27
  getInstances() {
@@ -26,10 +33,11 @@ class DefaultGrafanaService {
26
33
  }
27
34
  /** {@inheritDoc GrafanaService.getDashboards} */
28
35
  async getDashboards(options) {
29
- const names = this.resolveNames(options.instanceName);
30
36
  const result = [];
31
- for (const name of names) {
32
- const snapshot = await this.snapshotFor(name, options.refresh);
37
+ for (const snapshot of await this.snapshotsFor(
38
+ options.instanceName,
39
+ options.refresh
40
+ )) {
33
41
  result.push(
34
42
  ...backstagePluginGrafanaNode.filterDashboards(snapshot.dashboards, {
35
43
  tags: options.tags,
@@ -42,10 +50,11 @@ class DefaultGrafanaService {
42
50
  }
43
51
  /** {@inheritDoc GrafanaService.getAlerts} */
44
52
  async getAlerts(options) {
45
- const names = this.resolveNames(options.instanceName);
46
53
  const result = [];
47
- for (const name of names) {
48
- const snapshot = await this.snapshotFor(name, options.refresh);
54
+ for (const snapshot of await this.snapshotsFor(
55
+ options.instanceName,
56
+ options.refresh
57
+ )) {
49
58
  result.push(
50
59
  ...backstagePluginGrafanaNode.filterAlerts(snapshot.alerts, {
51
60
  labelSelector: options.labelSelector
@@ -71,12 +80,76 @@ class DefaultGrafanaService {
71
80
  }
72
81
  }
73
82
  }
74
- resolveNames(instanceName) {
83
+ /** {@inheritDoc GrafanaService.getPanels} */
84
+ async getPanels(options) {
85
+ const { client } = this.mustGet(options.instanceName);
86
+ if (!client.getPanels) {
87
+ throw new errors.NotFoundError(
88
+ `The Grafana client for instance '${options.instanceName}' does not support panel queries`
89
+ );
90
+ }
91
+ return this.withPanelCache(
92
+ `panels:v1:${options.instanceName}:${options.dashboardUid}`,
93
+ () => client.getPanels(options.dashboardUid),
94
+ options.refresh
95
+ );
96
+ }
97
+ /** {@inheritDoc GrafanaService.getPanelData} */
98
+ async getPanelData(options) {
99
+ const { client } = this.mustGet(options.instanceName);
100
+ if (!client.getPanelData) {
101
+ throw new errors.NotFoundError(
102
+ `The Grafana client for instance '${options.instanceName}' does not support panel queries`
103
+ );
104
+ }
105
+ const from = options.from ?? "now-6h";
106
+ const to = options.to ?? "now";
107
+ return this.withPanelCache(
108
+ `panel-data:v1:${options.instanceName}:${options.dashboardUid}:${options.panelId}:${from}:${to}`,
109
+ () => client.getPanelData(options.dashboardUid, options.panelId, {
110
+ from,
111
+ to
112
+ }),
113
+ options.refresh
114
+ );
115
+ }
116
+ async withPanelCache(key, fn, refresh) {
117
+ if (!this.cache) {
118
+ return fn();
119
+ }
120
+ if (!refresh) {
121
+ const cached = await this.cache.get(key);
122
+ if (cached !== void 0) {
123
+ return cached;
124
+ }
125
+ }
126
+ const value = await fn();
127
+ await this.cache.set(key, value, { ttl: this.panelCacheTtlMs });
128
+ return value;
129
+ }
130
+ /**
131
+ * Resolves the snapshots a read spans. A read of one named instance
132
+ * propagates that instance's failure; a fan-out over all instances skips
133
+ * (and logs) failing instances instead, so one unreachable Grafana cannot
134
+ * fail reads that other instances can still serve.
135
+ */
136
+ async snapshotsFor(instanceName, refresh) {
75
137
  if (instanceName) {
76
138
  this.mustGet(instanceName);
77
- return [instanceName];
139
+ return [await this.snapshotFor(instanceName, refresh)];
140
+ }
141
+ const snapshots = [];
142
+ for (const name of this.instances.keys()) {
143
+ try {
144
+ snapshots.push(await this.snapshotFor(name, refresh));
145
+ } catch (error) {
146
+ this.logger.warn(
147
+ `Failed to read Grafana instance '${name}'; skipping it for this request`,
148
+ error
149
+ );
150
+ }
78
151
  }
79
- return [...this.instances.keys()];
152
+ return snapshots;
80
153
  }
81
154
  mustGet(instanceName) {
82
155
  const instance = this.instances.get(instanceName);
@@ -1 +1 @@
1
- {"version":3,"file":"GrafanaService.cjs.js","sources":["../../src/service/GrafanaService.ts"],"sourcesContent":["/*\n * Copyright 2026 Cassidy Marble\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { LoggerService } from '@backstage/backend-plugin-api';\nimport { NotFoundError } from '@backstage/errors';\nimport {\n GrafanaAlert,\n GrafanaDashboard,\n GrafanaInstanceInfo,\n} from '@marble-sh/backstage-plugin-grafana-common';\nimport {\n filterAlerts,\n filterDashboards,\n GrafanaClient,\n GrafanaInstanceConfig,\n} from '@marble-sh/backstage-plugin-grafana-node';\nimport { GrafanaSnapshot, GrafanaStore } from '../store/GrafanaStore';\n\n/**\n * A configured instance together with the client used to read from it.\n *\n * @public\n */\nexport type GrafanaInstance = {\n config: GrafanaInstanceConfig;\n client: GrafanaClient;\n};\n\n/**\n * Options for reading dashboards through the service.\n *\n * @public\n */\nexport type GetDashboardsOptions = {\n /** Restrict to a single instance. When omitted, all instances are queried. */\n instanceName?: string;\n /** Only return dashboards carrying all of these tags. */\n tags?: string[];\n /** Comma-separated title substrings; dashboards matching any are returned. */\n query?: string;\n /** Only return the dashboard with exactly this uid (case-sensitive). */\n uid?: string;\n /** Force a live fetch, bypassing the store. */\n refresh?: boolean;\n};\n\n/**\n * Options for reading alerts through the service.\n *\n * @public\n */\nexport type GetAlertsOptions = {\n /** Restrict to a single instance. When omitted, all instances are queried. */\n instanceName?: string;\n /** Only return alerts whose labels match all of these `key=value` pairs. */\n labelSelector?: Record<string, string>;\n /** Force a live fetch, bypassing the store. */\n refresh?: boolean;\n};\n\n/**\n * Reads dashboards and alerts from the configured Grafana instances, backed by a\n * {@link GrafanaStore} for caching and periodic refresh.\n *\n * @public\n */\nexport interface GrafanaService {\n /** Returns the configured Grafana instances. */\n getInstances(): GrafanaInstanceInfo[];\n /** Returns dashboards for one or all instances, honoring the store and filters. */\n getDashboards(options: GetDashboardsOptions): Promise<GrafanaDashboard[]>;\n /** Returns alerts for one or all instances, honoring the store and filters. */\n getAlerts(options: GetAlertsOptions): Promise<GrafanaAlert[]>;\n /** Refreshes a single instance, or all instances when no name is given. */\n refresh(instanceName?: string): Promise<void>;\n}\n\n/**\n * The default {@link GrafanaService} implementation.\n *\n * @public\n */\nexport class DefaultGrafanaService implements GrafanaService {\n private readonly instances: Map<string, GrafanaInstance>;\n private readonly store: GrafanaStore;\n private readonly logger: LoggerService;\n private readonly fetchOnDemand: boolean;\n\n constructor(options: {\n instances: GrafanaInstance[];\n store: GrafanaStore;\n logger: LoggerService;\n /**\n * Whether a store miss triggers a live Grafana read (default `true`).\n * When `false`, misses resolve to an empty snapshot and Grafana is only\n * contacted by explicit {@link DefaultGrafanaService.refresh} calls (the\n * schedule, the refresh endpoints, or a `refresh: true` read option).\n */\n fetchOnDemand?: boolean;\n }) {\n this.instances = new Map(\n options.instances.map(instance => [instance.config.name, instance]),\n );\n this.store = options.store;\n this.logger = options.logger;\n this.fetchOnDemand = options.fetchOnDemand ?? true;\n }\n\n /** {@inheritDoc GrafanaService.getInstances} */\n getInstances(): GrafanaInstanceInfo[] {\n return [...this.instances.values()].map(({ config }) => ({\n name: config.name,\n title: config.title,\n url: config.baseUrl,\n }));\n }\n\n /** {@inheritDoc GrafanaService.getDashboards} */\n async getDashboards(\n options: GetDashboardsOptions,\n ): Promise<GrafanaDashboard[]> {\n const names = this.resolveNames(options.instanceName);\n const result: GrafanaDashboard[] = [];\n for (const name of names) {\n const snapshot = await this.snapshotFor(name, options.refresh);\n result.push(\n ...filterDashboards(snapshot.dashboards, {\n tags: options.tags,\n query: options.query,\n uid: options.uid,\n }),\n );\n }\n return result;\n }\n\n /** {@inheritDoc GrafanaService.getAlerts} */\n async getAlerts(options: GetAlertsOptions): Promise<GrafanaAlert[]> {\n const names = this.resolveNames(options.instanceName);\n const result: GrafanaAlert[] = [];\n for (const name of names) {\n const snapshot = await this.snapshotFor(name, options.refresh);\n result.push(\n ...filterAlerts(snapshot.alerts, {\n labelSelector: options.labelSelector,\n }),\n );\n }\n return result;\n }\n\n /** {@inheritDoc GrafanaService.refresh} */\n async refresh(instanceName?: string): Promise<void> {\n if (instanceName) {\n await this.refreshInstance(instanceName);\n return;\n }\n for (const name of this.instances.keys()) {\n try {\n await this.refreshInstance(name);\n } catch (error) {\n this.logger.warn(\n `Failed to refresh Grafana instance '${name}'`,\n error as Error,\n );\n }\n }\n }\n\n private resolveNames(instanceName?: string): string[] {\n if (instanceName) {\n this.mustGet(instanceName);\n return [instanceName];\n }\n return [...this.instances.keys()];\n }\n\n private mustGet(instanceName: string): GrafanaInstance {\n const instance = this.instances.get(instanceName);\n if (!instance) {\n throw new NotFoundError(\n `No Grafana instance configured with name '${instanceName}'`,\n );\n }\n return instance;\n }\n\n private async snapshotFor(\n instanceName: string,\n refresh?: boolean,\n ): Promise<Pick<GrafanaSnapshot, 'dashboards' | 'alerts'>> {\n if (!refresh) {\n const cached = await this.store.get(instanceName);\n if (cached) {\n return cached;\n }\n if (!this.fetchOnDemand) {\n // Serve the miss as empty rather than reaching for Grafana; nothing\n // is stored, so results fill in as soon as a refresh runs.\n return { dashboards: [], alerts: [] };\n }\n }\n return this.refreshInstance(instanceName);\n }\n\n private async refreshInstance(\n instanceName: string,\n ): Promise<GrafanaSnapshot> {\n const { client } = this.mustGet(instanceName);\n const [dashboards, alerts] = await Promise.all([\n client.listDashboards(),\n client.listAlerts(),\n ]);\n const snapshot: GrafanaSnapshot = {\n dashboards,\n alerts,\n fetchedAt: new Date().toISOString(),\n };\n await this.store.set(instanceName, snapshot);\n this.logger.debug(\n `Refreshed Grafana instance '${instanceName}': ${dashboards.length} dashboards, ${alerts.length} alerts`,\n );\n return snapshot;\n }\n}\n"],"names":["filterDashboards","filterAlerts","NotFoundError"],"mappings":"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"}
1
+ {"version":3,"file":"GrafanaService.cjs.js","sources":["../../src/service/GrafanaService.ts"],"sourcesContent":["/*\n * Copyright 2026 Cassidy Marble\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { CacheService, LoggerService } from '@backstage/backend-plugin-api';\nimport { NotFoundError } from '@backstage/errors';\nimport { HumanDuration, durationToMilliseconds } from '@backstage/types';\nimport {\n GrafanaAlert,\n GrafanaDashboard,\n GrafanaInstanceInfo,\n GrafanaPanel,\n GrafanaPanelData,\n} from '@marble-sh/backstage-plugin-grafana-common';\nimport {\n filterAlerts,\n filterDashboards,\n GrafanaClient,\n GrafanaInstanceConfig,\n} from '@marble-sh/backstage-plugin-grafana-node';\nimport { GrafanaSnapshot, GrafanaStore } from '../store/GrafanaStore';\n\n/**\n * A configured instance together with the client used to read from it.\n *\n * @public\n */\nexport type GrafanaInstance = {\n config: GrafanaInstanceConfig;\n client: GrafanaClient;\n};\n\n/**\n * Options for reading dashboards through the service.\n *\n * @public\n */\nexport type GetDashboardsOptions = {\n /** Restrict to a single instance. When omitted, all instances are queried. */\n instanceName?: string;\n /** Only return dashboards carrying all of these tags. */\n tags?: string[];\n /** Comma-separated title substrings; dashboards matching any are returned. */\n query?: string;\n /** Only return the dashboard with exactly this uid (case-sensitive). */\n uid?: string;\n /** Force a live fetch, bypassing the store. */\n refresh?: boolean;\n};\n\n/**\n * Options for reading alerts through the service.\n *\n * @public\n */\nexport type GetAlertsOptions = {\n /** Restrict to a single instance. When omitted, all instances are queried. */\n instanceName?: string;\n /** Only return alerts whose labels match all of these `key=value` pairs. */\n labelSelector?: Record<string, string>;\n /** Force a live fetch, bypassing the store. */\n refresh?: boolean;\n};\n\n/**\n * Options for listing the panels of a dashboard through the service.\n *\n * @public\n */\nexport type GetPanelsOptions = {\n /** The instance to read from. */\n instanceName: string;\n /** The uid of the dashboard whose panels are listed. */\n dashboardUid: string;\n /** Force a live read, bypassing the panel cache. */\n refresh?: boolean;\n};\n\n/**\n * Options for querying the data of a single panel through the service.\n *\n * @public\n */\nexport type GetPanelDataOptions = {\n /** The instance to read from. */\n instanceName: string;\n /** The uid of the dashboard containing the panel. */\n dashboardUid: string;\n /** The id of the panel to query. */\n panelId: number;\n /** Range start: `now`, `now-<n><s|m|h|d|w>`, or epoch ms. Defaults to `now-6h`. */\n from?: string;\n /** Range end, same forms as `from`. Defaults to `now`. */\n to?: string;\n /** Force a live read, bypassing the panel cache. */\n refresh?: boolean;\n};\n\n/**\n * Reads dashboards and alerts from the configured Grafana instances, backed by a\n * {@link GrafanaStore} for caching and periodic refresh.\n *\n * The panel methods are optional so that custom implementations that predate\n * them stay valid; the router responds 404 when they are absent.\n *\n * @public\n */\nexport interface GrafanaService {\n /** Returns the configured Grafana instances. */\n getInstances(): GrafanaInstanceInfo[];\n /** Returns dashboards for one or all instances, honoring the store and filters. */\n getDashboards(options: GetDashboardsOptions): Promise<GrafanaDashboard[]>;\n /** Returns alerts for one or all instances, honoring the store and filters. */\n getAlerts(options: GetAlertsOptions): Promise<GrafanaAlert[]>;\n /** Refreshes a single instance, or all instances when no name is given. */\n refresh(instanceName?: string): Promise<void>;\n /** Returns the panels of a single dashboard, live from Grafana. */\n getPanels?(options: GetPanelsOptions): Promise<GrafanaPanel[]>;\n /** Returns the queried data of a single panel, live from Grafana. */\n getPanelData?(options: GetPanelDataOptions): Promise<GrafanaPanelData>;\n}\n\n/**\n * The default {@link GrafanaService} implementation.\n *\n * @public\n */\nexport class DefaultGrafanaService implements GrafanaService {\n private readonly instances: Map<string, GrafanaInstance>;\n private readonly store: GrafanaStore;\n private readonly logger: LoggerService;\n private readonly fetchOnDemand: boolean;\n private readonly cache?: CacheService;\n private readonly panelCacheTtlMs: number;\n\n constructor(options: {\n instances: GrafanaInstance[];\n store: GrafanaStore;\n logger: LoggerService;\n /**\n * Whether a store miss triggers a live Grafana read (default `true`).\n * When `false`, misses resolve to an empty snapshot and Grafana is only\n * contacted by explicit {@link DefaultGrafanaService.refresh} calls (the\n * schedule, the refresh endpoints, or a `refresh: true` read option).\n */\n fetchOnDemand?: boolean;\n /**\n * When given, panel listings and panel data are cached here for\n * `panelDataCacheTtl` to absorb bursts (a dashboard opening queries every\n * panel at once). Without it, every panel request reads live.\n */\n cache?: CacheService;\n /** Time-to-live for cached panel data (default 30 seconds). */\n panelDataCacheTtl?: HumanDuration;\n }) {\n this.instances = new Map(\n options.instances.map(instance => [instance.config.name, instance]),\n );\n this.store = options.store;\n this.logger = options.logger;\n this.fetchOnDemand = options.fetchOnDemand ?? true;\n this.cache = options.cache;\n this.panelCacheTtlMs = durationToMilliseconds(\n options.panelDataCacheTtl ?? { seconds: 30 },\n );\n }\n\n /** {@inheritDoc GrafanaService.getInstances} */\n getInstances(): GrafanaInstanceInfo[] {\n return [...this.instances.values()].map(({ config }) => ({\n name: config.name,\n title: config.title,\n url: config.baseUrl,\n }));\n }\n\n /** {@inheritDoc GrafanaService.getDashboards} */\n async getDashboards(\n options: GetDashboardsOptions,\n ): Promise<GrafanaDashboard[]> {\n const result: GrafanaDashboard[] = [];\n for (const snapshot of await this.snapshotsFor(\n options.instanceName,\n options.refresh,\n )) {\n result.push(\n ...filterDashboards(snapshot.dashboards, {\n tags: options.tags,\n query: options.query,\n uid: options.uid,\n }),\n );\n }\n return result;\n }\n\n /** {@inheritDoc GrafanaService.getAlerts} */\n async getAlerts(options: GetAlertsOptions): Promise<GrafanaAlert[]> {\n const result: GrafanaAlert[] = [];\n for (const snapshot of await this.snapshotsFor(\n options.instanceName,\n options.refresh,\n )) {\n result.push(\n ...filterAlerts(snapshot.alerts, {\n labelSelector: options.labelSelector,\n }),\n );\n }\n return result;\n }\n\n /** {@inheritDoc GrafanaService.refresh} */\n async refresh(instanceName?: string): Promise<void> {\n if (instanceName) {\n await this.refreshInstance(instanceName);\n return;\n }\n for (const name of this.instances.keys()) {\n try {\n await this.refreshInstance(name);\n } catch (error) {\n this.logger.warn(\n `Failed to refresh Grafana instance '${name}'`,\n error as Error,\n );\n }\n }\n }\n\n /** {@inheritDoc GrafanaService.getPanels} */\n async getPanels(options: GetPanelsOptions): Promise<GrafanaPanel[]> {\n const { client } = this.mustGet(options.instanceName);\n if (!client.getPanels) {\n throw new NotFoundError(\n `The Grafana client for instance '${options.instanceName}' does not support panel queries`,\n );\n }\n return this.withPanelCache(\n `panels:v1:${options.instanceName}:${options.dashboardUid}`,\n () => client.getPanels!(options.dashboardUid),\n options.refresh,\n );\n }\n\n /** {@inheritDoc GrafanaService.getPanelData} */\n async getPanelData(options: GetPanelDataOptions): Promise<GrafanaPanelData> {\n const { client } = this.mustGet(options.instanceName);\n if (!client.getPanelData) {\n throw new NotFoundError(\n `The Grafana client for instance '${options.instanceName}' does not support panel queries`,\n );\n }\n const from = options.from ?? 'now-6h';\n const to = options.to ?? 'now';\n return this.withPanelCache(\n `panel-data:v1:${options.instanceName}:${options.dashboardUid}:${options.panelId}:${from}:${to}`,\n () =>\n client.getPanelData!(options.dashboardUid, options.panelId, {\n from,\n to,\n }),\n options.refresh,\n );\n }\n\n private async withPanelCache<T>(\n key: string,\n fn: () => Promise<T>,\n refresh?: boolean,\n ): Promise<T> {\n if (!this.cache) {\n return fn();\n }\n if (!refresh) {\n const cached = await this.cache.get(key);\n if (cached !== undefined) {\n return cached as T;\n }\n }\n const value = await fn();\n await this.cache.set(key, value as never, { ttl: this.panelCacheTtlMs });\n return value;\n }\n\n /**\n * Resolves the snapshots a read spans. A read of one named instance\n * propagates that instance's failure; a fan-out over all instances skips\n * (and logs) failing instances instead, so one unreachable Grafana cannot\n * fail reads that other instances can still serve.\n */\n private async snapshotsFor(\n instanceName: string | undefined,\n refresh?: boolean,\n ): Promise<Array<Pick<GrafanaSnapshot, 'dashboards' | 'alerts'>>> {\n if (instanceName) {\n this.mustGet(instanceName);\n return [await this.snapshotFor(instanceName, refresh)];\n }\n const snapshots: Array<Pick<GrafanaSnapshot, 'dashboards' | 'alerts'>> = [];\n for (const name of this.instances.keys()) {\n try {\n snapshots.push(await this.snapshotFor(name, refresh));\n } catch (error) {\n this.logger.warn(\n `Failed to read Grafana instance '${name}'; skipping it for this request`,\n error as Error,\n );\n }\n }\n return snapshots;\n }\n\n private mustGet(instanceName: string): GrafanaInstance {\n const instance = this.instances.get(instanceName);\n if (!instance) {\n throw new NotFoundError(\n `No Grafana instance configured with name '${instanceName}'`,\n );\n }\n return instance;\n }\n\n private async snapshotFor(\n instanceName: string,\n refresh?: boolean,\n ): Promise<Pick<GrafanaSnapshot, 'dashboards' | 'alerts'>> {\n if (!refresh) {\n const cached = await this.store.get(instanceName);\n if (cached) {\n return cached;\n }\n if (!this.fetchOnDemand) {\n // Serve the miss as empty rather than reaching for Grafana; nothing\n // is stored, so results fill in as soon as a refresh runs.\n return { dashboards: [], alerts: [] };\n }\n }\n return this.refreshInstance(instanceName);\n }\n\n private async refreshInstance(\n instanceName: string,\n ): Promise<GrafanaSnapshot> {\n const { client } = this.mustGet(instanceName);\n const [dashboards, alerts] = await Promise.all([\n client.listDashboards(),\n client.listAlerts(),\n ]);\n const snapshot: GrafanaSnapshot = {\n dashboards,\n alerts,\n fetchedAt: new Date().toISOString(),\n };\n await this.store.set(instanceName, snapshot);\n this.logger.debug(\n `Refreshed Grafana instance '${instanceName}': ${dashboards.length} dashboards, ${alerts.length} alerts`,\n );\n return snapshot;\n }\n}\n"],"names":["durationToMilliseconds","filterDashboards","filterAlerts","NotFoundError"],"mappings":"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"}
@@ -21,6 +21,7 @@ const toBoolean = (value) => value !== void 0 && ["true", "1", ""].includes(Stri
21
21
  async function createRouter(options) {
22
22
  const { grafanaService } = options;
23
23
  const allowOnDemandRefresh = options.allowOnDemandRefresh ?? true;
24
+ const allowPanelQueries = options.allowPanelQueries ?? true;
24
25
  const router = Router__default.default();
25
26
  router.use(express__default.default.json());
26
27
  const toRefresh = (value) => allowOnDemandRefresh && toBoolean(value);
@@ -31,6 +32,13 @@ async function createRouter(options) {
31
32
  );
32
33
  }
33
34
  };
35
+ const assertPanelQueriesAllowed = () => {
36
+ if (!allowPanelQueries) {
37
+ throw new errors.NotAllowedError(
38
+ "Panel queries are disabled by configuration (grafana.allowPanelQueries)"
39
+ );
40
+ }
41
+ };
34
42
  router.get("/health", (_req, res) => {
35
43
  res.json({ status: "ok" });
36
44
  });
@@ -55,6 +63,45 @@ async function createRouter(options) {
55
63
  });
56
64
  res.json({ items });
57
65
  });
66
+ router.get("/instances/:name/dashboards/:uid/panels", async (req, res) => {
67
+ assertPanelQueriesAllowed();
68
+ if (!grafanaService.getPanels) {
69
+ throw new errors.NotFoundError(
70
+ "Panel queries are not supported by the configured Grafana service"
71
+ );
72
+ }
73
+ const items = await grafanaService.getPanels({
74
+ instanceName: req.params.name,
75
+ dashboardUid: req.params.uid,
76
+ refresh: toRefresh(req.query.refresh)
77
+ });
78
+ res.json({ items });
79
+ });
80
+ router.get(
81
+ "/instances/:name/dashboards/:uid/panels/:panelId/data",
82
+ async (req, res) => {
83
+ assertPanelQueriesAllowed();
84
+ if (!grafanaService.getPanelData) {
85
+ throw new errors.NotFoundError(
86
+ "Panel queries are not supported by the configured Grafana service"
87
+ );
88
+ }
89
+ if (!/^\d+$/.test(req.params.panelId)) {
90
+ throw new errors.InputError(
91
+ `Invalid panel id '${req.params.panelId}', expected an integer`
92
+ );
93
+ }
94
+ const data = await grafanaService.getPanelData({
95
+ instanceName: req.params.name,
96
+ dashboardUid: req.params.uid,
97
+ panelId: Number(req.params.panelId),
98
+ from: toString(req.query.from),
99
+ to: toString(req.query.to),
100
+ refresh: toRefresh(req.query.refresh)
101
+ });
102
+ res.json(data);
103
+ }
104
+ );
58
105
  router.post("/instances/:name/refresh", async (req, res) => {
59
106
  assertRefreshAllowed();
60
107
  await grafanaService.refresh(req.params.name);
@@ -1 +1 @@
1
- {"version":3,"file":"router.cjs.js","sources":["../../src/service/router.ts"],"sourcesContent":["/*\n * Copyright 2026 Cassidy Marble\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport express from 'express';\nimport Router from 'express-promise-router';\nimport { NotAllowedError } from '@backstage/errors';\nimport { parseLabelSelector } from '@marble-sh/backstage-plugin-grafana-node';\nimport { GrafanaService } from './GrafanaService';\n\nconst toArray = (value: unknown): string[] | undefined => {\n if (value === undefined) {\n return undefined;\n }\n return Array.isArray(value) ? value.map(String) : [String(value)];\n};\n\nconst toString = (value: unknown): string | undefined =>\n value === undefined ? undefined : String(value);\n\n// Accepts ?refresh=true, ?refresh=1, and the bare ?refresh flag.\nconst toBoolean = (value: unknown): boolean =>\n value !== undefined && ['true', '1', ''].includes(String(value));\n\n/**\n * Creates the Express router that exposes the read-only Grafana REST API.\n *\n * All routes are relative to the plugin base path (`/api/grafana`).\n *\n * @public\n */\nexport async function createRouter(options: {\n grafanaService: GrafanaService;\n /**\n * Whether callers may force live Grafana reads (default `true`). When\n * `false`, `refresh` query parameters are ignored and the `POST …/refresh`\n * routes respond 403.\n */\n allowOnDemandRefresh?: boolean;\n}): Promise<express.Router> {\n const { grafanaService } = options;\n const allowOnDemandRefresh = options.allowOnDemandRefresh ?? true;\n const router = Router();\n router.use(express.json());\n\n const toRefresh = (value: unknown): boolean =>\n allowOnDemandRefresh && toBoolean(value);\n\n const assertRefreshAllowed = () => {\n if (!allowOnDemandRefresh) {\n throw new NotAllowedError(\n 'On-demand refresh is disabled by configuration (grafana.allowOnDemandRefresh)',\n );\n }\n };\n\n router.get('/health', (_req, res) => {\n res.json({ status: 'ok' });\n });\n\n router.get('/instances', (_req, res) => {\n res.json({ items: grafanaService.getInstances() });\n });\n\n router.get('/instances/:name/dashboards', async (req, res) => {\n const items = await grafanaService.getDashboards({\n instanceName: req.params.name,\n tags: toArray(req.query.tag),\n query: toString(req.query.query),\n uid: toString(req.query.uid),\n refresh: toRefresh(req.query.refresh),\n });\n res.json({ items });\n });\n\n router.get('/instances/:name/alerts', async (req, res) => {\n const items = await grafanaService.getAlerts({\n instanceName: req.params.name,\n labelSelector: parseLabelSelector(toString(req.query.labelSelector)),\n refresh: toRefresh(req.query.refresh),\n });\n res.json({ items });\n });\n\n router.post('/instances/:name/refresh', async (req, res) => {\n assertRefreshAllowed();\n await grafanaService.refresh(req.params.name);\n res.json({ status: 'ok' });\n });\n\n router.get('/dashboards', async (req, res) => {\n const items = await grafanaService.getDashboards({\n instanceName: toString(req.query.instance),\n tags: toArray(req.query.tag),\n query: toString(req.query.query),\n uid: toString(req.query.uid),\n refresh: toRefresh(req.query.refresh),\n });\n res.json({ items });\n });\n\n router.get('/alerts', async (req, res) => {\n const items = await grafanaService.getAlerts({\n instanceName: toString(req.query.instance),\n labelSelector: parseLabelSelector(toString(req.query.labelSelector)),\n refresh: toRefresh(req.query.refresh),\n });\n res.json({ items });\n });\n\n router.post('/refresh', async (_req, res) => {\n assertRefreshAllowed();\n await grafanaService.refresh();\n res.json({ status: 'ok' });\n });\n\n return router;\n}\n"],"names":["Router","express","NotAllowedError","parseLabelSelector"],"mappings":"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"}
1
+ {"version":3,"file":"router.cjs.js","sources":["../../src/service/router.ts"],"sourcesContent":["/*\n * Copyright 2026 Cassidy Marble\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport express from 'express';\nimport Router from 'express-promise-router';\nimport { InputError, NotAllowedError, NotFoundError } from '@backstage/errors';\nimport { parseLabelSelector } from '@marble-sh/backstage-plugin-grafana-node';\nimport { GrafanaService } from './GrafanaService';\n\nconst toArray = (value: unknown): string[] | undefined => {\n if (value === undefined) {\n return undefined;\n }\n return Array.isArray(value) ? value.map(String) : [String(value)];\n};\n\nconst toString = (value: unknown): string | undefined =>\n value === undefined ? undefined : String(value);\n\n// Accepts ?refresh=true, ?refresh=1, and the bare ?refresh flag.\nconst toBoolean = (value: unknown): boolean =>\n value !== undefined && ['true', '1', ''].includes(String(value));\n\n/**\n * Creates the Express router that exposes the read-only Grafana REST API.\n *\n * All routes are relative to the plugin base path (`/api/grafana`).\n *\n * @public\n */\nexport async function createRouter(options: {\n grafanaService: GrafanaService;\n /**\n * Whether callers may force live Grafana reads (default `true`). When\n * `false`, `refresh` query parameters are ignored and the `POST …/refresh`\n * routes respond 403.\n */\n allowOnDemandRefresh?: boolean;\n /**\n * Whether the panel routes are served (default `true`). Panel listings and\n * panel data always read live from Grafana, so `false` disables them (403)\n * for deployments that want schedule-only Grafana traffic.\n */\n allowPanelQueries?: boolean;\n}): Promise<express.Router> {\n const { grafanaService } = options;\n const allowOnDemandRefresh = options.allowOnDemandRefresh ?? true;\n const allowPanelQueries = options.allowPanelQueries ?? true;\n const router = Router();\n router.use(express.json());\n\n const toRefresh = (value: unknown): boolean =>\n allowOnDemandRefresh && toBoolean(value);\n\n const assertRefreshAllowed = () => {\n if (!allowOnDemandRefresh) {\n throw new NotAllowedError(\n 'On-demand refresh is disabled by configuration (grafana.allowOnDemandRefresh)',\n );\n }\n };\n\n const assertPanelQueriesAllowed = () => {\n if (!allowPanelQueries) {\n throw new NotAllowedError(\n 'Panel queries are disabled by configuration (grafana.allowPanelQueries)',\n );\n }\n };\n\n router.get('/health', (_req, res) => {\n res.json({ status: 'ok' });\n });\n\n router.get('/instances', (_req, res) => {\n res.json({ items: grafanaService.getInstances() });\n });\n\n router.get('/instances/:name/dashboards', async (req, res) => {\n const items = await grafanaService.getDashboards({\n instanceName: req.params.name,\n tags: toArray(req.query.tag),\n query: toString(req.query.query),\n uid: toString(req.query.uid),\n refresh: toRefresh(req.query.refresh),\n });\n res.json({ items });\n });\n\n router.get('/instances/:name/alerts', async (req, res) => {\n const items = await grafanaService.getAlerts({\n instanceName: req.params.name,\n labelSelector: parseLabelSelector(toString(req.query.labelSelector)),\n refresh: toRefresh(req.query.refresh),\n });\n res.json({ items });\n });\n\n router.get('/instances/:name/dashboards/:uid/panels', async (req, res) => {\n assertPanelQueriesAllowed();\n if (!grafanaService.getPanels) {\n throw new NotFoundError(\n 'Panel queries are not supported by the configured Grafana service',\n );\n }\n const items = await grafanaService.getPanels({\n instanceName: req.params.name,\n dashboardUid: req.params.uid,\n refresh: toRefresh(req.query.refresh),\n });\n res.json({ items });\n });\n\n router.get(\n '/instances/:name/dashboards/:uid/panels/:panelId/data',\n async (req, res) => {\n assertPanelQueriesAllowed();\n if (!grafanaService.getPanelData) {\n throw new NotFoundError(\n 'Panel queries are not supported by the configured Grafana service',\n );\n }\n if (!/^\\d+$/.test(req.params.panelId)) {\n throw new InputError(\n `Invalid panel id '${req.params.panelId}', expected an integer`,\n );\n }\n const data = await grafanaService.getPanelData({\n instanceName: req.params.name,\n dashboardUid: req.params.uid,\n panelId: Number(req.params.panelId),\n from: toString(req.query.from),\n to: toString(req.query.to),\n refresh: toRefresh(req.query.refresh),\n });\n res.json(data);\n },\n );\n\n router.post('/instances/:name/refresh', async (req, res) => {\n assertRefreshAllowed();\n await grafanaService.refresh(req.params.name);\n res.json({ status: 'ok' });\n });\n\n router.get('/dashboards', async (req, res) => {\n const items = await grafanaService.getDashboards({\n instanceName: toString(req.query.instance),\n tags: toArray(req.query.tag),\n query: toString(req.query.query),\n uid: toString(req.query.uid),\n refresh: toRefresh(req.query.refresh),\n });\n res.json({ items });\n });\n\n router.get('/alerts', async (req, res) => {\n const items = await grafanaService.getAlerts({\n instanceName: toString(req.query.instance),\n labelSelector: parseLabelSelector(toString(req.query.labelSelector)),\n refresh: toRefresh(req.query.refresh),\n });\n res.json({ items });\n });\n\n router.post('/refresh', async (_req, res) => {\n assertRefreshAllowed();\n await grafanaService.refresh();\n res.json({ status: 'ok' });\n });\n\n return router;\n}\n"],"names":["Router","express","NotAllowedError","parseLabelSelector","NotFoundError","InputError"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marble-sh/backstage-plugin-grafana-backend",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Backend for the Grafana Backstage plugin: reads dashboards and alerts from Grafana, caches them, and exposes a read-only REST API",
5
5
  "main": "./dist/index.cjs.js",
6
6
  "types": "./dist/index.d.ts",
@@ -37,8 +37,8 @@
37
37
  "@backstage/config": "^1.3.8",
38
38
  "@backstage/errors": "^1.3.1",
39
39
  "@backstage/types": "^1.2.2",
40
- "@marble-sh/backstage-plugin-grafana-common": "^1.1.0",
41
- "@marble-sh/backstage-plugin-grafana-node": "^1.1.0",
40
+ "@marble-sh/backstage-plugin-grafana-common": "^1.2.1",
41
+ "@marble-sh/backstage-plugin-grafana-node": "^1.2.1",
42
42
  "express": "^4.17.1",
43
43
  "express-promise-router": "^4.1.0",
44
44
  "knex": "^3.0.0"