@marble-sh/backstage-plugin-grafana-backend 0.2.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/README.md +229 -0
- package/config.schema.json +174 -0
- package/dist/grafana/config.cjs.js +29 -0
- package/dist/grafana/config.cjs.js.map +1 -0
- package/dist/index.cjs.js +25 -0
- package/dist/index.cjs.js.map +1 -0
- package/dist/index.d.ts +230 -0
- package/dist/plugin.cjs.js +66 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/service/GrafanaService.cjs.js +121 -0
- package/dist/service/GrafanaService.cjs.js.map +1 -0
- package/dist/service/router.cjs.js +88 -0
- package/dist/service/router.cjs.js.map +1 -0
- package/dist/store/CacheGrafanaStore.cjs.js +29 -0
- package/dist/store/CacheGrafanaStore.cjs.js.map +1 -0
- package/dist/store/DatabaseGrafanaStore.cjs.js +62 -0
- package/dist/store/DatabaseGrafanaStore.cjs.js.map +1 -0
- package/migrations/20260826000000_init.js +45 -0
- package/package.json +87 -0
package/README.md
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# @marble-sh/backstage-plugin-grafana-backend
|
|
2
|
+
|
|
3
|
+
A read-only [Grafana](https://grafana.com/) backend plugin for Backstage,
|
|
4
|
+
targeting the **new backend system**.
|
|
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.
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- Reads dashboards from the modern **App Platform** API
|
|
17
|
+
(`dashboard.grafana.app/v1`, Grafana 12+), or — opt-in per instance via
|
|
18
|
+
`apis.dashboards: legacy-search` — from the classic `/api/search` endpoint
|
|
19
|
+
for older Grafana versions. Folder titles and links are resolved for both.
|
|
20
|
+
- Reads alert rules and their live state from the stable Grafana-managed
|
|
21
|
+
Prometheus rules API (`/api/prometheus/grafana/api/v1/rules`).
|
|
22
|
+
- Works with both **Grafana Cloud** and **self-hosted** Grafana, using a
|
|
23
|
+
service-account token.
|
|
24
|
+
- Supports **multiple instances**, each addressable by name.
|
|
25
|
+
- Caches data in the Backstage **cache** service (ephemeral, TTL-based) or the
|
|
26
|
+
**database** service (durable), selectable by configuration.
|
|
27
|
+
- Optional **scheduled refresh** to keep the cache warm.
|
|
28
|
+
- Serves data live on demand (cache miss) or forcibly via `?refresh=true`.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
Add the dependency to your backend package:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
yarn --cwd packages/backend add @marble-sh/backstage-plugin-grafana-backend
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Then register the plugin in your backend:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
// packages/backend/src/index.ts
|
|
42
|
+
const backend = createBackend();
|
|
43
|
+
// ...
|
|
44
|
+
backend.add(import('@marble-sh/backstage-plugin-grafana-backend'));
|
|
45
|
+
backend.start();
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Configuration
|
|
49
|
+
|
|
50
|
+
All configuration lives under the `grafana` key. The store/schedule schema is
|
|
51
|
+
documented in this package's [`config.d.ts`](./config.d.ts); the shared
|
|
52
|
+
`grafana.instances` schema lives in
|
|
53
|
+
[`grafana-node`](../grafana-node/config.d.ts), where every consumer of the
|
|
54
|
+
instance list picks it up.
|
|
55
|
+
|
|
56
|
+
```yaml
|
|
57
|
+
grafana:
|
|
58
|
+
# Where fetched data is stored between refreshes:
|
|
59
|
+
# cache (default) – ephemeral, honors cacheTtl
|
|
60
|
+
# database – durable, survives restarts and is shared across replicas
|
|
61
|
+
store: cache
|
|
62
|
+
cacheTtl: { minutes: 15 }
|
|
63
|
+
|
|
64
|
+
# Behavior flags (both default to true; see "Behavior flags" below).
|
|
65
|
+
allowOnDemandRefresh: true
|
|
66
|
+
fetchOnDemand: true
|
|
67
|
+
|
|
68
|
+
# Optional background refresh. Omit to fetch lazily on request instead.
|
|
69
|
+
schedule:
|
|
70
|
+
frequency: { minutes: 15 }
|
|
71
|
+
timeout: { minutes: 2 }
|
|
72
|
+
initialDelay: { seconds: 30 }
|
|
73
|
+
|
|
74
|
+
instances:
|
|
75
|
+
# A self-hosted Grafana (organization 1 → namespace "default").
|
|
76
|
+
- name: production
|
|
77
|
+
title: Production Grafana
|
|
78
|
+
baseUrl: https://grafana.internal.example.com
|
|
79
|
+
token: ${GRAFANA_PROD_TOKEN} # service-account token, Viewer is enough
|
|
80
|
+
|
|
81
|
+
# A Grafana Cloud stack (namespace derived as "stacks-<stackId>").
|
|
82
|
+
- name: cloud
|
|
83
|
+
title: Grafana Cloud
|
|
84
|
+
baseUrl: https://myorg.grafana.net
|
|
85
|
+
stackId: '123456'
|
|
86
|
+
token: ${GRAFANA_CLOUD_TOKEN}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Instance options
|
|
90
|
+
|
|
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 | Grafana Cloud stack id, used to derive the namespace. |
|
|
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`. |
|
|
101
|
+
|
|
102
|
+
### API selection
|
|
103
|
+
|
|
104
|
+
```yaml
|
|
105
|
+
apis:
|
|
106
|
+
dashboards: app-platform # or "legacy-search" for older Grafana, or "none"
|
|
107
|
+
alerts: prometheus # or "none"
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Every state, per data type:
|
|
111
|
+
|
|
112
|
+
- `dashboards: app-platform` (default) uses `dashboard.grafana.app/v1`
|
|
113
|
+
(Grafana 12+).
|
|
114
|
+
- `dashboards: legacy-search` uses the classic `/api/search` endpoint.
|
|
115
|
+
- `dashboards: none` — the instance serves no dashboards: dashboard listings
|
|
116
|
+
return empty for it without contacting Grafana, and catalog discovery emits
|
|
117
|
+
no dashboard entities for it.
|
|
118
|
+
- `alerts: prometheus` (default) uses the Grafana-managed Prometheus rules API.
|
|
119
|
+
- `alerts: none` — the instance serves no alerts: alert listings return empty
|
|
120
|
+
for it without contacting Grafana. Set this on instances that do not use
|
|
121
|
+
Grafana-managed alerting; otherwise their snapshot refreshes fail on the
|
|
122
|
+
alerting call.
|
|
123
|
+
|
|
124
|
+
### Folder resolution (`resolveFolders`)
|
|
125
|
+
|
|
126
|
+
- `true` (default): when dashboards come from the App Platform API, one extra
|
|
127
|
+
`/api/folders` request per refresh resolves folder uids to titles and links.
|
|
128
|
+
- `false`: that request is skipped and App Platform dashboards carry no folder
|
|
129
|
+
information. `legacy-search` dashboards are unaffected either way — their
|
|
130
|
+
search response already includes folder details.
|
|
131
|
+
|
|
132
|
+
### Behavior flags
|
|
133
|
+
|
|
134
|
+
Two top-level flags control when the backend talks to Grafana. Both default to
|
|
135
|
+
`true`; each can be set independently.
|
|
136
|
+
|
|
137
|
+
- **`allowOnDemandRefresh`** — may API callers force live reads?
|
|
138
|
+
|
|
139
|
+
- `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.
|
|
142
|
+
- `false`: `refresh` query parameters are silently ignored (the request is
|
|
143
|
+
served exactly as if the parameter were absent) and both `POST …/refresh`
|
|
144
|
+
routes respond `403 NotAllowedError`. The scheduled refresh is unaffected.
|
|
145
|
+
|
|
146
|
+
- **`fetchOnDemand`** — does a store miss trigger a live read?
|
|
147
|
+
- `true` (default): a miss fetches from Grafana on the spot and stores the
|
|
148
|
+
snapshot.
|
|
149
|
+
- `false`: a miss returns empty results and stores nothing; data appears
|
|
150
|
+
once any refresh runs.
|
|
151
|
+
|
|
152
|
+
All four combinations:
|
|
153
|
+
|
|
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. |
|
|
160
|
+
|
|
161
|
+
### Deriving the namespace
|
|
162
|
+
|
|
163
|
+
Grafana's App Platform APIs are namespaced:
|
|
164
|
+
|
|
165
|
+
- Self-hosted, organization 1 → `default`
|
|
166
|
+
- Self-hosted, other organizations → `org-<id>` (set `namespace` explicitly)
|
|
167
|
+
- Grafana Cloud → `stacks-<stackId>` (set `stackId`, or `namespace` directly)
|
|
168
|
+
|
|
169
|
+
## REST API
|
|
170
|
+
|
|
171
|
+
All routes are mounted under `/api/grafana` and (except `/health`) require a
|
|
172
|
+
valid Backstage credential.
|
|
173
|
+
|
|
174
|
+
| Method | Path | Description |
|
|
175
|
+
| ------ | ----------------------------- | ---------------------------------------------------------- |
|
|
176
|
+
| GET | `/health` | Health check (unauthenticated). |
|
|
177
|
+
| GET | `/instances` | List configured instances. |
|
|
178
|
+
| GET | `/instances/:name/dashboards` | Dashboards for one instance. |
|
|
179
|
+
| GET | `/instances/:name/alerts` | Alerts for one instance. |
|
|
180
|
+
| POST | `/instances/:name/refresh` | Force a refresh of one instance. |
|
|
181
|
+
| GET | `/dashboards` | Dashboards across all instances (or `?instance=` for one). |
|
|
182
|
+
| GET | `/alerts` | Alerts across all instances (or `?instance=` for one). |
|
|
183
|
+
| POST | `/refresh` | Force a refresh of all instances. |
|
|
184
|
+
|
|
185
|
+
### Query parameters
|
|
186
|
+
|
|
187
|
+
- `tag` — repeatable; only dashboards carrying **all** given tags are returned.
|
|
188
|
+
- `query` — comma-separated, case-insensitive title substrings; dashboards
|
|
189
|
+
matching **any** value are returned.
|
|
190
|
+
- `labelSelector` — `key=value,key2=value2`; only alerts matching **all** pairs.
|
|
191
|
+
- `instance` — (on `/dashboards` and `/alerts`) restrict to a single instance.
|
|
192
|
+
- `refresh` — `true` or `1` (or the bare flag) to bypass the store and read
|
|
193
|
+
live from Grafana.
|
|
194
|
+
|
|
195
|
+
Example:
|
|
196
|
+
|
|
197
|
+
```sh
|
|
198
|
+
curl -H "Authorization: Bearer <backstage-token>" \
|
|
199
|
+
"http://localhost:7007/api/grafana/instances/production/dashboards?tag=team-a&refresh=true"
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## How caching works
|
|
203
|
+
|
|
204
|
+
Each request resolves to a per-instance snapshot (all dashboards + all alerts):
|
|
205
|
+
|
|
206
|
+
1. Unless `refresh=true`, the snapshot is read from the configured store.
|
|
207
|
+
2. On a miss (or when `refresh=true`), the backend reads live from Grafana and
|
|
208
|
+
writes the snapshot back to the store.
|
|
209
|
+
3. Requested tag/query/label filters are then applied to the snapshot.
|
|
210
|
+
|
|
211
|
+
Because filtering happens after retrieval, a single cached snapshot serves many
|
|
212
|
+
entities with different selectors, keeping Grafana API traffic low.
|
|
213
|
+
|
|
214
|
+
## Local development
|
|
215
|
+
|
|
216
|
+
```sh
|
|
217
|
+
yarn start # from this directory, runs the plugin in a standalone backend
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## Testing
|
|
221
|
+
|
|
222
|
+
```sh
|
|
223
|
+
yarn workspace @marble-sh/backstage-plugin-grafana-backend test
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
The plugin is developed test-first. Unit tests cover the HTTP client (with an
|
|
227
|
+
injected `fetch`), config parsing, both stores (the database store against an
|
|
228
|
+
in-memory SQLite), the caching service, and the router. An integration test
|
|
229
|
+
boots the plugin with `startTestBackend` and a mocked Grafana.
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"type": "object",
|
|
4
|
+
"properties": {
|
|
5
|
+
"grafana": {
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"store": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"enum": [
|
|
11
|
+
"cache",
|
|
12
|
+
"database"
|
|
13
|
+
],
|
|
14
|
+
"description": "Where fetched dashboard and alert data is stored between live refreshes.\n\n - `cache` (default): the Backstage cache service (ephemeral, honors `cacheTtl`). Best for stateless deployments. - `database`: the Backstage database service (durable). Survives restarts and is shared across replicas."
|
|
15
|
+
},
|
|
16
|
+
"cacheTtl": {
|
|
17
|
+
"type": "object",
|
|
18
|
+
"properties": {
|
|
19
|
+
"years": {
|
|
20
|
+
"type": "number"
|
|
21
|
+
},
|
|
22
|
+
"months": {
|
|
23
|
+
"type": "number"
|
|
24
|
+
},
|
|
25
|
+
"weeks": {
|
|
26
|
+
"type": "number"
|
|
27
|
+
},
|
|
28
|
+
"days": {
|
|
29
|
+
"type": "number"
|
|
30
|
+
},
|
|
31
|
+
"hours": {
|
|
32
|
+
"type": "number"
|
|
33
|
+
},
|
|
34
|
+
"minutes": {
|
|
35
|
+
"type": "number"
|
|
36
|
+
},
|
|
37
|
+
"seconds": {
|
|
38
|
+
"type": "number"
|
|
39
|
+
},
|
|
40
|
+
"milliseconds": {
|
|
41
|
+
"type": "number"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"description": "Time-to-live for cached data. Only used when `store` is `cache`. Defaults to 15 minutes."
|
|
45
|
+
},
|
|
46
|
+
"allowOnDemandRefresh": {
|
|
47
|
+
"type": "boolean",
|
|
48
|
+
"description": "Whether API callers may force live reads from Grafana.\n\n - `true` (default): the `refresh=true` query parameter bypasses the store, and `POST /refresh` / `POST /instances/:name/refresh` trigger immediate refreshes. - `false`: `refresh` query parameters are silently ignored (requests are served from the store as if the parameter were absent) and the `POST …/refresh` routes respond `403`. The scheduled refresh is not affected."
|
|
49
|
+
},
|
|
50
|
+
"fetchOnDemand": {
|
|
51
|
+
"type": "boolean",
|
|
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
|
+
},
|
|
54
|
+
"schedule": {
|
|
55
|
+
"type": "object",
|
|
56
|
+
"properties": {
|
|
57
|
+
"frequency": {
|
|
58
|
+
"anyOf": [
|
|
59
|
+
{
|
|
60
|
+
"type": "object",
|
|
61
|
+
"properties": {
|
|
62
|
+
"years": {
|
|
63
|
+
"type": "number"
|
|
64
|
+
},
|
|
65
|
+
"months": {
|
|
66
|
+
"type": "number"
|
|
67
|
+
},
|
|
68
|
+
"weeks": {
|
|
69
|
+
"type": "number"
|
|
70
|
+
},
|
|
71
|
+
"days": {
|
|
72
|
+
"type": "number"
|
|
73
|
+
},
|
|
74
|
+
"hours": {
|
|
75
|
+
"type": "number"
|
|
76
|
+
},
|
|
77
|
+
"minutes": {
|
|
78
|
+
"type": "number"
|
|
79
|
+
},
|
|
80
|
+
"seconds": {
|
|
81
|
+
"type": "number"
|
|
82
|
+
},
|
|
83
|
+
"milliseconds": {
|
|
84
|
+
"type": "number"
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
"description": "Human friendly durations object."
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"type": "object",
|
|
91
|
+
"properties": {
|
|
92
|
+
"cron": {
|
|
93
|
+
"type": "string"
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
"required": [
|
|
97
|
+
"cron"
|
|
98
|
+
]
|
|
99
|
+
}
|
|
100
|
+
],
|
|
101
|
+
"description": "How often the refresh runs. Either a duration or a cron expression."
|
|
102
|
+
},
|
|
103
|
+
"timeout": {
|
|
104
|
+
"type": "object",
|
|
105
|
+
"properties": {
|
|
106
|
+
"years": {
|
|
107
|
+
"type": "number"
|
|
108
|
+
},
|
|
109
|
+
"months": {
|
|
110
|
+
"type": "number"
|
|
111
|
+
},
|
|
112
|
+
"weeks": {
|
|
113
|
+
"type": "number"
|
|
114
|
+
},
|
|
115
|
+
"days": {
|
|
116
|
+
"type": "number"
|
|
117
|
+
},
|
|
118
|
+
"hours": {
|
|
119
|
+
"type": "number"
|
|
120
|
+
},
|
|
121
|
+
"minutes": {
|
|
122
|
+
"type": "number"
|
|
123
|
+
},
|
|
124
|
+
"seconds": {
|
|
125
|
+
"type": "number"
|
|
126
|
+
},
|
|
127
|
+
"milliseconds": {
|
|
128
|
+
"type": "number"
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
"description": "The maximum time a single refresh run may take before it times out."
|
|
132
|
+
},
|
|
133
|
+
"initialDelay": {
|
|
134
|
+
"type": "object",
|
|
135
|
+
"properties": {
|
|
136
|
+
"years": {
|
|
137
|
+
"type": "number"
|
|
138
|
+
},
|
|
139
|
+
"months": {
|
|
140
|
+
"type": "number"
|
|
141
|
+
},
|
|
142
|
+
"weeks": {
|
|
143
|
+
"type": "number"
|
|
144
|
+
},
|
|
145
|
+
"days": {
|
|
146
|
+
"type": "number"
|
|
147
|
+
},
|
|
148
|
+
"hours": {
|
|
149
|
+
"type": "number"
|
|
150
|
+
},
|
|
151
|
+
"minutes": {
|
|
152
|
+
"type": "number"
|
|
153
|
+
},
|
|
154
|
+
"seconds": {
|
|
155
|
+
"type": "number"
|
|
156
|
+
},
|
|
157
|
+
"milliseconds": {
|
|
158
|
+
"type": "number"
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
"description": "How long to wait after startup before the first refresh run."
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
"required": [
|
|
165
|
+
"frequency",
|
|
166
|
+
"timeout"
|
|
167
|
+
],
|
|
168
|
+
"description": "Background refresh schedule. When set, the backend periodically refreshes every instance's dashboards and alerts into the configured store. Omit to disable scheduled refresh (data is then fetched lazily on request)."
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
"description": "Configuration for the Grafana backend plugin.\n\nThe backend performs all communication with Grafana. The frontend never talks to Grafana directly, so all credentials live here in the backend.\n\nThe `grafana.instances` list itself is declared by the shared `@marble-sh/backstage-plugin-grafana-node` library's config schema."
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var backendPluginApi = require('@backstage/backend-plugin-api');
|
|
4
|
+
var errors = require('@backstage/errors');
|
|
5
|
+
var backstagePluginGrafanaNode = require('@marble-sh/backstage-plugin-grafana-node');
|
|
6
|
+
|
|
7
|
+
const DEFAULT_CACHE_TTL = { minutes: 15 };
|
|
8
|
+
function readGrafanaConfig(rootConfig) {
|
|
9
|
+
const instances = backstagePluginGrafanaNode.readGrafanaInstances(rootConfig);
|
|
10
|
+
const config = rootConfig.getOptionalConfig("grafana");
|
|
11
|
+
const store = config?.getOptionalString("store") ?? "cache";
|
|
12
|
+
if (store !== "cache" && store !== "database") {
|
|
13
|
+
throw new errors.InputError(
|
|
14
|
+
`Invalid grafana.store '${store}', expected 'cache' or 'database'`
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
const scheduleConfig = config?.getOptionalConfig("schedule");
|
|
18
|
+
return {
|
|
19
|
+
instances,
|
|
20
|
+
store,
|
|
21
|
+
cacheTtl: config?.getOptional("cacheTtl") ?? DEFAULT_CACHE_TTL,
|
|
22
|
+
schedule: scheduleConfig ? backendPluginApi.readSchedulerServiceTaskScheduleDefinitionFromConfig(scheduleConfig) : void 0,
|
|
23
|
+
allowOnDemandRefresh: config?.getOptionalBoolean("allowOnDemandRefresh") ?? true,
|
|
24
|
+
fetchOnDemand: config?.getOptionalBoolean("fetchOnDemand") ?? true
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
exports.readGrafanaConfig = readGrafanaConfig;
|
|
29
|
+
//# sourceMappingURL=config.cjs.js.map
|
|
@@ -0,0 +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;;"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
5
|
+
var plugin = require('./plugin.cjs.js');
|
|
6
|
+
var GrafanaService = require('./service/GrafanaService.cjs.js');
|
|
7
|
+
var backstagePluginGrafanaNode = require('@marble-sh/backstage-plugin-grafana-node');
|
|
8
|
+
var config = require('./grafana/config.cjs.js');
|
|
9
|
+
var CacheGrafanaStore = require('./store/CacheGrafanaStore.cjs.js');
|
|
10
|
+
var DatabaseGrafanaStore = require('./store/DatabaseGrafanaStore.cjs.js');
|
|
11
|
+
var router = require('./service/router.cjs.js');
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
exports.default = plugin.grafanaPlugin;
|
|
16
|
+
exports.DefaultGrafanaService = GrafanaService.DefaultGrafanaService;
|
|
17
|
+
Object.defineProperty(exports, "GrafanaHttpClient", {
|
|
18
|
+
enumerable: true,
|
|
19
|
+
get: function () { return backstagePluginGrafanaNode.GrafanaHttpClient; }
|
|
20
|
+
});
|
|
21
|
+
exports.readGrafanaConfig = config.readGrafanaConfig;
|
|
22
|
+
exports.CacheGrafanaStore = CacheGrafanaStore.CacheGrafanaStore;
|
|
23
|
+
exports.DatabaseGrafanaStore = DatabaseGrafanaStore.DatabaseGrafanaStore;
|
|
24
|
+
exports.createRouter = router.createRouter;
|
|
25
|
+
//# sourceMappingURL=index.cjs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.cjs.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
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';
|
|
4
|
+
import { GrafanaInstanceConfig, GrafanaClient } from '@marble-sh/backstage-plugin-grafana-node';
|
|
5
|
+
export { FetchApi, GrafanaClient, GrafanaHttpClient, GrafanaInstanceApis, GrafanaInstanceConfig, ListAlertsOptions, ListDashboardsOptions } from '@marble-sh/backstage-plugin-grafana-node';
|
|
6
|
+
import { Config } from '@backstage/config';
|
|
7
|
+
import { HumanDuration } from '@backstage/types';
|
|
8
|
+
import { Knex } from 'knex';
|
|
9
|
+
import express from 'express';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The Grafana backend plugin.
|
|
13
|
+
*
|
|
14
|
+
* Reads dashboards and alerts from the configured Grafana instances, caches
|
|
15
|
+
* them (in the cache or the database), optionally refreshes them on a schedule,
|
|
16
|
+
* and exposes a read-only REST API under `/api/grafana`.
|
|
17
|
+
*
|
|
18
|
+
* @public
|
|
19
|
+
*/
|
|
20
|
+
declare const grafanaPlugin: _backstage_backend_plugin_api.BackendFeature;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A point-in-time snapshot of the data read from a single Grafana instance.
|
|
24
|
+
*
|
|
25
|
+
* @public
|
|
26
|
+
*/
|
|
27
|
+
type GrafanaSnapshot = {
|
|
28
|
+
dashboards: GrafanaDashboard[];
|
|
29
|
+
alerts: GrafanaAlert[];
|
|
30
|
+
/** ISO-8601 timestamp of when the data was read from Grafana. */
|
|
31
|
+
fetchedAt: string;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Persists {@link GrafanaSnapshot}s between live refreshes.
|
|
35
|
+
*
|
|
36
|
+
* Implementations may be durable (database-backed) or ephemeral (cache-backed).
|
|
37
|
+
*
|
|
38
|
+
* @public
|
|
39
|
+
*/
|
|
40
|
+
interface GrafanaStore {
|
|
41
|
+
/** Returns the stored snapshot for an instance, or `undefined` if none. */
|
|
42
|
+
get(instanceName: string): Promise<GrafanaSnapshot | undefined>;
|
|
43
|
+
/** Replaces the stored snapshot for an instance. */
|
|
44
|
+
set(instanceName: string, snapshot: GrafanaSnapshot): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A configured instance together with the client used to read from it.
|
|
49
|
+
*
|
|
50
|
+
* @public
|
|
51
|
+
*/
|
|
52
|
+
type GrafanaInstance = {
|
|
53
|
+
config: GrafanaInstanceConfig;
|
|
54
|
+
client: GrafanaClient;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Options for reading dashboards through the service.
|
|
58
|
+
*
|
|
59
|
+
* @public
|
|
60
|
+
*/
|
|
61
|
+
type GetDashboardsOptions = {
|
|
62
|
+
/** Restrict to a single instance. When omitted, all instances are queried. */
|
|
63
|
+
instanceName?: string;
|
|
64
|
+
/** Only return dashboards carrying all of these tags. */
|
|
65
|
+
tags?: string[];
|
|
66
|
+
/** Comma-separated title substrings; dashboards matching any are returned. */
|
|
67
|
+
query?: string;
|
|
68
|
+
/** Force a live fetch, bypassing the store. */
|
|
69
|
+
refresh?: boolean;
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Options for reading alerts through the service.
|
|
73
|
+
*
|
|
74
|
+
* @public
|
|
75
|
+
*/
|
|
76
|
+
type GetAlertsOptions = {
|
|
77
|
+
/** Restrict to a single instance. When omitted, all instances are queried. */
|
|
78
|
+
instanceName?: string;
|
|
79
|
+
/** Only return alerts whose labels match all of these `key=value` pairs. */
|
|
80
|
+
labelSelector?: Record<string, string>;
|
|
81
|
+
/** Force a live fetch, bypassing the store. */
|
|
82
|
+
refresh?: boolean;
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Reads dashboards and alerts from the configured Grafana instances, backed by a
|
|
86
|
+
* {@link GrafanaStore} for caching and periodic refresh.
|
|
87
|
+
*
|
|
88
|
+
* @public
|
|
89
|
+
*/
|
|
90
|
+
interface GrafanaService {
|
|
91
|
+
/** Returns the configured Grafana instances. */
|
|
92
|
+
getInstances(): GrafanaInstanceInfo[];
|
|
93
|
+
/** Returns dashboards for one or all instances, honoring the store and filters. */
|
|
94
|
+
getDashboards(options: GetDashboardsOptions): Promise<GrafanaDashboard[]>;
|
|
95
|
+
/** Returns alerts for one or all instances, honoring the store and filters. */
|
|
96
|
+
getAlerts(options: GetAlertsOptions): Promise<GrafanaAlert[]>;
|
|
97
|
+
/** Refreshes a single instance, or all instances when no name is given. */
|
|
98
|
+
refresh(instanceName?: string): Promise<void>;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The default {@link GrafanaService} implementation.
|
|
102
|
+
*
|
|
103
|
+
* @public
|
|
104
|
+
*/
|
|
105
|
+
declare class DefaultGrafanaService implements GrafanaService {
|
|
106
|
+
private readonly instances;
|
|
107
|
+
private readonly store;
|
|
108
|
+
private readonly logger;
|
|
109
|
+
private readonly fetchOnDemand;
|
|
110
|
+
constructor(options: {
|
|
111
|
+
instances: GrafanaInstance[];
|
|
112
|
+
store: GrafanaStore;
|
|
113
|
+
logger: LoggerService;
|
|
114
|
+
/**
|
|
115
|
+
* Whether a store miss triggers a live Grafana read (default `true`).
|
|
116
|
+
* When `false`, misses resolve to an empty snapshot and Grafana is only
|
|
117
|
+
* contacted by explicit {@link DefaultGrafanaService.refresh} calls (the
|
|
118
|
+
* schedule, the refresh endpoints, or a `refresh: true` read option).
|
|
119
|
+
*/
|
|
120
|
+
fetchOnDemand?: boolean;
|
|
121
|
+
});
|
|
122
|
+
/** {@inheritDoc GrafanaService.getInstances} */
|
|
123
|
+
getInstances(): GrafanaInstanceInfo[];
|
|
124
|
+
/** {@inheritDoc GrafanaService.getDashboards} */
|
|
125
|
+
getDashboards(options: GetDashboardsOptions): Promise<GrafanaDashboard[]>;
|
|
126
|
+
/** {@inheritDoc GrafanaService.getAlerts} */
|
|
127
|
+
getAlerts(options: GetAlertsOptions): Promise<GrafanaAlert[]>;
|
|
128
|
+
/** {@inheritDoc GrafanaService.refresh} */
|
|
129
|
+
refresh(instanceName?: string): Promise<void>;
|
|
130
|
+
private resolveNames;
|
|
131
|
+
private mustGet;
|
|
132
|
+
private snapshotFor;
|
|
133
|
+
private refreshInstance;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Where fetched data is stored between refreshes.
|
|
138
|
+
*
|
|
139
|
+
* @public
|
|
140
|
+
*/
|
|
141
|
+
type GrafanaStoreKind = 'cache' | 'database';
|
|
142
|
+
/**
|
|
143
|
+
* The fully-resolved backend configuration.
|
|
144
|
+
*
|
|
145
|
+
* @public
|
|
146
|
+
*/
|
|
147
|
+
type GrafanaBackendConfig = {
|
|
148
|
+
instances: GrafanaInstanceConfig[];
|
|
149
|
+
store: GrafanaStoreKind;
|
|
150
|
+
cacheTtl: HumanDuration;
|
|
151
|
+
schedule?: SchedulerServiceTaskScheduleDefinition;
|
|
152
|
+
/** Whether callers may force live reads (`?refresh=true`, `POST /refresh`). */
|
|
153
|
+
allowOnDemandRefresh: boolean;
|
|
154
|
+
/** Whether a store miss triggers a live Grafana read. */
|
|
155
|
+
fetchOnDemand: boolean;
|
|
156
|
+
};
|
|
157
|
+
/**
|
|
158
|
+
* Reads and validates the `grafana` section of the app configuration into a
|
|
159
|
+
* fully-resolved {@link GrafanaBackendConfig}.
|
|
160
|
+
*
|
|
161
|
+
* @public
|
|
162
|
+
*/
|
|
163
|
+
declare function readGrafanaConfig(rootConfig: Config): GrafanaBackendConfig;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A {@link GrafanaStore} backed by the Backstage cache service. Snapshots are
|
|
167
|
+
* ephemeral and expire after the configured TTL.
|
|
168
|
+
*
|
|
169
|
+
* @public
|
|
170
|
+
*/
|
|
171
|
+
declare class CacheGrafanaStore implements GrafanaStore {
|
|
172
|
+
private readonly cache;
|
|
173
|
+
private readonly ttlMillis;
|
|
174
|
+
constructor(options: {
|
|
175
|
+
cache: CacheService;
|
|
176
|
+
ttl: HumanDuration;
|
|
177
|
+
});
|
|
178
|
+
private key;
|
|
179
|
+
/** {@inheritDoc GrafanaStore.get} */
|
|
180
|
+
get(instanceName: string): Promise<GrafanaSnapshot | undefined>;
|
|
181
|
+
/** {@inheritDoc GrafanaStore.set} */
|
|
182
|
+
set(instanceName: string, snapshot: GrafanaSnapshot): Promise<void>;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* A {@link GrafanaStore} backed by the Backstage database service. Snapshots are
|
|
187
|
+
* durable and survive restarts.
|
|
188
|
+
*
|
|
189
|
+
* @public
|
|
190
|
+
*/
|
|
191
|
+
declare class DatabaseGrafanaStore implements GrafanaStore {
|
|
192
|
+
private readonly knex;
|
|
193
|
+
private constructor();
|
|
194
|
+
/**
|
|
195
|
+
* Creates the store from the database core service, running migrations
|
|
196
|
+
* unless the integrator has configured them to be skipped.
|
|
197
|
+
*/
|
|
198
|
+
static create(options: {
|
|
199
|
+
database: DatabaseService;
|
|
200
|
+
}): Promise<DatabaseGrafanaStore>;
|
|
201
|
+
/**
|
|
202
|
+
* Creates the store from a raw Knex client, running migrations. Primarily
|
|
203
|
+
* intended for testing.
|
|
204
|
+
*/
|
|
205
|
+
static fromKnex(knex: Knex): Promise<DatabaseGrafanaStore>;
|
|
206
|
+
/** {@inheritDoc GrafanaStore.get} */
|
|
207
|
+
get(instanceName: string): Promise<GrafanaSnapshot | undefined>;
|
|
208
|
+
/** {@inheritDoc GrafanaStore.set} */
|
|
209
|
+
set(instanceName: string, snapshot: GrafanaSnapshot): Promise<void>;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Creates the Express router that exposes the read-only Grafana REST API.
|
|
214
|
+
*
|
|
215
|
+
* All routes are relative to the plugin base path (`/api/grafana`).
|
|
216
|
+
*
|
|
217
|
+
* @public
|
|
218
|
+
*/
|
|
219
|
+
declare function createRouter(options: {
|
|
220
|
+
grafanaService: GrafanaService;
|
|
221
|
+
/**
|
|
222
|
+
* Whether callers may force live Grafana reads (default `true`). When
|
|
223
|
+
* `false`, `refresh` query parameters are ignored and the `POST …/refresh`
|
|
224
|
+
* routes respond 403.
|
|
225
|
+
*/
|
|
226
|
+
allowOnDemandRefresh?: boolean;
|
|
227
|
+
}): Promise<express.Router>;
|
|
228
|
+
|
|
229
|
+
export { CacheGrafanaStore, DatabaseGrafanaStore, DefaultGrafanaService, createRouter, grafanaPlugin as default, readGrafanaConfig };
|
|
230
|
+
export type { GetAlertsOptions, GetDashboardsOptions, GrafanaBackendConfig, GrafanaInstance, GrafanaService, GrafanaSnapshot, GrafanaStore, GrafanaStoreKind };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var backendPluginApi = require('@backstage/backend-plugin-api');
|
|
4
|
+
var config = require('./grafana/config.cjs.js');
|
|
5
|
+
var backstagePluginGrafanaNode = require('@marble-sh/backstage-plugin-grafana-node');
|
|
6
|
+
var CacheGrafanaStore = require('./store/CacheGrafanaStore.cjs.js');
|
|
7
|
+
var DatabaseGrafanaStore = require('./store/DatabaseGrafanaStore.cjs.js');
|
|
8
|
+
var GrafanaService = require('./service/GrafanaService.cjs.js');
|
|
9
|
+
var router = require('./service/router.cjs.js');
|
|
10
|
+
|
|
11
|
+
const grafanaPlugin = backendPluginApi.createBackendPlugin({
|
|
12
|
+
pluginId: "grafana",
|
|
13
|
+
register(env) {
|
|
14
|
+
env.registerInit({
|
|
15
|
+
deps: {
|
|
16
|
+
logger: backendPluginApi.coreServices.logger,
|
|
17
|
+
config: backendPluginApi.coreServices.rootConfig,
|
|
18
|
+
httpRouter: backendPluginApi.coreServices.httpRouter,
|
|
19
|
+
cache: backendPluginApi.coreServices.cache,
|
|
20
|
+
database: backendPluginApi.coreServices.database,
|
|
21
|
+
scheduler: backendPluginApi.coreServices.scheduler
|
|
22
|
+
},
|
|
23
|
+
async init({ logger, config: config$1, httpRouter, cache, database, scheduler }) {
|
|
24
|
+
const grafanaConfig = config.readGrafanaConfig(config$1);
|
|
25
|
+
if (grafanaConfig.instances.length === 0) {
|
|
26
|
+
logger.warn(
|
|
27
|
+
"No Grafana instances are configured under `grafana.instances`; the grafana plugin will return empty results"
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
const store = grafanaConfig.store === "database" ? await DatabaseGrafanaStore.DatabaseGrafanaStore.create({ database }) : new CacheGrafanaStore.CacheGrafanaStore({ cache, ttl: grafanaConfig.cacheTtl });
|
|
31
|
+
const instances = grafanaConfig.instances.map(
|
|
32
|
+
(instance) => ({
|
|
33
|
+
config: instance,
|
|
34
|
+
client: new backstagePluginGrafanaNode.GrafanaHttpClient({ instance })
|
|
35
|
+
})
|
|
36
|
+
);
|
|
37
|
+
const grafanaService = new GrafanaService.DefaultGrafanaService({
|
|
38
|
+
instances,
|
|
39
|
+
store,
|
|
40
|
+
logger,
|
|
41
|
+
fetchOnDemand: grafanaConfig.fetchOnDemand
|
|
42
|
+
});
|
|
43
|
+
httpRouter.use(
|
|
44
|
+
await router.createRouter({
|
|
45
|
+
grafanaService,
|
|
46
|
+
allowOnDemandRefresh: grafanaConfig.allowOnDemandRefresh
|
|
47
|
+
})
|
|
48
|
+
);
|
|
49
|
+
httpRouter.addAuthPolicy({ path: "/health", allow: "unauthenticated" });
|
|
50
|
+
if (grafanaConfig.schedule) {
|
|
51
|
+
await scheduler.scheduleTask({
|
|
52
|
+
id: "grafana-refresh",
|
|
53
|
+
...grafanaConfig.schedule,
|
|
54
|
+
fn: async () => {
|
|
55
|
+
await grafanaService.refresh();
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
logger.info("Scheduled periodic Grafana refresh");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
exports.grafanaPlugin = grafanaPlugin;
|
|
66
|
+
//# sourceMappingURL=plugin.cjs.js.map
|
|
@@ -0,0 +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;;"}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var errors = require('@backstage/errors');
|
|
4
|
+
var backstagePluginGrafanaNode = require('@marble-sh/backstage-plugin-grafana-node');
|
|
5
|
+
|
|
6
|
+
class DefaultGrafanaService {
|
|
7
|
+
instances;
|
|
8
|
+
store;
|
|
9
|
+
logger;
|
|
10
|
+
fetchOnDemand;
|
|
11
|
+
constructor(options) {
|
|
12
|
+
this.instances = new Map(
|
|
13
|
+
options.instances.map((instance) => [instance.config.name, instance])
|
|
14
|
+
);
|
|
15
|
+
this.store = options.store;
|
|
16
|
+
this.logger = options.logger;
|
|
17
|
+
this.fetchOnDemand = options.fetchOnDemand ?? true;
|
|
18
|
+
}
|
|
19
|
+
/** {@inheritDoc GrafanaService.getInstances} */
|
|
20
|
+
getInstances() {
|
|
21
|
+
return [...this.instances.values()].map(({ config }) => ({
|
|
22
|
+
name: config.name,
|
|
23
|
+
title: config.title,
|
|
24
|
+
url: config.baseUrl
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
/** {@inheritDoc GrafanaService.getDashboards} */
|
|
28
|
+
async getDashboards(options) {
|
|
29
|
+
const names = this.resolveNames(options.instanceName);
|
|
30
|
+
const result = [];
|
|
31
|
+
for (const name of names) {
|
|
32
|
+
const snapshot = await this.snapshotFor(name, options.refresh);
|
|
33
|
+
result.push(
|
|
34
|
+
...backstagePluginGrafanaNode.filterDashboards(snapshot.dashboards, {
|
|
35
|
+
tags: options.tags,
|
|
36
|
+
query: options.query
|
|
37
|
+
})
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
/** {@inheritDoc GrafanaService.getAlerts} */
|
|
43
|
+
async getAlerts(options) {
|
|
44
|
+
const names = this.resolveNames(options.instanceName);
|
|
45
|
+
const result = [];
|
|
46
|
+
for (const name of names) {
|
|
47
|
+
const snapshot = await this.snapshotFor(name, options.refresh);
|
|
48
|
+
result.push(
|
|
49
|
+
...backstagePluginGrafanaNode.filterAlerts(snapshot.alerts, {
|
|
50
|
+
labelSelector: options.labelSelector
|
|
51
|
+
})
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
/** {@inheritDoc GrafanaService.refresh} */
|
|
57
|
+
async refresh(instanceName) {
|
|
58
|
+
if (instanceName) {
|
|
59
|
+
await this.refreshInstance(instanceName);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
for (const name of this.instances.keys()) {
|
|
63
|
+
try {
|
|
64
|
+
await this.refreshInstance(name);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
this.logger.warn(
|
|
67
|
+
`Failed to refresh Grafana instance '${name}'`,
|
|
68
|
+
error
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
resolveNames(instanceName) {
|
|
74
|
+
if (instanceName) {
|
|
75
|
+
this.mustGet(instanceName);
|
|
76
|
+
return [instanceName];
|
|
77
|
+
}
|
|
78
|
+
return [...this.instances.keys()];
|
|
79
|
+
}
|
|
80
|
+
mustGet(instanceName) {
|
|
81
|
+
const instance = this.instances.get(instanceName);
|
|
82
|
+
if (!instance) {
|
|
83
|
+
throw new errors.NotFoundError(
|
|
84
|
+
`No Grafana instance configured with name '${instanceName}'`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return instance;
|
|
88
|
+
}
|
|
89
|
+
async snapshotFor(instanceName, refresh) {
|
|
90
|
+
if (!refresh) {
|
|
91
|
+
const cached = await this.store.get(instanceName);
|
|
92
|
+
if (cached) {
|
|
93
|
+
return cached;
|
|
94
|
+
}
|
|
95
|
+
if (!this.fetchOnDemand) {
|
|
96
|
+
return { dashboards: [], alerts: [] };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return this.refreshInstance(instanceName);
|
|
100
|
+
}
|
|
101
|
+
async refreshInstance(instanceName) {
|
|
102
|
+
const { client } = this.mustGet(instanceName);
|
|
103
|
+
const [dashboards, alerts] = await Promise.all([
|
|
104
|
+
client.listDashboards(),
|
|
105
|
+
client.listAlerts()
|
|
106
|
+
]);
|
|
107
|
+
const snapshot = {
|
|
108
|
+
dashboards,
|
|
109
|
+
alerts,
|
|
110
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
111
|
+
};
|
|
112
|
+
await this.store.set(instanceName, snapshot);
|
|
113
|
+
this.logger.debug(
|
|
114
|
+
`Refreshed Grafana instance '${instanceName}': ${dashboards.length} dashboards, ${alerts.length} alerts`
|
|
115
|
+
);
|
|
116
|
+
return snapshot;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
exports.DefaultGrafanaService = DefaultGrafanaService;
|
|
121
|
+
//# sourceMappingURL=GrafanaService.cjs.js.map
|
|
@@ -0,0 +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 /** 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 }),\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":";;;;;AA6FO,MAAM,qBAAA,CAAgD;AAAA,EAC1C,SAAA;AAAA,EACA,KAAA;AAAA,EACA,MAAA;AAAA,EACA,aAAA;AAAA,EAEjB,YAAY,OAAA,EAWT;AACD,IAAA,IAAA,CAAK,YAAY,IAAI,GAAA;AAAA,MACnB,OAAA,CAAQ,UAAU,GAAA,CAAI,CAAA,QAAA,KAAY,CAAC,QAAA,CAAS,MAAA,CAAO,IAAA,EAAM,QAAQ,CAAC;AAAA,KACpE;AACA,IAAA,IAAA,CAAK,QAAQ,OAAA,CAAQ,KAAA;AACrB,IAAA,IAAA,CAAK,SAAS,OAAA,CAAQ,MAAA;AACtB,IAAA,IAAA,CAAK,aAAA,GAAgB,QAAQ,aAAA,IAAiB,IAAA;AAAA,EAChD;AAAA;AAAA,EAGA,YAAA,GAAsC;AACpC,IAAA,OAAO,CAAC,GAAG,IAAA,CAAK,SAAA,CAAU,MAAA,EAAQ,CAAA,CAAE,GAAA,CAAI,CAAC,EAAE,MAAA,EAAO,MAAO;AAAA,MACvD,MAAM,MAAA,CAAO,IAAA;AAAA,MACb,OAAO,MAAA,CAAO,KAAA;AAAA,MACd,KAAK,MAAA,CAAO;AAAA,KACd,CAAE,CAAA;AAAA,EACJ;AAAA;AAAA,EAGA,MAAM,cACJ,OAAA,EAC6B;AAC7B,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,YAAA,CAAa,OAAA,CAAQ,YAAY,CAAA;AACpD,IAAA,MAAM,SAA6B,EAAC;AACpC,IAAA,KAAA,MAAW,QAAQ,KAAA,EAAO;AACxB,MAAA,MAAM,WAAW,MAAM,IAAA,CAAK,WAAA,CAAY,IAAA,EAAM,QAAQ,OAAO,CAAA;AAC7D,MAAA,MAAA,CAAO,IAAA;AAAA,QACL,GAAGA,2CAAA,CAAiB,QAAA,CAAS,UAAA,EAAY;AAAA,UACvC,MAAM,OAAA,CAAQ,IAAA;AAAA,UACd,OAAO,OAAA,CAAQ;AAAA,SAChB;AAAA,OACH;AAAA,IACF;AACA,IAAA,OAAO,MAAA;AAAA,EACT;AAAA;AAAA,EAGA,MAAM,UAAU,OAAA,EAAoD;AAClE,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,YAAA,CAAa,OAAA,CAAQ,YAAY,CAAA;AACpD,IAAA,MAAM,SAAyB,EAAC;AAChC,IAAA,KAAA,MAAW,QAAQ,KAAA,EAAO;AACxB,MAAA,MAAM,WAAW,MAAM,IAAA,CAAK,WAAA,CAAY,IAAA,EAAM,QAAQ,OAAO,CAAA;AAC7D,MAAA,MAAA,CAAO,IAAA;AAAA,QACL,GAAGC,uCAAA,CAAa,QAAA,CAAS,MAAA,EAAQ;AAAA,UAC/B,eAAe,OAAA,CAAQ;AAAA,SACxB;AAAA,OACH;AAAA,IACF;AACA,IAAA,OAAO,MAAA;AAAA,EACT;AAAA;AAAA,EAGA,MAAM,QAAQ,YAAA,EAAsC;AAClD,IAAA,IAAI,YAAA,EAAc;AAChB,MAAA,MAAM,IAAA,CAAK,gBAAgB,YAAY,CAAA;AACvC,MAAA;AAAA,IACF;AACA,IAAA,KAAA,MAAW,IAAA,IAAQ,IAAA,CAAK,SAAA,CAAU,IAAA,EAAK,EAAG;AACxC,MAAA,IAAI;AACF,QAAA,MAAM,IAAA,CAAK,gBAAgB,IAAI,CAAA;AAAA,MACjC,SAAS,KAAA,EAAO;AACd,QAAA,IAAA,CAAK,MAAA,CAAO,IAAA;AAAA,UACV,uCAAuC,IAAI,CAAA,CAAA,CAAA;AAAA,UAC3C;AAAA,SACF;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAAA,EAEQ,aAAa,YAAA,EAAiC;AACpD,IAAA,IAAI,YAAA,EAAc;AAChB,MAAA,IAAA,CAAK,QAAQ,YAAY,CAAA;AACzB,MAAA,OAAO,CAAC,YAAY,CAAA;AAAA,IACtB;AACA,IAAA,OAAO,CAAC,GAAG,IAAA,CAAK,SAAA,CAAU,MAAM,CAAA;AAAA,EAClC;AAAA,EAEQ,QAAQ,YAAA,EAAuC;AACrD,IAAA,MAAM,QAAA,GAAW,IAAA,CAAK,SAAA,CAAU,GAAA,CAAI,YAAY,CAAA;AAChD,IAAA,IAAI,CAAC,QAAA,EAAU;AACb,MAAA,MAAM,IAAIC,oBAAA;AAAA,QACR,6CAA6C,YAAY,CAAA,CAAA;AAAA,OAC3D;AAAA,IACF;AACA,IAAA,OAAO,QAAA;AAAA,EACT;AAAA,EAEA,MAAc,WAAA,CACZ,YAAA,EACA,OAAA,EACyD;AACzD,IAAA,IAAI,CAAC,OAAA,EAAS;AACZ,MAAA,MAAM,MAAA,GAAS,MAAM,IAAA,CAAK,KAAA,CAAM,IAAI,YAAY,CAAA;AAChD,MAAA,IAAI,MAAA,EAAQ;AACV,QAAA,OAAO,MAAA;AAAA,MACT;AACA,MAAA,IAAI,CAAC,KAAK,aAAA,EAAe;AAGvB,QAAA,OAAO,EAAE,UAAA,EAAY,EAAC,EAAG,MAAA,EAAQ,EAAC,EAAE;AAAA,MACtC;AAAA,IACF;AACA,IAAA,OAAO,IAAA,CAAK,gBAAgB,YAAY,CAAA;AAAA,EAC1C;AAAA,EAEA,MAAc,gBACZ,YAAA,EAC0B;AAC1B,IAAA,MAAM,EAAE,MAAA,EAAO,GAAI,IAAA,CAAK,QAAQ,YAAY,CAAA;AAC5C,IAAA,MAAM,CAAC,UAAA,EAAY,MAAM,CAAA,GAAI,MAAM,QAAQ,GAAA,CAAI;AAAA,MAC7C,OAAO,cAAA,EAAe;AAAA,MACtB,OAAO,UAAA;AAAW,KACnB,CAAA;AACD,IAAA,MAAM,QAAA,GAA4B;AAAA,MAChC,UAAA;AAAA,MACA,MAAA;AAAA,MACA,SAAA,EAAA,iBAAW,IAAI,IAAA,EAAK,EAAE,WAAA;AAAY,KACpC;AACA,IAAA,MAAM,IAAA,CAAK,KAAA,CAAM,GAAA,CAAI,YAAA,EAAc,QAAQ,CAAA;AAC3C,IAAA,IAAA,CAAK,MAAA,CAAO,KAAA;AAAA,MACV,+BAA+B,YAAY,CAAA,GAAA,EAAM,WAAW,MAAM,CAAA,aAAA,EAAgB,OAAO,MAAM,CAAA,OAAA;AAAA,KACjG;AACA,IAAA,OAAO,QAAA;AAAA,EACT;AACF;;"}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var express = require('express');
|
|
4
|
+
var Router = require('express-promise-router');
|
|
5
|
+
var errors = require('@backstage/errors');
|
|
6
|
+
var backstagePluginGrafanaNode = require('@marble-sh/backstage-plugin-grafana-node');
|
|
7
|
+
|
|
8
|
+
function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e : { default: e }; }
|
|
9
|
+
|
|
10
|
+
var express__default = /*#__PURE__*/_interopDefaultCompat(express);
|
|
11
|
+
var Router__default = /*#__PURE__*/_interopDefaultCompat(Router);
|
|
12
|
+
|
|
13
|
+
const toArray = (value) => {
|
|
14
|
+
if (value === void 0) {
|
|
15
|
+
return void 0;
|
|
16
|
+
}
|
|
17
|
+
return Array.isArray(value) ? value.map(String) : [String(value)];
|
|
18
|
+
};
|
|
19
|
+
const toString = (value) => value === void 0 ? void 0 : String(value);
|
|
20
|
+
const toBoolean = (value) => value !== void 0 && ["true", "1", ""].includes(String(value));
|
|
21
|
+
async function createRouter(options) {
|
|
22
|
+
const { grafanaService } = options;
|
|
23
|
+
const allowOnDemandRefresh = options.allowOnDemandRefresh ?? true;
|
|
24
|
+
const router = Router__default.default();
|
|
25
|
+
router.use(express__default.default.json());
|
|
26
|
+
const toRefresh = (value) => allowOnDemandRefresh && toBoolean(value);
|
|
27
|
+
const assertRefreshAllowed = () => {
|
|
28
|
+
if (!allowOnDemandRefresh) {
|
|
29
|
+
throw new errors.NotAllowedError(
|
|
30
|
+
"On-demand refresh is disabled by configuration (grafana.allowOnDemandRefresh)"
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
router.get("/health", (_req, res) => {
|
|
35
|
+
res.json({ status: "ok" });
|
|
36
|
+
});
|
|
37
|
+
router.get("/instances", (_req, res) => {
|
|
38
|
+
res.json({ items: grafanaService.getInstances() });
|
|
39
|
+
});
|
|
40
|
+
router.get("/instances/:name/dashboards", async (req, res) => {
|
|
41
|
+
const items = await grafanaService.getDashboards({
|
|
42
|
+
instanceName: req.params.name,
|
|
43
|
+
tags: toArray(req.query.tag),
|
|
44
|
+
query: toString(req.query.query),
|
|
45
|
+
refresh: toRefresh(req.query.refresh)
|
|
46
|
+
});
|
|
47
|
+
res.json({ items });
|
|
48
|
+
});
|
|
49
|
+
router.get("/instances/:name/alerts", async (req, res) => {
|
|
50
|
+
const items = await grafanaService.getAlerts({
|
|
51
|
+
instanceName: req.params.name,
|
|
52
|
+
labelSelector: backstagePluginGrafanaNode.parseLabelSelector(toString(req.query.labelSelector)),
|
|
53
|
+
refresh: toRefresh(req.query.refresh)
|
|
54
|
+
});
|
|
55
|
+
res.json({ items });
|
|
56
|
+
});
|
|
57
|
+
router.post("/instances/:name/refresh", async (req, res) => {
|
|
58
|
+
assertRefreshAllowed();
|
|
59
|
+
await grafanaService.refresh(req.params.name);
|
|
60
|
+
res.json({ status: "ok" });
|
|
61
|
+
});
|
|
62
|
+
router.get("/dashboards", async (req, res) => {
|
|
63
|
+
const items = await grafanaService.getDashboards({
|
|
64
|
+
instanceName: toString(req.query.instance),
|
|
65
|
+
tags: toArray(req.query.tag),
|
|
66
|
+
query: toString(req.query.query),
|
|
67
|
+
refresh: toRefresh(req.query.refresh)
|
|
68
|
+
});
|
|
69
|
+
res.json({ items });
|
|
70
|
+
});
|
|
71
|
+
router.get("/alerts", async (req, res) => {
|
|
72
|
+
const items = await grafanaService.getAlerts({
|
|
73
|
+
instanceName: toString(req.query.instance),
|
|
74
|
+
labelSelector: backstagePluginGrafanaNode.parseLabelSelector(toString(req.query.labelSelector)),
|
|
75
|
+
refresh: toRefresh(req.query.refresh)
|
|
76
|
+
});
|
|
77
|
+
res.json({ items });
|
|
78
|
+
});
|
|
79
|
+
router.post("/refresh", async (_req, res) => {
|
|
80
|
+
assertRefreshAllowed();
|
|
81
|
+
await grafanaService.refresh();
|
|
82
|
+
res.json({ status: "ok" });
|
|
83
|
+
});
|
|
84
|
+
return router;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
exports.createRouter = createRouter;
|
|
88
|
+
//# sourceMappingURL=router.cjs.js.map
|
|
@@ -0,0 +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 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 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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"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var types = require('@backstage/types');
|
|
4
|
+
|
|
5
|
+
class CacheGrafanaStore {
|
|
6
|
+
cache;
|
|
7
|
+
ttlMillis;
|
|
8
|
+
constructor(options) {
|
|
9
|
+
this.cache = options.cache;
|
|
10
|
+
this.ttlMillis = types.durationToMilliseconds(options.ttl);
|
|
11
|
+
}
|
|
12
|
+
key(instanceName) {
|
|
13
|
+
return `grafana-snapshot:${instanceName}`;
|
|
14
|
+
}
|
|
15
|
+
/** {@inheritDoc GrafanaStore.get} */
|
|
16
|
+
async get(instanceName) {
|
|
17
|
+
const value = await this.cache.get(this.key(instanceName));
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
/** {@inheritDoc GrafanaStore.set} */
|
|
21
|
+
async set(instanceName, snapshot) {
|
|
22
|
+
await this.cache.set(this.key(instanceName), snapshot, {
|
|
23
|
+
ttl: this.ttlMillis
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
exports.CacheGrafanaStore = CacheGrafanaStore;
|
|
29
|
+
//# sourceMappingURL=CacheGrafanaStore.cjs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"CacheGrafanaStore.cjs.js","sources":["../../src/store/CacheGrafanaStore.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 } from '@backstage/backend-plugin-api';\nimport { HumanDuration, durationToMilliseconds } from '@backstage/types';\nimport { GrafanaSnapshot, GrafanaStore } from './GrafanaStore';\n\n/**\n * A {@link GrafanaStore} backed by the Backstage cache service. Snapshots are\n * ephemeral and expire after the configured TTL.\n *\n * @public\n */\nexport class CacheGrafanaStore implements GrafanaStore {\n private readonly cache: CacheService;\n private readonly ttlMillis: number;\n\n constructor(options: { cache: CacheService; ttl: HumanDuration }) {\n this.cache = options.cache;\n this.ttlMillis = durationToMilliseconds(options.ttl);\n }\n\n private key(instanceName: string): string {\n return `grafana-snapshot:${instanceName}`;\n }\n\n /** {@inheritDoc GrafanaStore.get} */\n async get(instanceName: string): Promise<GrafanaSnapshot | undefined> {\n const value = await this.cache.get(this.key(instanceName));\n return value as GrafanaSnapshot | undefined;\n }\n\n /** {@inheritDoc GrafanaStore.set} */\n async set(instanceName: string, snapshot: GrafanaSnapshot): Promise<void> {\n await this.cache.set(this.key(instanceName), snapshot as any, {\n ttl: this.ttlMillis,\n });\n }\n}\n"],"names":["durationToMilliseconds"],"mappings":";;;;AA0BO,MAAM,iBAAA,CAA0C;AAAA,EACpC,KAAA;AAAA,EACA,SAAA;AAAA,EAEjB,YAAY,OAAA,EAAsD;AAChE,IAAA,IAAA,CAAK,QAAQ,OAAA,CAAQ,KAAA;AACrB,IAAA,IAAA,CAAK,SAAA,GAAYA,4BAAA,CAAuB,OAAA,CAAQ,GAAG,CAAA;AAAA,EACrD;AAAA,EAEQ,IAAI,YAAA,EAA8B;AACxC,IAAA,OAAO,oBAAoB,YAAY,CAAA,CAAA;AAAA,EACzC;AAAA;AAAA,EAGA,MAAM,IAAI,YAAA,EAA4D;AACpE,IAAA,MAAM,KAAA,GAAQ,MAAM,IAAA,CAAK,KAAA,CAAM,IAAI,IAAA,CAAK,GAAA,CAAI,YAAY,CAAC,CAAA;AACzD,IAAA,OAAO,KAAA;AAAA,EACT;AAAA;AAAA,EAGA,MAAM,GAAA,CAAI,YAAA,EAAsB,QAAA,EAA0C;AACxE,IAAA,MAAM,KAAK,KAAA,CAAM,GAAA,CAAI,KAAK,GAAA,CAAI,YAAY,GAAG,QAAA,EAAiB;AAAA,MAC5D,KAAK,IAAA,CAAK;AAAA,KACX,CAAA;AAAA,EACH;AACF;;"}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var backendPluginApi = require('@backstage/backend-plugin-api');
|
|
4
|
+
|
|
5
|
+
const migrationsDir = backendPluginApi.resolvePackagePath(
|
|
6
|
+
"@marble-sh/backstage-plugin-grafana-backend",
|
|
7
|
+
"migrations"
|
|
8
|
+
);
|
|
9
|
+
const TABLE = "grafana__snapshots";
|
|
10
|
+
class DatabaseGrafanaStore {
|
|
11
|
+
constructor(knex) {
|
|
12
|
+
this.knex = knex;
|
|
13
|
+
}
|
|
14
|
+
knex;
|
|
15
|
+
/**
|
|
16
|
+
* Creates the store from the database core service, running migrations
|
|
17
|
+
* unless the integrator has configured them to be skipped.
|
|
18
|
+
*/
|
|
19
|
+
static async create(options) {
|
|
20
|
+
const knex = await options.database.getClient();
|
|
21
|
+
if (options.database.migrations?.skip) {
|
|
22
|
+
return new DatabaseGrafanaStore(knex);
|
|
23
|
+
}
|
|
24
|
+
return DatabaseGrafanaStore.fromKnex(knex);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Creates the store from a raw Knex client, running migrations. Primarily
|
|
28
|
+
* intended for testing.
|
|
29
|
+
*/
|
|
30
|
+
static async fromKnex(knex) {
|
|
31
|
+
await knex.migrate.latest({
|
|
32
|
+
directory: migrationsDir,
|
|
33
|
+
tableName: "grafana__knex_migrations"
|
|
34
|
+
});
|
|
35
|
+
return new DatabaseGrafanaStore(knex);
|
|
36
|
+
}
|
|
37
|
+
/** {@inheritDoc GrafanaStore.get} */
|
|
38
|
+
async get(instanceName) {
|
|
39
|
+
const row = await this.knex(TABLE).where({ instance: instanceName }).first();
|
|
40
|
+
if (!row) {
|
|
41
|
+
return void 0;
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
dashboards: JSON.parse(row.dashboards),
|
|
45
|
+
alerts: JSON.parse(row.alerts),
|
|
46
|
+
fetchedAt: row.fetched_at
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** {@inheritDoc GrafanaStore.set} */
|
|
50
|
+
async set(instanceName, snapshot) {
|
|
51
|
+
const row = {
|
|
52
|
+
instance: instanceName,
|
|
53
|
+
dashboards: JSON.stringify(snapshot.dashboards),
|
|
54
|
+
alerts: JSON.stringify(snapshot.alerts),
|
|
55
|
+
fetched_at: snapshot.fetchedAt
|
|
56
|
+
};
|
|
57
|
+
await this.knex(TABLE).insert(row).onConflict("instance").merge();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
exports.DatabaseGrafanaStore = DatabaseGrafanaStore;
|
|
62
|
+
//# sourceMappingURL=DatabaseGrafanaStore.cjs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"DatabaseGrafanaStore.cjs.js","sources":["../../src/store/DatabaseGrafanaStore.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 DatabaseService,\n resolvePackagePath,\n} from '@backstage/backend-plugin-api';\nimport { Knex } from 'knex';\nimport { GrafanaSnapshot, GrafanaStore } from './GrafanaStore';\n\nconst migrationsDir = resolvePackagePath(\n '@marble-sh/backstage-plugin-grafana-backend',\n 'migrations',\n);\n\nconst TABLE = 'grafana__snapshots';\n\ntype SnapshotRow = {\n instance: string;\n dashboards: string;\n alerts: string;\n fetched_at: string;\n};\n\n/**\n * A {@link GrafanaStore} backed by the Backstage database service. Snapshots are\n * durable and survive restarts.\n *\n * @public\n */\nexport class DatabaseGrafanaStore implements GrafanaStore {\n private constructor(private readonly knex: Knex) {}\n\n /**\n * Creates the store from the database core service, running migrations\n * unless the integrator has configured them to be skipped.\n */\n static async create(options: {\n database: DatabaseService;\n }): Promise<DatabaseGrafanaStore> {\n const knex = await options.database.getClient();\n if (options.database.migrations?.skip) {\n return new DatabaseGrafanaStore(knex);\n }\n return DatabaseGrafanaStore.fromKnex(knex);\n }\n\n /**\n * Creates the store from a raw Knex client, running migrations. Primarily\n * intended for testing.\n */\n static async fromKnex(knex: Knex): Promise<DatabaseGrafanaStore> {\n await knex.migrate.latest({\n directory: migrationsDir,\n tableName: 'grafana__knex_migrations',\n });\n return new DatabaseGrafanaStore(knex);\n }\n\n /** {@inheritDoc GrafanaStore.get} */\n async get(instanceName: string): Promise<GrafanaSnapshot | undefined> {\n const row = await this.knex<SnapshotRow>(TABLE)\n .where({ instance: instanceName })\n .first();\n if (!row) {\n return undefined;\n }\n return {\n dashboards: JSON.parse(row.dashboards),\n alerts: JSON.parse(row.alerts),\n fetchedAt: row.fetched_at,\n };\n }\n\n /** {@inheritDoc GrafanaStore.set} */\n async set(instanceName: string, snapshot: GrafanaSnapshot): Promise<void> {\n const row: SnapshotRow = {\n instance: instanceName,\n dashboards: JSON.stringify(snapshot.dashboards),\n alerts: JSON.stringify(snapshot.alerts),\n fetched_at: snapshot.fetchedAt,\n };\n await this.knex<SnapshotRow>(TABLE)\n .insert(row)\n .onConflict('instance')\n .merge();\n }\n}\n"],"names":["resolvePackagePath"],"mappings":";;;;AAuBA,MAAM,aAAA,GAAgBA,mCAAA;AAAA,EACpB,6CAAA;AAAA,EACA;AACF,CAAA;AAEA,MAAM,KAAA,GAAQ,oBAAA;AAeP,MAAM,oBAAA,CAA6C;AAAA,EAChD,YAA6B,IAAA,EAAY;AAAZ,IAAA,IAAA,CAAA,IAAA,GAAA,IAAA;AAAA,EAAa;AAAA,EAAb,IAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMrC,aAAa,OAAO,OAAA,EAEc;AAChC,IAAA,MAAM,IAAA,GAAO,MAAM,OAAA,CAAQ,QAAA,CAAS,SAAA,EAAU;AAC9C,IAAA,IAAI,OAAA,CAAQ,QAAA,CAAS,UAAA,EAAY,IAAA,EAAM;AACrC,MAAA,OAAO,IAAI,qBAAqB,IAAI,CAAA;AAAA,IACtC;AACA,IAAA,OAAO,oBAAA,CAAqB,SAAS,IAAI,CAAA;AAAA,EAC3C;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,aAAa,SAAS,IAAA,EAA2C;AAC/D,IAAA,MAAM,IAAA,CAAK,QAAQ,MAAA,CAAO;AAAA,MACxB,SAAA,EAAW,aAAA;AAAA,MACX,SAAA,EAAW;AAAA,KACZ,CAAA;AACD,IAAA,OAAO,IAAI,qBAAqB,IAAI,CAAA;AAAA,EACtC;AAAA;AAAA,EAGA,MAAM,IAAI,YAAA,EAA4D;AACpE,IAAA,MAAM,GAAA,GAAM,MAAM,IAAA,CAAK,IAAA,CAAkB,KAAK,CAAA,CAC3C,KAAA,CAAM,EAAE,QAAA,EAAU,YAAA,EAAc,CAAA,CAChC,KAAA,EAAM;AACT,IAAA,IAAI,CAAC,GAAA,EAAK;AACR,MAAA,OAAO,MAAA;AAAA,IACT;AACA,IAAA,OAAO;AAAA,MACL,UAAA,EAAY,IAAA,CAAK,KAAA,CAAM,GAAA,CAAI,UAAU,CAAA;AAAA,MACrC,MAAA,EAAQ,IAAA,CAAK,KAAA,CAAM,GAAA,CAAI,MAAM,CAAA;AAAA,MAC7B,WAAW,GAAA,CAAI;AAAA,KACjB;AAAA,EACF;AAAA;AAAA,EAGA,MAAM,GAAA,CAAI,YAAA,EAAsB,QAAA,EAA0C;AACxE,IAAA,MAAM,GAAA,GAAmB;AAAA,MACvB,QAAA,EAAU,YAAA;AAAA,MACV,UAAA,EAAY,IAAA,CAAK,SAAA,CAAU,QAAA,CAAS,UAAU,CAAA;AAAA,MAC9C,MAAA,EAAQ,IAAA,CAAK,SAAA,CAAU,QAAA,CAAS,MAAM,CAAA;AAAA,MACtC,YAAY,QAAA,CAAS;AAAA,KACvB;AACA,IAAA,MAAM,IAAA,CAAK,IAAA,CAAkB,KAAK,CAAA,CAC/B,MAAA,CAAO,GAAG,CAAA,CACV,UAAA,CAAW,UAAU,CAAA,CACrB,KAAA,EAAM;AAAA,EACX;AACF;;"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 Cassidy Marble
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {import('knex').Knex} knex
|
|
19
|
+
*/
|
|
20
|
+
exports.up = async function up(knex) {
|
|
21
|
+
await knex.schema.createTable('grafana__snapshots', table => {
|
|
22
|
+
table.comment('Latest Grafana data snapshot per configured instance');
|
|
23
|
+
table
|
|
24
|
+
.string('instance')
|
|
25
|
+
.primary()
|
|
26
|
+
.notNullable()
|
|
27
|
+
.comment('The configured Grafana instance name');
|
|
28
|
+
table
|
|
29
|
+
.text('dashboards')
|
|
30
|
+
.notNullable()
|
|
31
|
+
.comment('JSON-encoded array of dashboards');
|
|
32
|
+
table.text('alerts').notNullable().comment('JSON-encoded array of alerts');
|
|
33
|
+
table
|
|
34
|
+
.text('fetched_at')
|
|
35
|
+
.notNullable()
|
|
36
|
+
.comment('ISO-8601 timestamp of when the snapshot was read from Grafana');
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {import('knex').Knex} knex
|
|
42
|
+
*/
|
|
43
|
+
exports.down = async function down(knex) {
|
|
44
|
+
await knex.schema.dropTable('grafana__snapshots');
|
|
45
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@marble-sh/backstage-plugin-grafana-backend",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Backend for the Grafana Backstage plugin: reads dashboards and alerts from Grafana, caches them, and exposes a read-only REST API",
|
|
5
|
+
"main": "./dist/index.cjs.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"license": "Apache-2.0",
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public"
|
|
10
|
+
},
|
|
11
|
+
"backstage": {
|
|
12
|
+
"role": "backend-plugin",
|
|
13
|
+
"pluginId": "grafana",
|
|
14
|
+
"pluginPackages": [
|
|
15
|
+
"@marble-sh/backstage-plugin-grafana",
|
|
16
|
+
"@marble-sh/backstage-plugin-grafana-backend",
|
|
17
|
+
"@marble-sh/backstage-plugin-grafana-common",
|
|
18
|
+
"@marble-sh/backstage-plugin-grafana-node"
|
|
19
|
+
],
|
|
20
|
+
"features": {
|
|
21
|
+
".": "@backstage/BackendFeature"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"sideEffects": false,
|
|
25
|
+
"configSchema": "config.schema.json",
|
|
26
|
+
"scripts": {
|
|
27
|
+
"start": "backstage-cli package start",
|
|
28
|
+
"build": "backstage-cli package build",
|
|
29
|
+
"clean": "backstage-cli package clean",
|
|
30
|
+
"lint": "backstage-cli package lint",
|
|
31
|
+
"prepack": "backstage-cli package prepack",
|
|
32
|
+
"postpack": "backstage-cli package postpack",
|
|
33
|
+
"test": "backstage-cli package test"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@backstage/backend-plugin-api": "backstage:^",
|
|
37
|
+
"@backstage/config": "backstage:^",
|
|
38
|
+
"@backstage/errors": "backstage:^",
|
|
39
|
+
"@backstage/types": "backstage:^",
|
|
40
|
+
"@marble-sh/backstage-plugin-grafana-common": "workspace:^",
|
|
41
|
+
"@marble-sh/backstage-plugin-grafana-node": "workspace:^",
|
|
42
|
+
"express": "^4.17.1",
|
|
43
|
+
"express-promise-router": "^4.1.0",
|
|
44
|
+
"knex": "^3.0.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@backstage/backend-test-utils": "backstage:^",
|
|
48
|
+
"@backstage/cli": "backstage:^",
|
|
49
|
+
"@types/express": "^4.17.6",
|
|
50
|
+
"@types/supertest": "^2.0.12",
|
|
51
|
+
"supertest": "^6.2.4"
|
|
52
|
+
},
|
|
53
|
+
"files": [
|
|
54
|
+
"dist",
|
|
55
|
+
"config.schema.json",
|
|
56
|
+
"migrations/**/*.{js,d.ts}"
|
|
57
|
+
],
|
|
58
|
+
"repository": {
|
|
59
|
+
"type": "git",
|
|
60
|
+
"url": "https://github.com/marble-sh/backstage-plugins-grafana",
|
|
61
|
+
"directory": "plugins/grafana-backend"
|
|
62
|
+
},
|
|
63
|
+
"keywords": [
|
|
64
|
+
"backstage",
|
|
65
|
+
"plugin",
|
|
66
|
+
"grafana"
|
|
67
|
+
],
|
|
68
|
+
"author": "Cassidy Marble",
|
|
69
|
+
"homepage": "https://github.com/marble-sh/backstage-plugins-grafana/tree/main/plugins/grafana-backend",
|
|
70
|
+
"bugs": "https://github.com/marble-sh/backstage-plugins-grafana/issues",
|
|
71
|
+
"exports": {
|
|
72
|
+
".": {
|
|
73
|
+
"backstage": "@backstage/BackendFeature",
|
|
74
|
+
"require": "./dist/index.cjs.js",
|
|
75
|
+
"types": "./dist/index.d.ts",
|
|
76
|
+
"default": "./dist/index.cjs.js"
|
|
77
|
+
},
|
|
78
|
+
"./package.json": "./package.json"
|
|
79
|
+
},
|
|
80
|
+
"typesVersions": {
|
|
81
|
+
"*": {
|
|
82
|
+
"package.json": [
|
|
83
|
+
"package.json"
|
|
84
|
+
]
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|