@marble-sh/backstage-plugin-catalog-backend-module-grafana 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 ADDED
@@ -0,0 +1,151 @@
1
+ # @marble-sh/backstage-plugin-catalog-backend-module-grafana
2
+
3
+ A catalog backend module that **auto-discovers Grafana instances and dashboards
4
+ as catalog entities**, so your Grafana estate shows up in the Software Catalog
5
+ and can participate in relations.
6
+
7
+ It registers an `EntityProvider` that periodically reads every configured
8
+ Grafana instance and emits:
9
+
10
+ - one **`Resource`** (`spec.type: grafana-instance`) per Grafana instance, and
11
+ - one **`Resource`** (`spec.type: grafana-dashboard`) per discovered dashboard,
12
+
13
+ with each dashboard declaring `spec.dependsOn` on its instance Resource. That
14
+ produces `dependsOn` / `dependencyOf` relations, so an instance's entity page
15
+ lists all of its dashboards, and each dashboard links back to its instance.
16
+
17
+ Every generated entity carries the required
18
+ `backstage.io/managed-by-location` and
19
+ `backstage.io/managed-by-origin-location` annotations and a `grafana/instance`
20
+ annotation (dashboards also get a `grafana/dashboard-selector`), so the
21
+ [frontend plugin](../grafana/README.md) tabs light up on the generated entities
22
+ automatically.
23
+
24
+ Discovery is resilient to outages: when reading an instance fails, its
25
+ previously discovered entities are re-emitted (a full refresh replaces the
26
+ provider's entire entity set, so skipping the instance would delete them); if
27
+ an instance fails before any successful read, the refresh is aborted and
28
+ retried on the next scheduled run, leaving the catalog untouched. Entity names
29
+ are sanitized to satisfy catalog validation — names that would exceed 63
30
+ characters, and dashboard uids containing uppercase characters, get a short
31
+ stable hash appended to stay unique.
32
+
33
+ ## Installation
34
+
35
+ ```sh
36
+ yarn --cwd packages/backend add @marble-sh/backstage-plugin-catalog-backend-module-grafana
37
+ ```
38
+
39
+ ```ts
40
+ // packages/backend/src/index.ts
41
+ backend.add(import('@backstage/plugin-catalog-backend'));
42
+ backend.add(
43
+ import('@marble-sh/backstage-plugin-catalog-backend-module-grafana'),
44
+ );
45
+ ```
46
+
47
+ ## Configuration
48
+
49
+ Connection details are read from the shared `grafana.instances` config (the
50
+ same block the [backend plugin](../grafana-backend/README.md) uses). Discovery
51
+ behavior is configured under `grafana.catalog`:
52
+
53
+ ```yaml
54
+ grafana:
55
+ instances:
56
+ - name: production
57
+ baseUrl: https://grafana.internal.example.com
58
+ token: ${GRAFANA_PROD_TOKEN}
59
+
60
+ catalog:
61
+ # How often discovery runs (defaults to every 30 minutes / 3-minute timeout).
62
+ schedule:
63
+ frequency: { minutes: 30 }
64
+ timeout: { minutes: 3 }
65
+ # Owner assigned to every generated entity (defaults to group:default/grafana).
66
+ defaultOwner: group:default/observability
67
+ # Optional system for every generated entity.
68
+ system: observability
69
+ # Catalog namespace for the generated entities (defaults to "default").
70
+ namespace: monitoring
71
+ # Only discover these instances (defaults to all under grafana.instances).
72
+ instances: [production]
73
+ # Only ingest a subset of dashboards (defaults to everything).
74
+ filter:
75
+ tags: [team-a]
76
+ query: payments, checkout
77
+ # Toggle what is emitted (all default to true).
78
+ emitInstances: true
79
+ emitDashboards: true
80
+ emitTags: true
81
+ ```
82
+
83
+ Every option and its states:
84
+
85
+ | Option | Unset / default | When set |
86
+ | ---------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
87
+ | `schedule` | Every 30 minutes, 3-minute timeout. | Discovery runs on your schedule. |
88
+ | `defaultOwner` | `group:default/grafana`. | Every generated entity gets this `spec.owner`. |
89
+ | `system` | No `spec.system` on generated entities. | Every generated entity gets this `spec.system`. |
90
+ | `namespace` | Entities live in the `default` namespace. | Entities — and the `dependsOn` references between them — use this namespace. |
91
+ | `instances` | Every instance under `grafana.instances`. | Only the listed instances are discovered. A name that doesn't exist under `grafana.instances` fails startup with a configuration error. |
92
+ | `filter.tags` | No tag filtering. | Only dashboards carrying **all** listed tags are ingested. |
93
+ | `filter.query` | No title filtering. | Only dashboards whose title contains **any** of the comma-separated values (case-insensitive) are ingested. |
94
+ | `emitInstances` | `true`: one `Resource` per instance. | `false`: no instance entities, and dashboard entities carry no `dependsOn` (there is nothing to point at). |
95
+ | `emitDashboards` | `true`: one `Resource` per dashboard. | `false`: no dashboard entities, and Grafana is not queried for dashboards during discovery at all. |
96
+ | `emitTags` | `true`: dashboard tags become entity tags. | `false`: generated entities carry no tags (`filter.tags` still works — it filters what is ingested, not what the entities carry). |
97
+
98
+ An instance with `apis.dashboards: none` (see the
99
+ [backend README](../grafana-backend/README.md#api-selection)) contributes its
100
+ instance `Resource` but no dashboards.
101
+
102
+ See [`config.d.ts`](./config.d.ts) for the full, documented schema.
103
+
104
+ ## Generated entity shape
105
+
106
+ ```yaml
107
+ # Instance
108
+ apiVersion: backstage.io/v1alpha1
109
+ kind: Resource
110
+ metadata:
111
+ name: grafana-instance-production
112
+ title: Production Grafana
113
+ annotations:
114
+ backstage.io/managed-by-location: grafana:production
115
+ grafana/instance: production
116
+ links:
117
+ - url: https://grafana.internal.example.com
118
+ title: Open Grafana
119
+ spec:
120
+ type: grafana-instance
121
+ owner: group:default/observability
122
+ ---
123
+ # Dashboard
124
+ apiVersion: backstage.io/v1alpha1
125
+ kind: Resource
126
+ metadata:
127
+ name: grafana-dashboard-production-abc123
128
+ title: My Service
129
+ annotations:
130
+ backstage.io/managed-by-location: grafana:production
131
+ grafana/instance: production
132
+ grafana/dashboard-selector: My Service
133
+ links:
134
+ - url: https://grafana.internal.example.com/d/abc123/my-service
135
+ title: Open dashboard
136
+ spec:
137
+ type: grafana-dashboard
138
+ owner: group:default/observability
139
+ dependsOn:
140
+ - resource:default/grafana-instance-production
141
+ ```
142
+
143
+ ## Testing
144
+
145
+ ```sh
146
+ yarn workspace @marble-sh/backstage-plugin-catalog-backend-module-grafana test
147
+ ```
148
+
149
+ The entity-building logic is unit-tested in isolation; the provider is tested
150
+ with a fake task runner and a mocked catalog connection; and the module is
151
+ verified to register its provider via `startTestBackend`.
@@ -0,0 +1,180 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "type": "object",
4
+ "properties": {
5
+ "grafana": {
6
+ "type": "object",
7
+ "properties": {
8
+ "catalog": {
9
+ "type": "object",
10
+ "properties": {
11
+ "schedule": {
12
+ "type": "object",
13
+ "properties": {
14
+ "frequency": {
15
+ "anyOf": [
16
+ {
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": "Human friendly durations object."
45
+ },
46
+ {
47
+ "type": "object",
48
+ "properties": {
49
+ "cron": {
50
+ "type": "string"
51
+ }
52
+ },
53
+ "required": [
54
+ "cron"
55
+ ]
56
+ }
57
+ ]
58
+ },
59
+ "timeout": {
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
+ "initialDelay": {
90
+ "type": "object",
91
+ "properties": {
92
+ "years": {
93
+ "type": "number"
94
+ },
95
+ "months": {
96
+ "type": "number"
97
+ },
98
+ "weeks": {
99
+ "type": "number"
100
+ },
101
+ "days": {
102
+ "type": "number"
103
+ },
104
+ "hours": {
105
+ "type": "number"
106
+ },
107
+ "minutes": {
108
+ "type": "number"
109
+ },
110
+ "seconds": {
111
+ "type": "number"
112
+ },
113
+ "milliseconds": {
114
+ "type": "number"
115
+ }
116
+ },
117
+ "description": "Human friendly durations object."
118
+ }
119
+ },
120
+ "required": [
121
+ "frequency",
122
+ "timeout"
123
+ ],
124
+ "description": "How often discovery runs, and how long a single run may take. Defaults to every 30 minutes with a 3-minute timeout."
125
+ },
126
+ "defaultOwner": {
127
+ "type": "string",
128
+ "description": "The `spec.owner` assigned to every generated entity. Defaults to `group:default/grafana`. Set this to a team that owns the Grafana estate, for example `group:default/observability`."
129
+ },
130
+ "system": {
131
+ "type": "string",
132
+ "description": "An optional `spec.system` assigned to every generated entity, for example `observability`."
133
+ },
134
+ "namespace": {
135
+ "type": "string",
136
+ "description": "The catalog namespace the generated entities are created in.\n\n - unset (default): entities live in the `default` namespace. - set: entities (and the `dependsOn` references between them) use the given namespace."
137
+ },
138
+ "instances": {
139
+ "type": "array",
140
+ "items": {
141
+ "type": "string"
142
+ },
143
+ "description": "Which configured Grafana instances discovery reads.\n\n - unset (default): every instance under `grafana.instances`. - a list of instance names: only those instances are discovered. Startup fails with a configuration error if a listed name does not exist under `grafana.instances`."
144
+ },
145
+ "filter": {
146
+ "type": "object",
147
+ "properties": {
148
+ "tags": {
149
+ "type": "array",
150
+ "items": {
151
+ "type": "string"
152
+ },
153
+ "description": "Only discover dashboards carrying **all** of these tags. Unset (default) means no tag filtering."
154
+ },
155
+ "query": {
156
+ "type": "string",
157
+ "description": "Only discover dashboards whose title contains one of these comma-separated values (case-insensitive). Unset (default) means no title filtering."
158
+ }
159
+ },
160
+ "description": "Narrows which dashboards are ingested. Both conditions must hold when both are set. Instance entities are unaffected."
161
+ },
162
+ "emitInstances": {
163
+ "type": "boolean",
164
+ "description": "Whether to emit a `Resource` for each Grafana instance.\n\n - `true` (default): one `Resource` (`spec.type: grafana-instance`) per instance; dashboards declare `dependsOn` on it. - `false`: no instance entities, and dashboard entities carry no `dependsOn` (there is nothing to point at)."
165
+ },
166
+ "emitDashboards": {
167
+ "type": "boolean",
168
+ "description": "Whether to emit a `Resource` for each discovered dashboard.\n\n - `true` (default): one `Resource` (`spec.type: grafana-dashboard`) per dashboard that passes `filter`. - `false`: no dashboard entities, and Grafana is not queried for dashboards during discovery at all."
169
+ },
170
+ "emitTags": {
171
+ "type": "boolean",
172
+ "description": "Whether dashboard tags are copied onto the generated entities.\n\n - `true` (default): sanitized dashboard tags become `metadata.tags` on the dashboard entities. - `false`: generated entities carry no tags. Tag-based `filter` settings still work — this only affects the emitted entities."
173
+ }
174
+ },
175
+ "description": "Configuration for the Grafana catalog discovery module.\n\nThe module reads the instances from `grafana.instances` (shared with the Grafana backend plugin) and periodically emits a `Resource` entity for each Grafana instance and each dashboard it discovers, linking every dashboard to its instance via `spec.dependsOn`."
176
+ }
177
+ }
178
+ }
179
+ }
180
+ }
@@ -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
+ var buildEntities = require('./buildEntities.cjs.js');
6
+ var config = require('./config.cjs.js');
7
+
8
+ class GrafanaEntityProvider {
9
+ instances;
10
+ discovery;
11
+ logger;
12
+ taskRunner;
13
+ clientFactory;
14
+ connection;
15
+ // The most recent successfully built entities per instance. A full mutation
16
+ // replaces everything this provider ever emitted, so a transiently failing
17
+ // instance must not simply be skipped — that would delete its entities.
18
+ lastGoodEntities = /* @__PURE__ */ new Map();
19
+ /**
20
+ * Builds a provider from the root config and the logger/scheduler services.
21
+ *
22
+ * Throws if `grafana.catalog.instances` names an instance that is not
23
+ * configured under `grafana.instances`.
24
+ */
25
+ static fromConfig(rootConfig, options) {
26
+ const discovery = config.readGrafanaDiscoveryConfig(rootConfig);
27
+ let instances = backstagePluginGrafanaNode.readGrafanaInstances(rootConfig);
28
+ if (discovery.instances) {
29
+ const known = new Set(instances.map((instance) => instance.name));
30
+ const unknown = discovery.instances.filter((name) => !known.has(name));
31
+ if (unknown.length > 0) {
32
+ throw new errors.InputError(
33
+ `grafana.catalog.instances names unknown instance(s) '${unknown.join(
34
+ "', '"
35
+ )}'; configured instances are: ${[...known].join(", ")}`
36
+ );
37
+ }
38
+ const allowed = new Set(discovery.instances);
39
+ instances = instances.filter((instance) => allowed.has(instance.name));
40
+ }
41
+ return new GrafanaEntityProvider({
42
+ instances,
43
+ discovery,
44
+ logger: options.logger,
45
+ taskRunner: options.scheduler.createScheduledTaskRunner(
46
+ discovery.schedule
47
+ )
48
+ });
49
+ }
50
+ constructor(options) {
51
+ this.instances = options.instances;
52
+ this.discovery = options.discovery;
53
+ this.logger = options.logger;
54
+ this.taskRunner = options.taskRunner;
55
+ this.clientFactory = options.clientFactory ?? ((instance) => new backstagePluginGrafanaNode.GrafanaHttpClient({ instance }));
56
+ }
57
+ /** The unique, stable name identifying this provider's entity bucket. */
58
+ getProviderName() {
59
+ return "GrafanaEntityProvider";
60
+ }
61
+ /** Stores the connection and schedules the recurring discovery task. */
62
+ async connect(connection) {
63
+ this.connection = connection;
64
+ await this.taskRunner.run({
65
+ id: `${this.getProviderName()}:refresh`,
66
+ fn: async () => {
67
+ await this.refresh();
68
+ }
69
+ });
70
+ }
71
+ /**
72
+ * Reads every configured instance and replaces the provider's entities.
73
+ *
74
+ * When an instance fails transiently, its previously discovered entities are
75
+ * re-emitted so the full mutation does not remove them from the catalog. If
76
+ * an instance fails before any successful read, the whole refresh is aborted
77
+ * (leaving the catalog untouched) and retried on the next scheduled run.
78
+ */
79
+ async refresh() {
80
+ if (!this.connection) {
81
+ throw new Error("GrafanaEntityProvider is not connected");
82
+ }
83
+ const entities = [];
84
+ for (const instance of this.instances) {
85
+ let built;
86
+ try {
87
+ const client = this.clientFactory(instance);
88
+ const dashboards = this.discovery.emitDashboards ? await client.listDashboards(this.discovery.filter) : [];
89
+ built = buildEntities.buildGrafanaEntities(instance, dashboards, this.discovery);
90
+ this.lastGoodEntities.set(instance.name, built);
91
+ } catch (error) {
92
+ const previous = this.lastGoodEntities.get(instance.name);
93
+ if (!previous) {
94
+ this.logger.error(
95
+ `Failed to read Grafana instance '${instance.name}' for catalog discovery and no previous result is available; aborting this refresh`,
96
+ error
97
+ );
98
+ throw error;
99
+ }
100
+ this.logger.warn(
101
+ `Failed to read Grafana instance '${instance.name}' for catalog discovery; keeping its ${previous.length} previously discovered entities`,
102
+ error
103
+ );
104
+ built = previous;
105
+ }
106
+ entities.push(
107
+ ...built.map((entity) => ({
108
+ entity,
109
+ locationKey: `grafana:${instance.name}`
110
+ }))
111
+ );
112
+ }
113
+ await this.connection.applyMutation({ type: "full", entities });
114
+ this.logger.info(
115
+ `Grafana catalog discovery emitted ${entities.length} entities`
116
+ );
117
+ }
118
+ }
119
+
120
+ exports.GrafanaEntityProvider = GrafanaEntityProvider;
121
+ //# sourceMappingURL=GrafanaEntityProvider.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"GrafanaEntityProvider.cjs.js","sources":["../src/GrafanaEntityProvider.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 LoggerService,\n SchedulerService,\n SchedulerServiceTaskRunner,\n} from '@backstage/backend-plugin-api';\nimport { Config } from '@backstage/config';\nimport { InputError } from '@backstage/errors';\nimport { ResourceEntity } from '@backstage/catalog-model';\nimport {\n DeferredEntity,\n EntityProvider,\n EntityProviderConnection,\n} from '@backstage/plugin-catalog-node';\nimport {\n GrafanaClient,\n GrafanaHttpClient,\n GrafanaInstanceConfig,\n readGrafanaInstances,\n} from '@marble-sh/backstage-plugin-grafana-node';\nimport { buildGrafanaEntities } from './buildEntities';\nimport { GrafanaDiscoveryConfig, readGrafanaDiscoveryConfig } from './config';\n\n/**\n * A catalog `EntityProvider` that discovers Grafana instances and their\n * dashboards, emitting a `Resource` for each and linking every dashboard to its\n * instance via `spec.dependsOn`.\n *\n * @public\n */\nexport class GrafanaEntityProvider implements EntityProvider {\n private readonly instances: GrafanaInstanceConfig[];\n private readonly discovery: GrafanaDiscoveryConfig;\n private readonly logger: LoggerService;\n private readonly taskRunner: SchedulerServiceTaskRunner;\n private readonly clientFactory: (\n instance: GrafanaInstanceConfig,\n ) => GrafanaClient;\n private connection?: EntityProviderConnection;\n // The most recent successfully built entities per instance. A full mutation\n // replaces everything this provider ever emitted, so a transiently failing\n // instance must not simply be skipped — that would delete its entities.\n private readonly lastGoodEntities = new Map<string, ResourceEntity[]>();\n\n /**\n * Builds a provider from the root config and the logger/scheduler services.\n *\n * Throws if `grafana.catalog.instances` names an instance that is not\n * configured under `grafana.instances`.\n */\n static fromConfig(\n rootConfig: Config,\n options: { logger: LoggerService; scheduler: SchedulerService },\n ): GrafanaEntityProvider {\n const discovery = readGrafanaDiscoveryConfig(rootConfig);\n let instances = readGrafanaInstances(rootConfig);\n\n if (discovery.instances) {\n const known = new Set(instances.map(instance => instance.name));\n const unknown = discovery.instances.filter(name => !known.has(name));\n if (unknown.length > 0) {\n throw new InputError(\n `grafana.catalog.instances names unknown instance(s) '${unknown.join(\n \"', '\",\n )}'; configured instances are: ${[...known].join(', ')}`,\n );\n }\n const allowed = new Set(discovery.instances);\n instances = instances.filter(instance => allowed.has(instance.name));\n }\n\n return new GrafanaEntityProvider({\n instances,\n discovery,\n logger: options.logger,\n taskRunner: options.scheduler.createScheduledTaskRunner(\n discovery.schedule,\n ),\n });\n }\n\n constructor(options: {\n instances: GrafanaInstanceConfig[];\n discovery: GrafanaDiscoveryConfig;\n logger: LoggerService;\n taskRunner: SchedulerServiceTaskRunner;\n clientFactory?: (instance: GrafanaInstanceConfig) => GrafanaClient;\n }) {\n this.instances = options.instances;\n this.discovery = options.discovery;\n this.logger = options.logger;\n this.taskRunner = options.taskRunner;\n this.clientFactory =\n options.clientFactory ??\n (instance => new GrafanaHttpClient({ instance }));\n }\n\n /** The unique, stable name identifying this provider's entity bucket. */\n getProviderName(): string {\n return 'GrafanaEntityProvider';\n }\n\n /** Stores the connection and schedules the recurring discovery task. */\n async connect(connection: EntityProviderConnection): Promise<void> {\n this.connection = connection;\n await this.taskRunner.run({\n id: `${this.getProviderName()}:refresh`,\n fn: async () => {\n await this.refresh();\n },\n });\n }\n\n /**\n * Reads every configured instance and replaces the provider's entities.\n *\n * When an instance fails transiently, its previously discovered entities are\n * re-emitted so the full mutation does not remove them from the catalog. If\n * an instance fails before any successful read, the whole refresh is aborted\n * (leaving the catalog untouched) and retried on the next scheduled run.\n */\n async refresh(): Promise<void> {\n if (!this.connection) {\n throw new Error('GrafanaEntityProvider is not connected');\n }\n\n const entities: DeferredEntity[] = [];\n for (const instance of this.instances) {\n let built: ResourceEntity[];\n try {\n const client = this.clientFactory(instance);\n const dashboards = this.discovery.emitDashboards\n ? await client.listDashboards(this.discovery.filter)\n : [];\n built = buildGrafanaEntities(instance, dashboards, this.discovery);\n this.lastGoodEntities.set(instance.name, built);\n } catch (error) {\n const previous = this.lastGoodEntities.get(instance.name);\n if (!previous) {\n this.logger.error(\n `Failed to read Grafana instance '${instance.name}' for catalog discovery and no previous result is available; aborting this refresh`,\n error as Error,\n );\n throw error;\n }\n this.logger.warn(\n `Failed to read Grafana instance '${instance.name}' for catalog discovery; keeping its ${previous.length} previously discovered entities`,\n error as Error,\n );\n built = previous;\n }\n entities.push(\n ...built.map(entity => ({\n entity,\n locationKey: `grafana:${instance.name}`,\n })),\n );\n }\n\n await this.connection.applyMutation({ type: 'full', entities });\n this.logger.info(\n `Grafana catalog discovery emitted ${entities.length} entities`,\n );\n }\n}\n"],"names":["readGrafanaDiscoveryConfig","readGrafanaInstances","InputError","GrafanaHttpClient","buildGrafanaEntities"],"mappings":"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"}
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+
3
+ var crypto = require('crypto');
4
+ var catalogModel = require('@backstage/catalog-model');
5
+ var backstagePluginGrafanaCommon = require('@marble-sh/backstage-plugin-grafana-common');
6
+
7
+ const RESOURCE_TYPE_INSTANCE = "grafana-instance";
8
+ const RESOURCE_TYPE_DASHBOARD = "grafana-dashboard";
9
+ const shortHash = (value) => crypto.createHash("sha256").update(value).digest("hex").slice(0, 8);
10
+ const sanitizeName = (value) => {
11
+ const sanitized = value.toLocaleLowerCase("en-US").replace(/[^a-z0-9\-_.]+/g, "-").replace(/^[^a-z0-9]+/, "").replace(/[^a-z0-9]+$/, "");
12
+ if (sanitized.length <= 63) {
13
+ return sanitized;
14
+ }
15
+ const head = sanitized.slice(0, 54).replace(/[^a-z0-9]+$/, "");
16
+ return `${head}-${shortHash(value)}`;
17
+ };
18
+ const sanitizeTags = (tags) => {
19
+ const seen = /* @__PURE__ */ new Set();
20
+ for (const tag of tags) {
21
+ const sanitized = tag.toLocaleLowerCase("en-US").replace(/[^a-z0-9:+#]+/g, "-").replace(/^-+/, "").replace(/-+$/, "").slice(0, 63);
22
+ if (sanitized) {
23
+ seen.add(sanitized);
24
+ }
25
+ }
26
+ return [...seen];
27
+ };
28
+ const instanceEntityName = (instance) => sanitizeName(`grafana-instance-${instance.name}`);
29
+ const dashboardEntityName = (instance, dashboard) => {
30
+ const caseSafeUid = dashboard.uid === dashboard.uid.toLocaleLowerCase("en-US") ? dashboard.uid : `${dashboard.uid}-${shortHash(dashboard.uid)}`;
31
+ return sanitizeName(`grafana-dashboard-${instance.name}-${caseSafeUid}`);
32
+ };
33
+ function buildGrafanaEntities(instance, dashboards, options) {
34
+ const locationRef = `grafana:${instance.name}`;
35
+ const commonAnnotations = {
36
+ [catalogModel.ANNOTATION_LOCATION]: locationRef,
37
+ [catalogModel.ANNOTATION_ORIGIN_LOCATION]: locationRef,
38
+ [backstagePluginGrafanaCommon.GRAFANA_ANNOTATION_INSTANCE]: instance.name
39
+ };
40
+ const systemSpec = options.system ? { system: options.system } : {};
41
+ const entities = [];
42
+ if (options.emitInstances) {
43
+ entities.push({
44
+ apiVersion: "backstage.io/v1alpha1",
45
+ kind: "Resource",
46
+ metadata: {
47
+ name: instanceEntityName(instance),
48
+ namespace: options.namespace,
49
+ title: instance.title,
50
+ annotations: { ...commonAnnotations },
51
+ links: [{ url: instance.baseUrl, title: "Open Grafana" }]
52
+ },
53
+ spec: {
54
+ type: RESOURCE_TYPE_INSTANCE,
55
+ owner: options.defaultOwner,
56
+ ...systemSpec
57
+ }
58
+ });
59
+ }
60
+ if (options.emitDashboards) {
61
+ for (const dashboard of dashboards) {
62
+ const tags = options.emitTags ? sanitizeTags(dashboard.tags) : [];
63
+ entities.push({
64
+ apiVersion: "backstage.io/v1alpha1",
65
+ kind: "Resource",
66
+ metadata: {
67
+ name: dashboardEntityName(instance, dashboard),
68
+ namespace: options.namespace,
69
+ title: dashboard.title,
70
+ annotations: {
71
+ ...commonAnnotations,
72
+ [backstagePluginGrafanaCommon.GRAFANA_ANNOTATION_DASHBOARD_SELECTOR]: dashboard.title
73
+ },
74
+ links: [{ url: dashboard.url, title: "Open dashboard" }],
75
+ ...tags.length ? { tags } : {}
76
+ },
77
+ spec: {
78
+ type: RESOURCE_TYPE_DASHBOARD,
79
+ owner: options.defaultOwner,
80
+ ...options.emitInstances ? {
81
+ dependsOn: [
82
+ `resource:${options.namespace}/${instanceEntityName(
83
+ instance
84
+ )}`
85
+ ]
86
+ } : {},
87
+ ...systemSpec
88
+ }
89
+ });
90
+ }
91
+ }
92
+ return entities;
93
+ }
94
+
95
+ exports.buildGrafanaEntities = buildGrafanaEntities;
96
+ //# sourceMappingURL=buildEntities.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"buildEntities.cjs.js","sources":["../src/buildEntities.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 { createHash } from 'crypto';\nimport {\n ANNOTATION_LOCATION,\n ANNOTATION_ORIGIN_LOCATION,\n ResourceEntity,\n} from '@backstage/catalog-model';\nimport {\n GRAFANA_ANNOTATION_DASHBOARD_SELECTOR,\n GRAFANA_ANNOTATION_INSTANCE,\n GrafanaDashboard,\n} from '@marble-sh/backstage-plugin-grafana-common';\nimport { GrafanaInstanceConfig } from '@marble-sh/backstage-plugin-grafana-node';\n\n/**\n * Options controlling how Grafana data is turned into catalog entities.\n *\n * @public\n */\nexport type GrafanaEntityOptions = {\n /** The `spec.owner` assigned to every generated entity. */\n defaultOwner: string;\n /** An optional `spec.system` assigned to every generated entity. */\n system?: string;\n /** The catalog namespace the generated entities live in. */\n namespace: string;\n /** Whether to emit a Resource for the Grafana instance. */\n emitInstances: boolean;\n /** Whether to emit a Resource for each dashboard. */\n emitDashboards: boolean;\n /** Whether dashboard tags are copied onto the generated entities. */\n emitTags: boolean;\n};\n\nconst RESOURCE_TYPE_INSTANCE = 'grafana-instance';\nconst RESOURCE_TYPE_DASHBOARD = 'grafana-dashboard';\n\nconst shortHash = (value: string): string =>\n createHash('sha256').update(value).digest('hex').slice(0, 8);\n\nconst sanitizeName = (value: string): string => {\n const sanitized = value\n .toLocaleLowerCase('en-US')\n .replace(/[^a-z0-9\\-_.]+/g, '-')\n .replace(/^[^a-z0-9]+/, '')\n .replace(/[^a-z0-9]+$/, '');\n if (sanitized.length <= 63) {\n return sanitized;\n }\n // Entity names are capped at 63 characters and must end alphanumeric. A\n // plain cut could end on a separator and could collide with another long\n // name, so truncated names get a stable hash of the full value appended.\n const head = sanitized.slice(0, 54).replace(/[^a-z0-9]+$/, '');\n return `${head}-${shortHash(value)}`;\n};\n\nconst sanitizeTags = (tags: string[]): string[] => {\n const seen = new Set<string>();\n for (const tag of tags) {\n const sanitized = tag\n .toLocaleLowerCase('en-US')\n .replace(/[^a-z0-9:+#]+/g, '-')\n .replace(/^-+/, '')\n .replace(/-+$/, '')\n .slice(0, 63);\n if (sanitized) {\n seen.add(sanitized);\n }\n }\n return [...seen];\n};\n\nconst instanceEntityName = (instance: GrafanaInstanceConfig): string =>\n sanitizeName(`grafana-instance-${instance.name}`);\n\nconst dashboardEntityName = (\n instance: GrafanaInstanceConfig,\n dashboard: GrafanaDashboard,\n): string => {\n // Grafana uids are case-sensitive, but entity names are lowercased. When a\n // uid contains uppercase characters, a stable hash of the original uid is\n // appended so that uids differing only in case cannot collide.\n const caseSafeUid =\n dashboard.uid === dashboard.uid.toLocaleLowerCase('en-US')\n ? dashboard.uid\n : `${dashboard.uid}-${shortHash(dashboard.uid)}`;\n return sanitizeName(`grafana-dashboard-${instance.name}-${caseSafeUid}`);\n};\n\n/**\n * Builds the catalog entities for a single Grafana instance and its dashboards.\n *\n * Every entity carries `backstage.io/managed-by-location` and\n * `backstage.io/managed-by-origin-location` annotations so the catalog accepts\n * them. Dashboards declare a `spec.dependsOn` on their instance Resource, which\n * produces `dependsOn`/`dependencyOf` relations between them.\n *\n * @public\n */\nexport function buildGrafanaEntities(\n instance: GrafanaInstanceConfig,\n dashboards: GrafanaDashboard[],\n options: GrafanaEntityOptions,\n): ResourceEntity[] {\n const locationRef = `grafana:${instance.name}`;\n const commonAnnotations: Record<string, string> = {\n [ANNOTATION_LOCATION]: locationRef,\n [ANNOTATION_ORIGIN_LOCATION]: locationRef,\n [GRAFANA_ANNOTATION_INSTANCE]: instance.name,\n };\n const systemSpec = options.system ? { system: options.system } : {};\n\n const entities: ResourceEntity[] = [];\n\n if (options.emitInstances) {\n entities.push({\n apiVersion: 'backstage.io/v1alpha1',\n kind: 'Resource',\n metadata: {\n name: instanceEntityName(instance),\n namespace: options.namespace,\n title: instance.title,\n annotations: { ...commonAnnotations },\n links: [{ url: instance.baseUrl, title: 'Open Grafana' }],\n },\n spec: {\n type: RESOURCE_TYPE_INSTANCE,\n owner: options.defaultOwner,\n ...systemSpec,\n },\n });\n }\n\n if (options.emitDashboards) {\n for (const dashboard of dashboards) {\n const tags = options.emitTags ? sanitizeTags(dashboard.tags) : [];\n entities.push({\n apiVersion: 'backstage.io/v1alpha1',\n kind: 'Resource',\n metadata: {\n name: dashboardEntityName(instance, dashboard),\n namespace: options.namespace,\n title: dashboard.title,\n annotations: {\n ...commonAnnotations,\n [GRAFANA_ANNOTATION_DASHBOARD_SELECTOR]: dashboard.title,\n },\n links: [{ url: dashboard.url, title: 'Open dashboard' }],\n ...(tags.length ? { tags } : {}),\n },\n spec: {\n type: RESOURCE_TYPE_DASHBOARD,\n owner: options.defaultOwner,\n ...(options.emitInstances\n ? {\n dependsOn: [\n `resource:${options.namespace}/${instanceEntityName(\n instance,\n )}`,\n ],\n }\n : {}),\n ...systemSpec,\n },\n });\n }\n }\n\n return entities;\n}\n"],"names":["createHash","ANNOTATION_LOCATION","ANNOTATION_ORIGIN_LOCATION","GRAFANA_ANNOTATION_INSTANCE","GRAFANA_ANNOTATION_DASHBOARD_SELECTOR"],"mappings":"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"}
@@ -0,0 +1,30 @@
1
+ 'use strict';
2
+
3
+ var backendPluginApi = require('@backstage/backend-plugin-api');
4
+
5
+ const DEFAULT_SCHEDULE = {
6
+ frequency: { minutes: 30 },
7
+ timeout: { minutes: 3 }
8
+ };
9
+ const DEFAULT_OWNER = "group:default/grafana";
10
+ function readGrafanaDiscoveryConfig(rootConfig) {
11
+ const config = rootConfig.getOptionalConfig("grafana.catalog");
12
+ const scheduleConfig = config?.getOptionalConfig("schedule");
13
+ return {
14
+ schedule: scheduleConfig ? backendPluginApi.readSchedulerServiceTaskScheduleDefinitionFromConfig(scheduleConfig) : DEFAULT_SCHEDULE,
15
+ defaultOwner: config?.getOptionalString("defaultOwner") ?? DEFAULT_OWNER,
16
+ system: config?.getOptionalString("system"),
17
+ namespace: config?.getOptionalString("namespace") ?? "default",
18
+ emitInstances: config?.getOptionalBoolean("emitInstances") ?? true,
19
+ emitDashboards: config?.getOptionalBoolean("emitDashboards") ?? true,
20
+ emitTags: config?.getOptionalBoolean("emitTags") ?? true,
21
+ instances: config?.getOptionalStringArray("instances"),
22
+ filter: {
23
+ tags: config?.getOptionalStringArray("filter.tags"),
24
+ query: config?.getOptionalString("filter.query")
25
+ }
26
+ };
27
+ }
28
+
29
+ exports.readGrafanaDiscoveryConfig = readGrafanaDiscoveryConfig;
30
+ //# sourceMappingURL=config.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.cjs.js","sources":["../src/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 { GrafanaEntityOptions } from './buildEntities';\n\n/**\n * Filters narrowing which dashboards discovery ingests.\n *\n * @public\n */\nexport type GrafanaDiscoveryFilter = {\n /** Only discover dashboards carrying all of these tags. */\n tags?: string[];\n /** Comma-separated title substrings; dashboards matching any are discovered. */\n query?: string;\n};\n\n/**\n * The fully-resolved configuration for the Grafana catalog discovery module.\n *\n * @public\n */\nexport type GrafanaDiscoveryConfig = GrafanaEntityOptions & {\n schedule: SchedulerServiceTaskScheduleDefinition;\n /** Instance names to discover; `undefined` means every configured instance. */\n instances?: string[];\n /** Dashboard filter applied during discovery. */\n filter: GrafanaDiscoveryFilter;\n};\n\nconst DEFAULT_SCHEDULE: SchedulerServiceTaskScheduleDefinition = {\n frequency: { minutes: 30 },\n timeout: { minutes: 3 },\n};\n\nconst DEFAULT_OWNER = 'group:default/grafana';\n\n/**\n * Reads the `grafana.catalog` configuration into a resolved\n * {@link GrafanaDiscoveryConfig}, applying defaults.\n *\n * @public\n */\nexport function readGrafanaDiscoveryConfig(\n rootConfig: Config,\n): GrafanaDiscoveryConfig {\n const config = rootConfig.getOptionalConfig('grafana.catalog');\n\n const scheduleConfig = config?.getOptionalConfig('schedule');\n\n return {\n schedule: scheduleConfig\n ? readSchedulerServiceTaskScheduleDefinitionFromConfig(scheduleConfig)\n : DEFAULT_SCHEDULE,\n defaultOwner: config?.getOptionalString('defaultOwner') ?? DEFAULT_OWNER,\n system: config?.getOptionalString('system'),\n namespace: config?.getOptionalString('namespace') ?? 'default',\n emitInstances: config?.getOptionalBoolean('emitInstances') ?? true,\n emitDashboards: config?.getOptionalBoolean('emitDashboards') ?? true,\n emitTags: config?.getOptionalBoolean('emitTags') ?? true,\n instances: config?.getOptionalStringArray('instances'),\n filter: {\n tags: config?.getOptionalStringArray('filter.tags'),\n query: config?.getOptionalString('filter.query'),\n },\n };\n}\n"],"names":["readSchedulerServiceTaskScheduleDefinitionFromConfig"],"mappings":";;;;AAgDA,MAAM,gBAAA,GAA2D;AAAA,EAC/D,SAAA,EAAW,EAAE,OAAA,EAAS,EAAA,EAAG;AAAA,EACzB,OAAA,EAAS,EAAE,OAAA,EAAS,CAAA;AACtB,CAAA;AAEA,MAAM,aAAA,GAAgB,uBAAA;AAQf,SAAS,2BACd,UAAA,EACwB;AACxB,EAAA,MAAM,MAAA,GAAS,UAAA,CAAW,iBAAA,CAAkB,iBAAiB,CAAA;AAE7D,EAAA,MAAM,cAAA,GAAiB,MAAA,EAAQ,iBAAA,CAAkB,UAAU,CAAA;AAE3D,EAAA,OAAO;AAAA,IACL,QAAA,EAAU,cAAA,GACNA,qEAAA,CAAqD,cAAc,CAAA,GACnE,gBAAA;AAAA,IACJ,YAAA,EAAc,MAAA,EAAQ,iBAAA,CAAkB,cAAc,CAAA,IAAK,aAAA;AAAA,IAC3D,MAAA,EAAQ,MAAA,EAAQ,iBAAA,CAAkB,QAAQ,CAAA;AAAA,IAC1C,SAAA,EAAW,MAAA,EAAQ,iBAAA,CAAkB,WAAW,CAAA,IAAK,SAAA;AAAA,IACrD,aAAA,EAAe,MAAA,EAAQ,kBAAA,CAAmB,eAAe,CAAA,IAAK,IAAA;AAAA,IAC9D,cAAA,EAAgB,MAAA,EAAQ,kBAAA,CAAmB,gBAAgB,CAAA,IAAK,IAAA;AAAA,IAChE,QAAA,EAAU,MAAA,EAAQ,kBAAA,CAAmB,UAAU,CAAA,IAAK,IAAA;AAAA,IACpD,SAAA,EAAW,MAAA,EAAQ,sBAAA,CAAuB,WAAW,CAAA;AAAA,IACrD,MAAA,EAAQ;AAAA,MACN,IAAA,EAAM,MAAA,EAAQ,sBAAA,CAAuB,aAAa,CAAA;AAAA,MAClD,KAAA,EAAO,MAAA,EAAQ,iBAAA,CAAkB,cAAc;AAAA;AACjD,GACF;AACF;;"}
@@ -0,0 +1,16 @@
1
+ 'use strict';
2
+
3
+ Object.defineProperty(exports, '__esModule', { value: true });
4
+
5
+ var module$1 = require('./module.cjs.js');
6
+ var GrafanaEntityProvider = require('./GrafanaEntityProvider.cjs.js');
7
+ var buildEntities = require('./buildEntities.cjs.js');
8
+ var config = require('./config.cjs.js');
9
+
10
+
11
+
12
+ exports.default = module$1.catalogModuleGrafana;
13
+ exports.GrafanaEntityProvider = GrafanaEntityProvider.GrafanaEntityProvider;
14
+ exports.buildGrafanaEntities = buildEntities.buildGrafanaEntities;
15
+ exports.readGrafanaDiscoveryConfig = config.readGrafanaDiscoveryConfig;
16
+ //# sourceMappingURL=index.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.cjs.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;"}
@@ -0,0 +1,127 @@
1
+ import * as _backstage_backend_plugin_api from '@backstage/backend-plugin-api';
2
+ import { SchedulerServiceTaskScheduleDefinition, LoggerService, SchedulerService, SchedulerServiceTaskRunner } from '@backstage/backend-plugin-api';
3
+ import { Config } from '@backstage/config';
4
+ import { EntityProvider, EntityProviderConnection } from '@backstage/plugin-catalog-node';
5
+ import { GrafanaInstanceConfig, GrafanaClient } from '@marble-sh/backstage-plugin-grafana-node';
6
+ import { ResourceEntity } from '@backstage/catalog-model';
7
+ import { GrafanaDashboard } from '@marble-sh/backstage-plugin-grafana-common';
8
+
9
+ /**
10
+ * Catalog backend module that discovers Grafana instances and dashboards as
11
+ * catalog `Resource` entities.
12
+ *
13
+ * @public
14
+ */
15
+ declare const catalogModuleGrafana: _backstage_backend_plugin_api.BackendFeature;
16
+
17
+ /**
18
+ * Options controlling how Grafana data is turned into catalog entities.
19
+ *
20
+ * @public
21
+ */
22
+ type GrafanaEntityOptions = {
23
+ /** The `spec.owner` assigned to every generated entity. */
24
+ defaultOwner: string;
25
+ /** An optional `spec.system` assigned to every generated entity. */
26
+ system?: string;
27
+ /** The catalog namespace the generated entities live in. */
28
+ namespace: string;
29
+ /** Whether to emit a Resource for the Grafana instance. */
30
+ emitInstances: boolean;
31
+ /** Whether to emit a Resource for each dashboard. */
32
+ emitDashboards: boolean;
33
+ /** Whether dashboard tags are copied onto the generated entities. */
34
+ emitTags: boolean;
35
+ };
36
+ /**
37
+ * Builds the catalog entities for a single Grafana instance and its dashboards.
38
+ *
39
+ * Every entity carries `backstage.io/managed-by-location` and
40
+ * `backstage.io/managed-by-origin-location` annotations so the catalog accepts
41
+ * them. Dashboards declare a `spec.dependsOn` on their instance Resource, which
42
+ * produces `dependsOn`/`dependencyOf` relations between them.
43
+ *
44
+ * @public
45
+ */
46
+ declare function buildGrafanaEntities(instance: GrafanaInstanceConfig, dashboards: GrafanaDashboard[], options: GrafanaEntityOptions): ResourceEntity[];
47
+
48
+ /**
49
+ * Filters narrowing which dashboards discovery ingests.
50
+ *
51
+ * @public
52
+ */
53
+ type GrafanaDiscoveryFilter = {
54
+ /** Only discover dashboards carrying all of these tags. */
55
+ tags?: string[];
56
+ /** Comma-separated title substrings; dashboards matching any are discovered. */
57
+ query?: string;
58
+ };
59
+ /**
60
+ * The fully-resolved configuration for the Grafana catalog discovery module.
61
+ *
62
+ * @public
63
+ */
64
+ type GrafanaDiscoveryConfig = GrafanaEntityOptions & {
65
+ schedule: SchedulerServiceTaskScheduleDefinition;
66
+ /** Instance names to discover; `undefined` means every configured instance. */
67
+ instances?: string[];
68
+ /** Dashboard filter applied during discovery. */
69
+ filter: GrafanaDiscoveryFilter;
70
+ };
71
+ /**
72
+ * Reads the `grafana.catalog` configuration into a resolved
73
+ * {@link GrafanaDiscoveryConfig}, applying defaults.
74
+ *
75
+ * @public
76
+ */
77
+ declare function readGrafanaDiscoveryConfig(rootConfig: Config): GrafanaDiscoveryConfig;
78
+
79
+ /**
80
+ * A catalog `EntityProvider` that discovers Grafana instances and their
81
+ * dashboards, emitting a `Resource` for each and linking every dashboard to its
82
+ * instance via `spec.dependsOn`.
83
+ *
84
+ * @public
85
+ */
86
+ declare class GrafanaEntityProvider implements EntityProvider {
87
+ private readonly instances;
88
+ private readonly discovery;
89
+ private readonly logger;
90
+ private readonly taskRunner;
91
+ private readonly clientFactory;
92
+ private connection?;
93
+ private readonly lastGoodEntities;
94
+ /**
95
+ * Builds a provider from the root config and the logger/scheduler services.
96
+ *
97
+ * Throws if `grafana.catalog.instances` names an instance that is not
98
+ * configured under `grafana.instances`.
99
+ */
100
+ static fromConfig(rootConfig: Config, options: {
101
+ logger: LoggerService;
102
+ scheduler: SchedulerService;
103
+ }): GrafanaEntityProvider;
104
+ constructor(options: {
105
+ instances: GrafanaInstanceConfig[];
106
+ discovery: GrafanaDiscoveryConfig;
107
+ logger: LoggerService;
108
+ taskRunner: SchedulerServiceTaskRunner;
109
+ clientFactory?: (instance: GrafanaInstanceConfig) => GrafanaClient;
110
+ });
111
+ /** The unique, stable name identifying this provider's entity bucket. */
112
+ getProviderName(): string;
113
+ /** Stores the connection and schedules the recurring discovery task. */
114
+ connect(connection: EntityProviderConnection): Promise<void>;
115
+ /**
116
+ * Reads every configured instance and replaces the provider's entities.
117
+ *
118
+ * When an instance fails transiently, its previously discovered entities are
119
+ * re-emitted so the full mutation does not remove them from the catalog. If
120
+ * an instance fails before any successful read, the whole refresh is aborted
121
+ * (leaving the catalog untouched) and retried on the next scheduled run.
122
+ */
123
+ refresh(): Promise<void>;
124
+ }
125
+
126
+ export { GrafanaEntityProvider, buildGrafanaEntities, catalogModuleGrafana as default, readGrafanaDiscoveryConfig };
127
+ export type { GrafanaDiscoveryConfig, GrafanaDiscoveryFilter, GrafanaEntityOptions };
@@ -0,0 +1,28 @@
1
+ 'use strict';
2
+
3
+ var backendPluginApi = require('@backstage/backend-plugin-api');
4
+ var pluginCatalogNode = require('@backstage/plugin-catalog-node');
5
+ var GrafanaEntityProvider = require('./GrafanaEntityProvider.cjs.js');
6
+
7
+ const catalogModuleGrafana = backendPluginApi.createBackendModule({
8
+ pluginId: "catalog",
9
+ moduleId: "grafana",
10
+ register(env) {
11
+ env.registerInit({
12
+ deps: {
13
+ logger: backendPluginApi.coreServices.logger,
14
+ config: backendPluginApi.coreServices.rootConfig,
15
+ scheduler: backendPluginApi.coreServices.scheduler,
16
+ catalog: pluginCatalogNode.catalogProcessingExtensionPoint
17
+ },
18
+ async init({ logger, config, scheduler, catalog }) {
19
+ catalog.addEntityProvider(
20
+ GrafanaEntityProvider.GrafanaEntityProvider.fromConfig(config, { logger, scheduler })
21
+ );
22
+ }
23
+ });
24
+ }
25
+ });
26
+
27
+ exports.catalogModuleGrafana = catalogModuleGrafana;
28
+ //# sourceMappingURL=module.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"module.cjs.js","sources":["../src/module.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 createBackendModule,\n} from '@backstage/backend-plugin-api';\nimport { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node';\nimport { GrafanaEntityProvider } from './GrafanaEntityProvider';\n\n/**\n * Catalog backend module that discovers Grafana instances and dashboards as\n * catalog `Resource` entities.\n *\n * @public\n */\nexport const catalogModuleGrafana = createBackendModule({\n pluginId: 'catalog',\n moduleId: 'grafana',\n register(env) {\n env.registerInit({\n deps: {\n logger: coreServices.logger,\n config: coreServices.rootConfig,\n scheduler: coreServices.scheduler,\n catalog: catalogProcessingExtensionPoint,\n },\n async init({ logger, config, scheduler, catalog }) {\n catalog.addEntityProvider(\n GrafanaEntityProvider.fromConfig(config, { logger, scheduler }),\n );\n },\n });\n },\n});\n"],"names":["createBackendModule","coreServices","catalogProcessingExtensionPoint","GrafanaEntityProvider"],"mappings":";;;;;;AA6BO,MAAM,uBAAuBA,oCAAA,CAAoB;AAAA,EACtD,QAAA,EAAU,SAAA;AAAA,EACV,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,WAAWA,6BAAA,CAAa,SAAA;AAAA,QACxB,OAAA,EAASC;AAAA,OACX;AAAA,MACA,MAAM,IAAA,CAAK,EAAE,QAAQ,MAAA,EAAQ,SAAA,EAAW,SAAQ,EAAG;AACjD,QAAA,OAAA,CAAQ,iBAAA;AAAA,UACNC,4CAAsB,UAAA,CAAW,MAAA,EAAQ,EAAE,MAAA,EAAQ,WAAW;AAAA,SAChE;AAAA,MACF;AAAA,KACD,CAAA;AAAA,EACH;AACF,CAAC;;"}
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "@marble-sh/backstage-plugin-catalog-backend-module-grafana",
3
+ "version": "0.2.0",
4
+ "description": "Catalog backend module that discovers Grafana instances and dashboards as catalog entities",
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-module",
13
+ "pluginId": "catalog",
14
+ "pluginPackage": "@backstage/plugin-catalog-backend",
15
+ "features": {
16
+ ".": "@backstage/BackendFeature"
17
+ }
18
+ },
19
+ "sideEffects": false,
20
+ "configSchema": "config.schema.json",
21
+ "scripts": {
22
+ "start": "backstage-cli package start",
23
+ "build": "backstage-cli package build",
24
+ "clean": "backstage-cli package clean",
25
+ "lint": "backstage-cli package lint",
26
+ "prepack": "backstage-cli package prepack",
27
+ "postpack": "backstage-cli package postpack",
28
+ "test": "backstage-cli package test"
29
+ },
30
+ "dependencies": {
31
+ "@backstage/backend-plugin-api": "backstage:^",
32
+ "@backstage/catalog-model": "backstage:^",
33
+ "@backstage/config": "backstage:^",
34
+ "@backstage/errors": "backstage:^",
35
+ "@backstage/plugin-catalog-node": "backstage:^",
36
+ "@marble-sh/backstage-plugin-grafana-common": "workspace:^",
37
+ "@marble-sh/backstage-plugin-grafana-node": "workspace:^"
38
+ },
39
+ "devDependencies": {
40
+ "@backstage/backend-test-utils": "backstage:^",
41
+ "@backstage/cli": "backstage:^",
42
+ "@backstage/types": "backstage:^"
43
+ },
44
+ "files": [
45
+ "dist",
46
+ "config.schema.json"
47
+ ],
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "https://github.com/marble-sh/backstage-plugins-grafana",
51
+ "directory": "plugins/catalog-backend-module-grafana"
52
+ },
53
+ "keywords": [
54
+ "backstage",
55
+ "plugin",
56
+ "grafana",
57
+ "catalog"
58
+ ],
59
+ "author": "Cassidy Marble",
60
+ "homepage": "https://github.com/marble-sh/backstage-plugins-grafana/tree/main/plugins/catalog-backend-module-grafana",
61
+ "bugs": "https://github.com/marble-sh/backstage-plugins-grafana/issues",
62
+ "exports": {
63
+ ".": {
64
+ "backstage": "@backstage/BackendFeature",
65
+ "require": "./dist/index.cjs.js",
66
+ "types": "./dist/index.d.ts",
67
+ "default": "./dist/index.cjs.js"
68
+ },
69
+ "./package.json": "./package.json"
70
+ },
71
+ "typesVersions": {
72
+ "*": {
73
+ "package.json": [
74
+ "package.json"
75
+ ]
76
+ }
77
+ }
78
+ }