@intentius/chant-lexicon-grafana 0.99.0 → 0.100.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.
Files changed (224) hide show
  1. package/README.md +12 -6
  2. package/dist/annotations.d.ts +61 -0
  3. package/dist/annotations.d.ts.map +1 -0
  4. package/dist/api/apply.d.ts +8 -4
  5. package/dist/api/apply.d.ts.map +1 -1
  6. package/dist/api/fake-grafana.d.ts +2 -0
  7. package/dist/api/fake-grafana.d.ts.map +1 -1
  8. package/dist/api/folders.d.ts +53 -17
  9. package/dist/api/folders.d.ts.map +1 -1
  10. package/dist/build.d.ts +26 -5
  11. package/dist/build.d.ts.map +1 -1
  12. package/dist/catalog.d.ts +1 -1
  13. package/dist/catalog.d.ts.map +1 -1
  14. package/dist/codegen/docs.d.ts.map +1 -1
  15. package/dist/codegen/generate.d.ts +7 -1
  16. package/dist/codegen/generate.d.ts.map +1 -1
  17. package/dist/composites/shared.d.ts +4 -3
  18. package/dist/composites/shared.d.ts.map +1 -1
  19. package/dist/dashboard.d.ts +42 -14
  20. package/dist/dashboard.d.ts.map +1 -1
  21. package/dist/datasource-refs.d.ts +9 -5
  22. package/dist/datasource-refs.d.ts.map +1 -1
  23. package/dist/datasource-settings.d.ts +834 -0
  24. package/dist/datasource-settings.d.ts.map +1 -0
  25. package/dist/datasource.d.ts +53 -11
  26. package/dist/datasource.d.ts.map +1 -1
  27. package/dist/deep-observe-hooks.d.ts +1 -1
  28. package/dist/deep-observe-hooks.d.ts.map +1 -1
  29. package/dist/deep-observe.d.ts +9 -4
  30. package/dist/deep-observe.d.ts.map +1 -1
  31. package/dist/describe-resources.d.ts +1 -0
  32. package/dist/describe-resources.d.ts.map +1 -1
  33. package/dist/detect.d.ts +4 -2
  34. package/dist/detect.d.ts.map +1 -1
  35. package/dist/folder.d.ts +68 -0
  36. package/dist/folder.d.ts.map +1 -0
  37. package/dist/import/generator.d.ts.map +1 -1
  38. package/dist/import/mappings.d.ts +12 -6
  39. package/dist/import/mappings.d.ts.map +1 -1
  40. package/dist/import/model.d.ts +7 -0
  41. package/dist/import/model.d.ts.map +1 -1
  42. package/dist/import/normalize.d.ts.map +1 -1
  43. package/dist/import/parser.d.ts +16 -1
  44. package/dist/import/parser.d.ts.map +1 -1
  45. package/dist/import/testdata/fixtures.d.ts +6 -0
  46. package/dist/import/testdata/fixtures.d.ts.map +1 -1
  47. package/dist/import/v2.d.ts +4 -3
  48. package/dist/import/v2.d.ts.map +1 -1
  49. package/dist/index.d.ts +11 -9
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/init-templates.d.ts +27 -0
  52. package/dist/init-templates.d.ts.map +1 -0
  53. package/dist/integrity.json +11 -9
  54. package/dist/lint/audit-catalog.d.ts +1 -1
  55. package/dist/lint/audit-catalog.d.ts.map +1 -1
  56. package/dist/lint/post-synth/graf101.d.ts +1 -1
  57. package/dist/lint/post-synth/graf104.d.ts +2 -2
  58. package/dist/lint/post-synth/graf106.d.ts +2 -2
  59. package/dist/lint/post-synth/graf110.d.ts +8 -0
  60. package/dist/lint/post-synth/graf110.d.ts.map +1 -0
  61. package/dist/lint/post-synth/grafana-helpers.d.ts.map +1 -1
  62. package/dist/lint/post-synth/index.d.ts.map +1 -1
  63. package/dist/manifest.json +1 -1
  64. package/dist/meta.json +70 -0
  65. package/dist/okf/index.md +18 -3
  66. package/dist/okf/rules/GRAF101.md +2 -2
  67. package/dist/okf/rules/GRAF104.md +2 -2
  68. package/dist/okf/rules/GRAF106.md +2 -2
  69. package/dist/okf/rules/GRAF110.md +11 -0
  70. package/dist/okf/types/AdhocVariable.md +9 -0
  71. package/dist/okf/types/AzureMonitorQuery.md +9 -0
  72. package/dist/okf/types/BigQueryQuery.md +9 -0
  73. package/dist/okf/types/CloudMonitoringQuery.md +9 -0
  74. package/dist/okf/types/CloudWatchQuery.md +9 -0
  75. package/dist/okf/types/Dashboard.md +1 -1
  76. package/dist/okf/types/Datasource.md +1 -1
  77. package/dist/okf/types/DatasourceProvisioning.md +9 -0
  78. package/dist/okf/types/ElasticsearchQuery.md +9 -0
  79. package/dist/okf/types/ExternalDatasource.md +1 -1
  80. package/dist/okf/types/Folder.md +9 -0
  81. package/dist/okf/types/GroupByVariable.md +9 -0
  82. package/dist/okf/types/MSSQLQuery.md +9 -0
  83. package/dist/okf/types/MySQLQuery.md +9 -0
  84. package/dist/okf/types/PostgresQuery.md +9 -0
  85. package/dist/okf/types/PyroscopeQuery.md +9 -0
  86. package/dist/okf/types/SwitchVariable.md +9 -0
  87. package/dist/op/activities/grafana-apply.d.ts +11 -0
  88. package/dist/op/activities/grafana-apply.d.ts.map +1 -1
  89. package/dist/op/activities/index.d.ts +1 -1
  90. package/dist/op/activities/index.d.ts.map +1 -1
  91. package/dist/ownership.d.ts +11 -0
  92. package/dist/ownership.d.ts.map +1 -1
  93. package/dist/pin.d.ts +1 -1
  94. package/dist/pin.d.ts.map +1 -1
  95. package/dist/plugin.d.ts.map +1 -1
  96. package/dist/promql-check.d.ts.map +1 -1
  97. package/dist/query-models.d.ts +95 -0
  98. package/dist/query-models.d.ts.map +1 -0
  99. package/dist/query.d.ts +107 -10
  100. package/dist/query.d.ts.map +1 -1
  101. package/dist/rules/graf101.ts +2 -2
  102. package/dist/rules/graf104.ts +3 -3
  103. package/dist/rules/graf106.ts +3 -3
  104. package/dist/rules/graf110.ts +17 -0
  105. package/dist/rules/grafana-helpers.ts +13 -3
  106. package/dist/schema/azuremonitor.gen.d.ts +354 -0
  107. package/dist/schema/azuremonitor.gen.d.ts.map +1 -0
  108. package/dist/schema/bigquery.gen.d.ts +93 -0
  109. package/dist/schema/bigquery.gen.d.ts.map +1 -0
  110. package/dist/schema/cloudwatch.gen.d.ts +394 -0
  111. package/dist/schema/cloudwatch.gen.d.ts.map +1 -0
  112. package/dist/schema/elasticsearch.gen.d.ts +444 -0
  113. package/dist/schema/elasticsearch.gen.d.ts.map +1 -0
  114. package/dist/schema/googlecloudmonitoring.gen.d.ts +312 -0
  115. package/dist/schema/googlecloudmonitoring.gen.d.ts.map +1 -0
  116. package/dist/schema/grafanapyroscope.gen.d.ts +60 -0
  117. package/dist/schema/grafanapyroscope.gen.d.ts.map +1 -0
  118. package/dist/schema/index.d.ts +6 -0
  119. package/dist/schema/index.d.ts.map +1 -1
  120. package/dist/schema/table.gen.d.ts +13 -0
  121. package/dist/schema/table.gen.d.ts.map +1 -1
  122. package/dist/schema-validate.d.ts +9 -0
  123. package/dist/schema-validate.d.ts.map +1 -1
  124. package/dist/skill-defs.d.ts.map +1 -1
  125. package/dist/skills/chant-grafana-operations.md +108 -0
  126. package/dist/skills/chant-grafana.md +5 -4
  127. package/dist/spec/schemas.d.ts +9 -7
  128. package/dist/spec/schemas.d.ts.map +1 -1
  129. package/dist/spec/schemas.gen.d.ts +3 -0
  130. package/dist/spec/schemas.gen.d.ts.map +1 -0
  131. package/dist/validate-alerting.d.ts.map +1 -1
  132. package/dist/validate-output.d.ts +15 -2
  133. package/dist/validate-output.d.ts.map +1 -1
  134. package/dist/validate.d.ts.map +1 -1
  135. package/dist/validation.d.ts +14 -0
  136. package/dist/validation.d.ts.map +1 -0
  137. package/dist/variables.d.ts +102 -5
  138. package/dist/variables.d.ts.map +1 -1
  139. package/package.json +2 -2
  140. package/src/annotations.ts +84 -0
  141. package/src/api/apply.test.ts +19 -1
  142. package/src/api/apply.ts +18 -8
  143. package/src/api/fake-grafana.ts +6 -2
  144. package/src/api/folders.ts +106 -32
  145. package/src/build.test.ts +142 -6
  146. package/src/build.ts +126 -24
  147. package/src/catalog.ts +20 -2
  148. package/src/codegen/docs.ts +13 -6
  149. package/src/codegen/generate.ts +27 -1
  150. package/src/composites/shared.ts +3 -2
  151. package/src/dashboard.ts +59 -14
  152. package/src/datasource-refs.ts +30 -6
  153. package/src/datasource-settings.test.ts +106 -0
  154. package/src/datasource-settings.ts +941 -0
  155. package/src/datasource.ts +71 -11
  156. package/src/deep-observe-hooks.ts +16 -1
  157. package/src/deep-observe.test.ts +47 -1
  158. package/src/deep-observe.ts +36 -13
  159. package/src/describe-resources.test.ts +16 -0
  160. package/src/describe-resources.ts +36 -1
  161. package/src/detect.ts +3 -3
  162. package/src/folder.ts +118 -0
  163. package/src/generated/lexicon-grafana.json +70 -0
  164. package/src/import/generated-types.e2e.test.ts +15 -3
  165. package/src/import/generator.ts +2 -1
  166. package/src/import/mappings.ts +98 -26
  167. package/src/import/model.ts +7 -0
  168. package/src/import/normalize.ts +32 -3
  169. package/src/import/parser.test.ts +97 -21
  170. package/src/import/parser.ts +169 -18
  171. package/src/import/roundtrip.test.ts +104 -9
  172. package/src/import/testdata/fixtures.ts +8 -0
  173. package/src/import/v2.ts +4 -3
  174. package/src/index.ts +122 -13
  175. package/src/init-templates.test.ts +74 -0
  176. package/src/init-templates.ts +349 -0
  177. package/src/lint/audit-catalog.ts +9 -1
  178. package/src/lint/post-synth/graf101.ts +2 -2
  179. package/src/lint/post-synth/graf104.ts +3 -3
  180. package/src/lint/post-synth/graf106.ts +3 -3
  181. package/src/lint/post-synth/graf110.ts +17 -0
  182. package/src/lint/post-synth/grafana-helpers.ts +13 -3
  183. package/src/lint/post-synth/index.ts +2 -0
  184. package/src/lint/post-synth/post-synth.test.ts +99 -2
  185. package/src/load-cost.test.ts +35 -0
  186. package/src/op/activities/grafana-apply.e2e.test.ts +14 -5
  187. package/src/op/activities/grafana-apply.test.ts +16 -2
  188. package/src/op/activities/grafana-apply.ts +29 -3
  189. package/src/op/activities/index.ts +1 -1
  190. package/src/ownership.ts +19 -0
  191. package/src/panels.test.ts +3 -1
  192. package/src/pin.ts +12 -0
  193. package/src/plugin.test.ts +9 -2
  194. package/src/plugin.ts +9 -0
  195. package/src/promql-check.ts +5 -1
  196. package/src/query-models.ts +94 -0
  197. package/src/query.test.ts +166 -0
  198. package/src/query.ts +177 -12
  199. package/src/schema/azuremonitor.gen.ts +381 -0
  200. package/src/schema/bigquery.gen.ts +110 -0
  201. package/src/schema/cloudwatch.gen.ts +423 -0
  202. package/src/schema/elasticsearch.gen.ts +508 -0
  203. package/src/schema/googlecloudmonitoring.gen.ts +330 -0
  204. package/src/schema/grafanapyroscope.gen.ts +66 -0
  205. package/src/schema/index.ts +6 -0
  206. package/src/schema/table.gen.ts +14 -0
  207. package/src/schema-validate.ts +58 -7
  208. package/src/skill-defs.ts +17 -0
  209. package/src/skills/chant-grafana-operations.md +108 -0
  210. package/src/skills/chant-grafana.md +5 -4
  211. package/src/spec/overlay/table.overlay.json +19 -0
  212. package/src/spec/schemas/azuremonitor.jsonschema.json +679 -0
  213. package/src/spec/schemas/bigquery.jsonschema.json +290 -0
  214. package/src/spec/schemas/cloudwatch.jsonschema.json +721 -0
  215. package/src/spec/schemas/elasticsearch.jsonschema.json +1695 -0
  216. package/src/spec/schemas/googlecloudmonitoring.jsonschema.json +488 -0
  217. package/src/spec/schemas/grafanapyroscope.jsonschema.json +93 -0
  218. package/src/spec/schemas.gen.ts +40 -0
  219. package/src/spec/schemas.ts +9 -7
  220. package/src/validate-alerting.ts +4 -2
  221. package/src/validate-output.ts +96 -4
  222. package/src/validate.ts +14 -0
  223. package/src/validation.ts +27 -0
  224. package/src/variables.ts +113 -6
@@ -1,7 +1,7 @@
1
1
  /**
2
- * GRAF104: Two dashboards, datasources, panels, variables or queries share an id
2
+ * GRAF104: Two dashboards, datasources, folders, panels, variables or queries share an id
3
3
  *
4
- * Dashboard uids and datasource uids and names must be unique across the build, panel ids and variable names within a dashboard, and refIds within a panel. Grafana keeps one of two dashboards with the same uid and silently drops the other.
4
+ * Dashboard, folder and datasource uids and datasource names must be unique across the build, panel ids and variable names within a dashboard, and refIds within a panel. Grafana keeps one of two dashboards with the same uid and silently drops the other.
5
5
  */
6
6
 
7
7
  import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
@@ -9,7 +9,7 @@ import { grafanaDiagnostics } from "./grafana-helpers";
9
9
 
10
10
  export const graf104: PostSynthCheck = {
11
11
  id: "GRAF104",
12
- description: "Two dashboards, datasources, panels, variables or queries share an id",
12
+ description: "Two dashboards, datasources, folders, panels, variables or queries share an id",
13
13
 
14
14
  check(ctx: PostSynthContext): PostSynthDiagnostic[] {
15
15
  return grafanaDiagnostics(ctx, "GRAF104");
@@ -1,7 +1,7 @@
1
1
  /**
2
- * GRAF106: A dashboard or datasource uid Grafana rejects, or a dashboard with no title
2
+ * GRAF106: A dashboard, folder or datasource uid Grafana rejects, or a dashboard with no title
3
3
  *
4
- * Grafana accepts uids of 1-40 letters, digits, - and _, and refuses to save a dashboard without a title.
4
+ * Grafana accepts uids of 1-40 letters, digits, - and _, refuses the folder uid general (its root), and refuses to save a dashboard without a title.
5
5
  */
6
6
 
7
7
  import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
@@ -9,7 +9,7 @@ import { grafanaDiagnostics } from "./grafana-helpers";
9
9
 
10
10
  export const graf106: PostSynthCheck = {
11
11
  id: "GRAF106",
12
- description: "A dashboard or datasource uid Grafana rejects, or a dashboard with no title",
12
+ description: "A dashboard, folder or datasource uid Grafana rejects, or a dashboard with no title",
13
13
 
14
14
  check(ctx: PostSynthContext): PostSynthDiagnostic[] {
15
15
  return grafanaDiagnostics(ctx, "GRAF106");
@@ -0,0 +1,17 @@
1
+ /**
2
+ * GRAF110: A panel or row repeats over a variable that cannot give it more than one value
3
+ *
4
+ * Grafana repeats a panel or row once per selected value of a query, custom, datasource or group by variable. Over an ad hoc, constant, interval, textbox or switch variable it shows the panel once and logs an error; over a query, custom or datasource variable with neither multi nor includeAll there is only ever one value. A repeat naming no variable is GRAF103.
5
+ */
6
+
7
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
8
+ import { grafanaDiagnostics } from "./grafana-helpers";
9
+
10
+ export const graf110: PostSynthCheck = {
11
+ id: "GRAF110",
12
+ description: "A panel or row repeats over a variable that cannot give it more than one value",
13
+
14
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
15
+ return grafanaDiagnostics(ctx, "GRAF110");
16
+ },
17
+ };
@@ -33,10 +33,15 @@ function parse(text: string, name: string): unknown[] {
33
33
  }
34
34
  }
35
35
 
36
+ /** The grafana serializer's primary output (build.ts `GrafanaIndex`), which lists the folders the build resolved. */
37
+ function isGrafanaIndex(doc: unknown): doc is { folders?: Array<{ uid: string; title: string; path?: string; parentUid?: string }> } {
38
+ return typeof doc === "object" && doc !== null && typeof (doc as { grafanaSchema?: unknown }).grafanaSchema === "string" && Array.isArray((doc as { dashboards?: unknown }).dashboards);
39
+ }
40
+
36
41
  /** Every dashboard, provisioned datasource and dashboard provider and alerting provisioning file in the build's output, and every `ExternalDatasource` it declares. */
37
42
  export function grafanaArtifacts(ctx: PostSynthContext): GrafanaArtifacts {
38
43
  const externalDatasources = [...(ctx.entities?.values() ?? [])].filter(isExternalDatasource).map(externalDatasourceRecord);
39
- const out: GrafanaArtifacts = { dashboards: [], datasources: [], externalDatasources, providers: [], alerting: [] };
44
+ const out: GrafanaArtifacts = { dashboards: [], datasources: [], externalDatasources, providers: [], alerting: [], folders: [], deleteDatasources: [] };
40
45
  for (const [lexicon, output] of ctx.outputs) {
41
46
  const texts: Array<[string, string]> =
42
47
  typeof output === "string"
@@ -49,8 +54,13 @@ export function grafanaArtifacts(ctx: PostSynthContext): GrafanaArtifacts {
49
54
  else if (looksLikeDashboardProvisioning(doc)) {
50
55
  for (const p of doc.providers) if (p && typeof p === "object" && !Array.isArray(p)) out.providers!.push(p as Record<string, unknown>);
51
56
  } else if (looksLikeAlertingProvisioning(doc)) out.alerting!.push({ source, json: doc });
52
- else if (looksLikeDatasourceProvisioning(doc)) {
53
- for (const d of doc.datasources) {
57
+ else if (isGrafanaIndex(doc)) {
58
+ for (const f of doc.folders ?? []) if (f && typeof f.uid === "string" && typeof f.title === "string") out.folders!.push(f);
59
+ } else if (looksLikeDatasourceProvisioning(doc)) {
60
+ for (const d of doc.deleteDatasources ?? []) {
61
+ if (d && typeof d === "object" && typeof (d as { name?: unknown }).name === "string") out.deleteDatasources!.push(d as { name: string; orgId?: number });
62
+ }
63
+ for (const d of doc.datasources ?? []) {
54
64
  if (d && typeof d === "object" && typeof (d as ProvisionedDatasource).name === "string") {
55
65
  const ds = d as ProvisionedDatasource;
56
66
  out.datasources.push({ ...ds, uid: typeof ds.uid === "string" ? ds.uid : ds.name });
@@ -9,6 +9,7 @@ import { graf106 } from "./graf106";
9
9
  import { graf107 } from "./graf107";
10
10
  import { graf108 } from "./graf108";
11
11
  import { graf109 } from "./graf109";
12
+ import { graf110 } from "./graf110";
12
13
  import { graf111 } from "./graf111";
13
14
  import { graf112 } from "./graf112";
14
15
  import { graf113 } from "./graf113";
@@ -25,6 +26,7 @@ export const postSynthChecks: PostSynthCheck[] = [
25
26
  graf107,
26
27
  graf108,
27
28
  graf109,
29
+ graf110,
28
30
  graf111,
29
31
  graf112,
30
32
  graf113,
@@ -6,11 +6,12 @@ import { makePostSynthCtxFromFiles, makePostSynthCtx } from "@intentius/chant-te
6
6
  import type { PostSynthCheck } from "@intentius/chant/lint/post-synth";
7
7
  import type { Declarable } from "@intentius/chant/declarable";
8
8
  import { grafanaSerializer } from "../../serializer";
9
- import { Datasource, ExternalDatasource } from "../../datasource";
9
+ import { Datasource, DatasourceProvisioning, ExternalDatasource } from "../../datasource";
10
+ import { Folder } from "../../folder";
10
11
  import { Dashboard, DashboardProvider } from "../../dashboard";
11
12
  import { HeatmapPanel, Row, StatPanel, TimeSeriesPanel, TablePanel, TextPanel } from "../../panels";
12
13
  import { LokiQuery, PromQuery, TempoQuery } from "../../query";
13
- import { DatasourceVariable, QueryVariable } from "../../variables";
14
+ import { AdhocVariable, CustomVariable, DatasourceVariable, GroupByVariable, IntervalVariable, QueryVariable, SwitchVariable } from "../../variables";
14
15
  import { graf101 } from "./graf101";
15
16
  import { graf102 } from "./graf102";
16
17
  import { graf103 } from "./graf103";
@@ -20,6 +21,7 @@ import { graf106 } from "./graf106";
20
21
  import { graf107 } from "./graf107";
21
22
  import { graf108 } from "./graf108";
22
23
  import { graf109 } from "./graf109";
24
+ import { graf110 } from "./graf110";
23
25
  import { graf115 } from "./graf115";
24
26
  import { panelUnits } from "../../validate-output";
25
27
  import { panelsOf } from "../../datasource-refs";
@@ -194,6 +196,18 @@ describe("GRAF103: undeclared variables", () => {
194
196
  });
195
197
  });
196
198
 
199
+ describe("GRAF101, GRAF103 on ad hoc and group by variables", () => {
200
+ test("checks an ad hoc or group by variable's datasource like a query variable's", () => {
201
+ const d = dashboardJson([], [
202
+ { type: "adhoc", name: "f", datasource: { type: "prometheus", uid: "gone" }, filters: [] },
203
+ { type: "groupby", name: "by", datasource: { type: "prometheus", uid: "${nope}" } },
204
+ ]);
205
+ const ctx = ctxOfJson(d, [{ name: "Prometheus", type: "prometheus", uid: "prom" }]);
206
+ expect(graf101.check(ctx).map((x) => x.message)).toEqual([expect.stringContaining('variable "f" uses datasource uid "gone"')]);
207
+ expect(graf103.check(ctx).map((x) => x.message)).toEqual([expect.stringContaining('variable "by" datasource uses $nope')]);
208
+ });
209
+ });
210
+
197
211
  describe("GRAF104: duplicates", () => {
198
212
  test("flags duplicate dashboard uids, datasource names and panel ids", () => {
199
213
  const again = new Datasource({ name: "Prometheus", type: "prometheus", uid: "prom-2" });
@@ -363,6 +377,54 @@ describe("GRAF108: PromQL syntax", () => {
363
377
  });
364
378
  });
365
379
 
380
+ describe("GRAF110: repeats", () => {
381
+ test("flags a repeat over a variable Grafana cannot repeat over, and over one that holds one value", () => {
382
+ const ds = new DatasourceVariable({ name: "ds", pluginType: "prometheus" });
383
+ const w = new IntervalVariable({ name: "w", values: ["1m", "5m"] });
384
+ const on = new SwitchVariable({ name: "on" });
385
+ const f = new AdhocVariable({ name: "f", datasource: ds });
386
+ const env = new CustomVariable({ name: "env", values: ["a", "b"] });
387
+ const panels = [new StatPanel({ title: "by w", repeat: w }), new StatPanel({ title: "by on", repeat: on }), new StatPanel({ title: "by f", repeat: f }), new StatPanel({ title: "by env", repeat: env })];
388
+ const diags = graf110.check(ctxOf({ prometheus, d: new Dashboard({ title: "D", variables: [ds, w, on, f, env], panels }) }));
389
+ expect(diags.map((x) => [x.checkId, x.severity])).toEqual(Array(4).fill(["GRAF110", "warning"]));
390
+ expect(diags.map((x) => x.message)).toEqual([
391
+ expect.stringContaining('panel "by w" (id 1) repeats over $w, an interval variable, which Grafana cannot repeat over'),
392
+ expect.stringContaining("repeats over $on, a switch variable"),
393
+ expect.stringContaining("repeats over $f, an ad hoc variable"),
394
+ expect.stringContaining("repeats over $env, a custom variable with neither multi nor includeAll"),
395
+ ]);
396
+ });
397
+
398
+ test("checks a row's repeat too", () => {
399
+ const env = new CustomVariable({ name: "env", values: ["a", "b"] });
400
+ const row = new Row({ title: "Per env", repeat: env, panels: [new StatPanel({ title: "s" })] });
401
+ expect(graf110.check(ctxOf({ d: new Dashboard({ title: "D", variables: [env], panels: [row] }) })).map((x) => x.message)).toEqual([
402
+ expect.stringContaining('row "Per env" repeats over $env'),
403
+ ]);
404
+ });
405
+
406
+ test("passes a multi-value or include-all variable, a group by variable, and leaves an undeclared one to GRAF103", () => {
407
+ const env = new CustomVariable({ name: "env", values: ["a", "b"], multi: true });
408
+ const job = new QueryVariable({ name: "job", datasource: prometheus, query: { query: "label_values(up, job)", qryType: 1 }, includeAll: true });
409
+ const ds = new DatasourceVariable({ name: "ds", pluginType: "prometheus", multi: true });
410
+ const by = new GroupByVariable({ name: "by", datasource: prometheus });
411
+ const panels = [env, job, ds, by].map((v) => new StatPanel({ title: v.variableName, repeat: v }));
412
+ panels.push(new StatPanel({ title: "gone", repeat: "gone" }));
413
+ expect(graf110.check(ctxOf({ prometheus, d: new Dashboard({ title: "D", variables: [env, job, ds, by], panels }) }))).toEqual([]);
414
+ });
415
+ });
416
+
417
+ describe("GRAF108 on object-form variable queries", () => {
418
+ test("parses the query text of an object query sent to Prometheus", () => {
419
+ const ds = new DatasourceVariable({ name: "ds", pluginType: "prometheus" });
420
+ const bad = new QueryVariable({ name: "bad", datasource: ds, query: { qryType: 1, query: 'label_values(up{job="x", instance)', refId: "PrometheusVariableQueryEditor-VariableQuery" } });
421
+ const good = new QueryVariable({ name: "good", datasource: ds, query: { qryType: 4, query: 'kube_pod_info{namespace=~"$ns"}' } });
422
+ const ns = new CustomVariable({ name: "ns", values: ["a"] });
423
+ const diags = graf108.check(ctxOf({ prometheus, d: new Dashboard({ title: "D", variables: [ds, ns, bad, good] }) }));
424
+ expect(diags.map((x) => x.message)).toEqual([expect.stringContaining('variable "bad" query is not valid PromQL')]);
425
+ });
426
+ });
427
+
366
428
  describe("GRAF108 over real Grafana exports", () => {
367
429
  const exportsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "test", "fixtures", "exports");
368
430
  const files = readdirSync(exportsDir)
@@ -453,3 +515,38 @@ describe("GRAF115 over real Grafana exports and community dashboards", () => {
453
515
  expect(unitsSeen).toBeGreaterThan(200);
454
516
  });
455
517
  });
518
+
519
+ describe("annotations, folders and datasource deletes (#2953)", () => {
520
+ const deploys = (datasource: unknown, extra: Record<string, unknown> = {}) => ({ name: "Deploys", datasource, ...extra }) as never;
521
+
522
+ test("GRAF101 flags an annotation on an undeclared datasource, GRAF102 one of another type", () => {
523
+ const d = new Dashboard({ title: "D", annotations: [deploys({ type: "prometheus", uid: "gone" })] });
524
+ expect(ids(graf101, ctxOf({ prometheus, d }))).toEqual([["GRAF101", "error"]]);
525
+ const t = new Dashboard({ title: "T", annotations: [deploys({ type: "loki", uid: "prometheus" })] });
526
+ expect(ids(graf102, ctxOf({ prometheus, t }))).toEqual([["GRAF102", "error"]]);
527
+ expect(ids(graf101, ctxOf({ prometheus, ok: new Dashboard({ title: "O", annotations: [deploys(prometheus)] }) }))).toEqual([]);
528
+ });
529
+
530
+ test("GRAF101 flags an ExternalDatasource the provisioning file deletes", () => {
531
+ const old = new ExternalDatasource({ type: "prometheus", uid: "old", name: "Old" });
532
+ const d = new Dashboard({ title: "D", panels: [new StatPanel({ datasource: old, targets: [up] })] });
533
+ const diags = graf101.check(ctxOf({ prometheus, old, d, settings: new DatasourceProvisioning({ deleteDatasources: [{ name: "Old" }] }) }));
534
+ // The panel's own ref and its query's, as for any undeclared datasource.
535
+ expect(diags.map((x) => [x.checkId, x.severity])).toEqual([["GRAF101", "error"], ["GRAF101", "error"]]);
536
+ expect(diags[0].message).toContain("deleteDatasources");
537
+ });
538
+
539
+ test("GRAF103 reads an annotation's query and datasource; GRAF108 parses its PromQL", () => {
540
+ const d = new Dashboard({ title: "D", annotations: [deploys(prometheus, { expr: "changes(up{job=\"$job\"}[5m] > 0" })] });
541
+ expect(ids(graf103, ctxOf({ prometheus, d }))).toEqual([["GRAF103", "error"]]);
542
+ expect(ids(graf108, ctxOf({ prometheus, d }))).toEqual([["GRAF108", "error"]]);
543
+ });
544
+
545
+ test("GRAF104 flags two folders with one uid; GRAF106 a folder uid Grafana refuses", () => {
546
+ const two = ctxOf({ a: new Dashboard({ title: "A", folder: "Team A" }), b: new Dashboard({ title: "B", folder: "team-a" }) });
547
+ expect(ids(graf104, two)).toEqual([["GRAF104", "error"]]);
548
+ expect(ids(graf106, ctxOf({ g: new Dashboard({ title: "G", folder: "General" }) }))).toEqual([["GRAF106", "error"]]);
549
+ expect(ids(graf106, ctxOf({ f: new Folder({ title: "F", uid: "not a uid" }) }))).toEqual([["GRAF106", "error"]]);
550
+ expect(ids(graf106, ctxOf({ f: new Folder({ title: "F" }), d: new Dashboard({ title: "D", folder: "F/G" }) }))).toEqual([]);
551
+ });
552
+ });
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Importing the lexicon must not pay for schema validation (#2958): ajv
3
+ * loads the first time a schema is checked, and the schemas come from the
4
+ * generated `spec/schemas.gen.ts`, not from files beside the source.
5
+ */
6
+
7
+ import { describe, expect, test } from "vitest";
8
+ import { execFileSync } from "child_process";
9
+ import { dirname, join } from "path";
10
+ import { fileURLToPath } from "url";
11
+ import { SCHEMA_NAMES } from "./pin";
12
+ import { loadSchema } from "./spec/schemas";
13
+ import { bundledSchema, schemaValidationUnavailable } from "./schema-validate";
14
+
15
+ const srcDir = dirname(fileURLToPath(import.meta.url));
16
+ const tsx = join(srcDir, "..", "..", "..", "node_modules", ".bin", "tsx");
17
+
18
+ /** How many ajv modules a fresh process has loaded after importing `entries`. */
19
+ function ajvModulesAfterImporting(...entries: string[]): number {
20
+ const imports = entries.map((e) => `import(${JSON.stringify(join(srcDir, e))})`).join(", ");
21
+ const script = `Promise.all([${imports}]).then(() => console.log(Object.keys(require.cache).filter((k) => k.includes("/ajv/")).length))`;
22
+ return Number(execFileSync(tsx, ["-e", script], { encoding: "utf-8" }).trim());
23
+ }
24
+
25
+ describe("load cost", () => {
26
+ test("importing the package root, its plugin or the validation subpath does not load ajv", () => {
27
+ // index.ts imports plugin.ts, whose post-synth checks reach schema-validate.ts.
28
+ expect(ajvModulesAfterImporting("index.ts", "validation.ts")).toBe(0);
29
+ });
30
+
31
+ test("validation reads the overlaid schemas from the generated module", () => {
32
+ expect(schemaValidationUnavailable()).toBeUndefined();
33
+ for (const name of SCHEMA_NAMES) expect(bundledSchema(name)).toEqual(loadSchema(name));
34
+ });
35
+ });
@@ -4,8 +4,9 @@
4
4
  * Per image, one project is built and applied with `grafanaApply`, the Op
5
5
  * activity, through its build output (the index and the dashboard files):
6
6
  *
7
- * 1. Apply: two folders, a library panel and three dashboards are created,
8
- * each dashboard and folder labelled with the project's marker.
7
+ * 1. Apply: three folders (one nested in another, with a pinned uid), a
8
+ * library panel and three dashboards are created, each dashboard and
9
+ * folder labelled with the project's marker.
9
10
  * 2. Re-apply: nothing is created, and nothing is written.
10
11
  * 3. A panel title is edited in the source and applied: that dashboard is
11
12
  * updated, the rest unchanged.
@@ -43,13 +44,15 @@ const MARKER_LABELS = { "app.kubernetes.io/managed-by": "chant", "chant.intentiu
43
44
  const LIBRARY_PANEL = { uid: "chant-e2e-burn", name: "Error budget burn", kind: 1, model: { type: "stat", title: "Error budget burn", description: "shared" } };
44
45
 
45
46
  function source(opts: { requestsTitle: string; withErrors: boolean }): string {
46
- return `import { Dashboard, StatPanel } from "@intentius/chant-lexicon-grafana";
47
+ return `import { Dashboard, Folder, StatPanel } from "@intentius/chant-lexicon-grafana";
47
48
 
48
49
  export const requests = new StatPanel({ title: ${JSON.stringify(opts.requestsTitle)} });
49
50
  export const latency = new StatPanel({ title: "Latency" });
50
51
  export const fiveHundreds = new StatPanel({ title: "5xx" });
51
52
  export const apiOverview = new Dashboard({ title: "API overview", folder: "Team A", tags: ["api"], panels: [requests, latency] });
52
- ${opts.withErrors ? `export const errors = new Dashboard({ title: "Errors", folder: "Team B", panels: [fiveHundreds] });\n` : ""}export const home = new Dashboard({ title: "Home" });
53
+ ${opts.withErrors ? `export const errors = new Dashboard({ title: "Errors", folder: "Team B", panels: [fiveHundreds] });\n` : ""}export const teamA = new Folder({ title: "Team A" });
54
+ export const payments = new Folder({ title: "Payments", uid: "chant-e2e-payments", parent: teamA });
55
+ export const home = new Dashboard({ title: "Home", folder: payments });
53
56
  `;
54
57
  }
55
58
 
@@ -112,7 +115,7 @@ describe.skipIf(!hasDocker).each(GRAFANA_IMAGES)(`grafanaApply applies, re-appli
112
115
 
113
116
  afterAll(() => scope.cleanup());
114
117
 
115
- it("apply: creates the folders, the library panel and the dashboards, labelled with the marker", { timeout: 240_000 }, async () => {
118
+ it("apply: creates the folders, nested ones under their parent, the library panel and the dashboards, labelled with the marker", { timeout: 240_000 }, async () => {
116
119
  await buildProject({ requestsTitle: "Requests", withErrors: true });
117
120
  const out = await apply();
118
121
  expect(out.api).toMatch(/^apis\/v1(beta1)?$/);
@@ -121,6 +124,7 @@ describe.skipIf(!hasDocker).each(GRAFANA_IMAGES)(`grafanaApply applies, re-appli
121
124
  "created Dashboard/api-overview",
122
125
  "created Dashboard/errors",
123
126
  "created Dashboard/home",
127
+ "created Folder/chant-e2e-payments",
124
128
  "created Folder/team-a",
125
129
  "created Folder/team-b",
126
130
  "created LibraryPanel/chant-e2e-burn",
@@ -133,6 +137,11 @@ describe.skipIf(!hasDocker).each(GRAFANA_IMAGES)(`grafanaApply applies, re-appli
133
137
  const folder = (await grafana.api(out.applied.find((a) => a.name === "team-a")!.address)).body as { metadata: { labels: Record<string, string> }; spec: { title: string } };
134
138
  expect(folder.metadata.labels).toMatchObject(MARKER_LABELS);
135
139
  expect(folder.spec.title).toBe("Team A");
140
+ const nested = (await grafana.api("/api/folders/chant-e2e-payments")).body as { title: string; parentUid?: string; parents?: Array<{ uid: string }> };
141
+ expect(nested.title).toBe("Payments");
142
+ expect(nested.parentUid ?? nested.parents?.[0]?.uid).toBe("team-a");
143
+ const home = (await grafana.api(out.applied.find((a) => a.name === "home")!.address)).body as { metadata: { annotations: Record<string, string> } };
144
+ expect(home.metadata.annotations["grafana.app/folder"]).toBe("chant-e2e-payments");
136
145
  const connections = (await grafana.api(`/api/library-elements/${LIBRARY_PANEL.uid}/connections`)).body as { result: Array<{ connectionUid: string }> };
137
146
  expect(connections.result.map((c) => c.connectionUid)).toEqual(["api-overview"]);
138
147
  });
@@ -1,10 +1,10 @@
1
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
1
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import { afterAll, describe, expect, test } from "vitest";
5
5
  import { describeApplyConformance } from "@intentius/chant-test-utils";
6
6
  import { normalizeApply } from "@intentius/chant/apply";
7
- import { grafanaApply, readBuiltDashboards, resolveMarker, toApplyResult, type GrafanaApplyDeps } from "./grafana-apply";
7
+ import { grafanaApply, readBuiltDashboards, readBuiltIndex, resolveMarker, toApplyResult, type GrafanaApplyDeps } from "./grafana-apply";
8
8
  import { emptyGrafana, writableGrafana, type WritableGrafanaState } from "../../api/fake-grafana-writes";
9
9
  import type { GrafanaHttp } from "../../api/client";
10
10
 
@@ -55,6 +55,20 @@ describe("readBuiltDashboards", () => {
55
55
  ]);
56
56
  });
57
57
 
58
+ test("reads the folders the index lists and each dashboard's folder uid (#2953)", () => {
59
+ const indexPath = buildOutput([{ json: dashboard("a"), folder: "Platform/Kubernetes" }]);
60
+ const index = JSON.parse(readFileSync(indexPath, "utf8")) as Json;
61
+ (index.dashboards as Json[])[0].folderUid = "k8s";
62
+ index.folders = [
63
+ { uid: "plat", title: "Platform", path: "Platform" },
64
+ { uid: "k8s", title: "Kubernetes", parentUid: "plat", path: "Platform/Kubernetes" },
65
+ ];
66
+ writeFileSync(indexPath, JSON.stringify(index));
67
+ const built = readBuiltIndex(indexPath);
68
+ expect(built.dashboards).toEqual([{ json: dashboard("a"), folder: "Platform/Kubernetes", folderUid: "k8s" }]);
69
+ expect(built.folders.map((f) => [f.uid, f.parentUid])).toEqual([["plat", undefined], ["k8s", "plat"]]);
70
+ });
71
+
58
72
  test("reads the grafana key of a combined multi-lexicon output", () => {
59
73
  expect(readBuiltDashboards(buildOutput([{ json: dashboard("a") }], { combined: true }))).toEqual([{ json: dashboard("a") }]);
60
74
  });
@@ -29,6 +29,7 @@ import {
29
29
  import { bindGrafana, GrafanaBindingError, type BindOptions } from "../../api/bind";
30
30
  import type { GrafanaHttp } from "../../api/client";
31
31
  import { applyGrafana, parseDashboardFile, planFromDashboards, planRefs, type BuiltDashboardInput, type GrafanaApplyOutcome } from "../../api/apply";
32
+ import type { FolderPlan } from "../../api/folders";
32
33
 
33
34
  export type { GrafanaApplyOutcome } from "../../api/apply";
34
35
 
@@ -74,16 +75,40 @@ function isObject(v: unknown): v is Json {
74
75
  * it. Pure but for the file reads.
75
76
  */
76
77
  export function readBuiltDashboards(indexPath: string, read: (path: string) => string = (p) => readFileSync(p, "utf8")): BuiltDashboardInput[] {
78
+ return readBuiltIndex(indexPath, read).dashboards;
79
+ }
80
+
81
+ /** The grafana index inside a build output: the index itself, or its `grafana` key in a multi-lexicon output. */
82
+ function grafanaIndexOf(indexPath: string, read: (path: string) => string): Json {
77
83
  const raw = parseDashboardFile(read(indexPath), indexPath);
78
84
  const index = isObject(raw.grafana) && Array.isArray(raw.grafana.dashboards) ? raw.grafana : raw;
79
85
  if (!Array.isArray(index.dashboards)) throw new Error(`grafana apply: ${indexPath} is not a grafana build index (it has no "dashboards" list)`);
86
+ return index;
87
+ }
88
+
89
+ /**
90
+ * The dashboards a build wrote, with their folders, and the folders its
91
+ * index lists (every level of every folder, `Folder`s holding no dashboard
92
+ * included). An index written before folders were listed has none, and the
93
+ * plan derives them from the dashboards' paths.
94
+ */
95
+ export function readBuiltIndex(indexPath: string, read: (path: string) => string = (p) => readFileSync(p, "utf8")): { dashboards: BuiltDashboardInput[]; folders: FolderPlan[] } {
96
+ const index = grafanaIndexOf(indexPath, read);
80
97
  const dir = dirname(indexPath);
81
- return index.dashboards.map((entry, i) => {
98
+ const dashboards = (index.dashboards as unknown[]).map((entry, i) => {
82
99
  if (!isObject(entry) || typeof entry.file !== "string") throw new Error(`grafana apply: ${indexPath} dashboards[${i}] names no file`);
83
100
  const file = join(dir, entry.file);
84
101
  const json = parseDashboardFile(read(file), file);
85
- return { json, ...(typeof entry.folder === "string" && entry.folder !== "" ? { folder: entry.folder } : {}) };
102
+ const folder = typeof entry.folder === "string" && entry.folder !== "" ? entry.folder : undefined;
103
+ const folderUid = folder && typeof entry.folderUid === "string" && entry.folderUid !== "" ? entry.folderUid : undefined;
104
+ return { json, ...(folder ? { folder } : {}), ...(folderUid ? { folderUid } : {}) };
86
105
  });
106
+ const folders: FolderPlan[] = (Array.isArray(index.folders) ? index.folders : []).flatMap((f: unknown) =>
107
+ isObject(f) && typeof f.uid === "string" && typeof f.title === "string" && typeof f.path === "string"
108
+ ? [{ uid: f.uid, title: f.title, path: f.path, ...(typeof f.parentUid === "string" ? { parentUid: f.parentUid } : {}) }]
109
+ : [],
110
+ );
111
+ return { dashboards, folders };
87
112
  }
88
113
 
89
114
  async function loadConfig(cwd: string): Promise<Pick<ChantConfig, "grafana" | "ownership"> | undefined> {
@@ -116,7 +141,8 @@ export function resolveMarker(args: Pick<GrafanaApplyArgs, "stack" | "ownershipE
116
141
  * with the binding's reason.
117
142
  */
118
143
  export async function grafanaApply(args: GrafanaApplyArgs, signal?: AbortSignal, deps: GrafanaApplyDeps = {}): Promise<GrafanaApplyOutcome> {
119
- const plan = planFromDashboards(readBuiltDashboards(args.indexPath));
144
+ const built = readBuiltIndex(args.indexPath);
145
+ const plan = planFromDashboards(built.dashboards, built.folders);
120
146
  const config = deps.config ?? (await loadConfig(args.cwd ?? process.cwd()));
121
147
  const marker = resolveMarker(args, config);
122
148
  const bind: BindOptions = {
@@ -4,5 +4,5 @@
4
4
  * API applier (`grafanaApply`, #2948) and its envelope projection
5
5
  * (`toApplyResult`), which core's apply activity looks up by name.
6
6
  */
7
- export { grafanaApply, toApplyResult, readBuiltDashboards, resolveMarker } from "./grafana-apply";
7
+ export { grafanaApply, toApplyResult, readBuiltDashboards, readBuiltIndex, resolveMarker } from "./grafana-apply";
8
8
  export type { GrafanaApplyArgs, GrafanaApplyDeps, GrafanaApplyOutcome } from "./grafana-apply";
package/src/ownership.ts CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  } from "@intentius/chant/ownership";
42
42
  import { DASHBOARD_PROVIDER_TYPE } from "./dashboard";
43
43
  import type { LiveDashboard } from "./api/dashboards";
44
+ import type { LiveFolder } from "./api/folders";
44
45
 
45
46
  /** The label keys chant's marker uses on a `dashboard.grafana.app` resource. */
46
47
  export const GRAFANA_OWNERSHIP_KEYS: ChannelKeys = LABEL_OWNERSHIP_KEYS;
@@ -101,3 +102,21 @@ export function dashboardOwnership(
101
102
  }
102
103
  return { ownership: "foreign" };
103
104
  }
105
+
106
+ /**
107
+ * A folder's ownership verdict, read the way a dashboard's is: chant's labels
108
+ * (the API applier's), else the manager annotations Grafana writes on a
109
+ * folder that file provisioning made for one of this project's providers.
110
+ * A folder read over `/api/folders` (Grafana 11) has neither: `unknown`.
111
+ */
112
+ export function folderOwnership(folder: LiveFolder, providers: ReadonlySet<string>): { ownership: OwnershipVerdict; marker?: OwnershipMarker } {
113
+ if (folder.via === "legacy") return { ownership: "unknown" };
114
+ if (hasOwnershipMarker(folder.labels, GRAFANA_OWNERSHIP_KEYS)) {
115
+ const marker = readOwnership(folder.labels, GRAFANA_OWNERSHIP_KEYS);
116
+ return { ownership: classifyOwnership(folder.labels, GRAFANA_OWNERSHIP_KEYS), ...(marker ? { marker } : {}) };
117
+ }
118
+ const annotations = folder.annotations ?? {};
119
+ const manager = annotations[MANAGER_ID_ANNOTATION];
120
+ if (annotations[MANAGED_BY_ANNOTATION] === FILE_PROVISIONING_MANAGER && manager !== undefined && providers.has(manager)) return { ownership: "owned" };
121
+ return { ownership: "foreign" };
122
+ }
@@ -4,12 +4,14 @@ import { Dashboard } from "./dashboard";
4
4
  import { renderDashboard } from "./build";
5
5
  import { validateDashboardSchema } from "./schema-validate";
6
6
  import { SCHEMA_NAMES } from "./pin";
7
+ import { registeredQueries } from "./query";
7
8
 
8
9
  const builtins = panels.registeredPanels().filter((d) => d.builtin);
9
10
 
10
11
  describe("built-in panels", () => {
11
12
  test("every panel type Grafana ships with a schema at the pin has a class", () => {
12
- const panelSchemas = SCHEMA_NAMES.filter((n) => !["dashboard", "prometheus", "tempo", "loki", "expr"].includes(n));
13
+ const querySchemas = new Set<string>(registeredQueries().flatMap((d) => (d.schema ? [d.schema] : [])));
14
+ const panelSchemas = SCHEMA_NAMES.filter((n) => n !== "dashboard" && n !== "expr" && !querySchemas.has(n));
13
15
  expect(builtins.map((d) => d.schema).filter(Boolean).sort()).toEqual([...panelSchemas].sort());
14
16
  });
15
17
 
package/src/pin.ts CHANGED
@@ -59,6 +59,12 @@ export const SCHEMA_NAMES = [
59
59
  "prometheus",
60
60
  "tempo",
61
61
  "loki",
62
+ "elasticsearch",
63
+ "cloudwatch",
64
+ "azuremonitor",
65
+ "googlecloudmonitoring",
66
+ "bigquery",
67
+ "grafanapyroscope",
62
68
  "expr",
63
69
  ] as const;
64
70
 
@@ -108,6 +114,12 @@ export const GRAFANA_SCHEMA_PIN: GrafanaSchemaPin = Object.freeze({
108
114
  prometheus: "726fc97eeb1e37791dbbf988c3cf40de17bb8e623926e6979c4a3f947cb1af87",
109
115
  tempo: "21aec4c333c9b8e5e9228e85a6225c76161975abb54002e6f12e919e3b4e54ae",
110
116
  loki: "03b81d6b952e3d31785c4b52637e2b7b1c170cc2ea8ec7ec671c21599508e31b",
117
+ elasticsearch: "126b0e04116877f468df7474ad6f75167cf9c54feb125534211fbe1915e6421e",
118
+ cloudwatch: "06d50341c82420865f3f58bfd3e3081cd90fb625f1d275bb7ba0230033f9db3f",
119
+ azuremonitor: "fe0f2fc01ff626dec7ce17a0e3dbb2e76f51f20539c73bbded46bbc4c2d3e262",
120
+ googlecloudmonitoring: "e1f2e2086d4216f22aca84def440b6a31a76e074727aa2ea3d2d10671e68834b",
121
+ bigquery: "939a0a6aee2c98fd8f690659e17060ff2b5bad1b22437e3449741ce0acc5abe4",
122
+ grafanapyroscope: "297db04efc4f9501ffa729acd9e02e484998bc4d823c7da362e401faceed64a2",
111
123
  expr: "b1f1c1e4f6bf00f0fb4cb32b5bcc1b0e91fad9b7ad9354ecb791322bd97a5564",
112
124
  dashboardv2: "a2cfb8b731ff9f48c41f5aa06ade134cf0d93ab9f6f0567a34a4bc39a9fd984e",
113
125
  }),
@@ -16,7 +16,7 @@ describe("grafana plugin", () => {
16
16
 
17
17
  it("ships lint rules and post-synth checks, all under the GRAF prefix", () => {
18
18
  const ids = [...grafanaPlugin.lintRules!().map((r) => r.id), ...grafanaPlugin.postSynthChecks!().map((c) => c.id)];
19
- expect(ids).toEqual(["GRAF001", "GRAF002", "GRAF101", "GRAF102", "GRAF103", "GRAF104", "GRAF105", "GRAF106", "GRAF107", "GRAF108", "GRAF109", "GRAF111", "GRAF112", "GRAF113", "GRAF114", "GRAF115"]);
19
+ expect(ids).toEqual(["GRAF001", "GRAF002", "GRAF101", "GRAF102", "GRAF103", "GRAF104", "GRAF105", "GRAF106", "GRAF107", "GRAF108", "GRAF109", "GRAF110", "GRAF111", "GRAF112", "GRAF113", "GRAF114", "GRAF115"]);
20
20
  });
21
21
 
22
22
  it("catalogues every rule and check for chant audit, and nothing else", () => {
@@ -38,10 +38,17 @@ describe("grafana plugin", () => {
38
38
 
39
39
  it("loads its skills with content", () => {
40
40
  const skills = grafanaPlugin.skills!();
41
- expect(skills.map((s) => s.name)).toEqual(["chant-grafana", "chant-grafana-provisioning", "chant-grafana-alerting"]);
41
+ expect(skills.map((s) => s.name)).toEqual(["chant-grafana", "chant-grafana-provisioning", "chant-grafana-alerting", "chant-grafana-operations"]);
42
42
  for (const s of skills) expect(s.content.length).toBeGreaterThan(200);
43
43
  });
44
44
 
45
+ it("offers a default init template and three named ones", () => {
46
+ const names = [undefined, "red", "k8s-pods", "slo"];
47
+ const sets = names.map((n) => grafanaPlugin.initTemplates!(n));
48
+ expect(new Set(sets).size).toBe(4);
49
+ for (const set of sets) expect(Object.keys(set.src).length).toBeGreaterThan(0);
50
+ });
51
+
45
52
  it("registers namespaced MCP contributions", async () => {
46
53
  expect(grafanaPlugin.mcpTools!().map((t) => t.name)).toEqual(["grafana:diff"]);
47
54
  const [resource] = grafanaPlugin.mcpResources!();
package/src/plugin.ts CHANGED
@@ -14,6 +14,7 @@ import { GrafanaParser } from "./import/parser";
14
14
  import { GrafanaGenerator } from "./import/generator";
15
15
  import { dashboardImporter } from "./import/embedded";
16
16
  import { grafanaSkills } from "./skill-defs";
17
+ import { DEFAULT_TEMPLATE, K8S_PODS_TEMPLATE, RED_TEMPLATE, SLO_TEMPLATE } from "./init-templates";
17
18
  import { BUILTIN_CATALOG } from "./catalog";
18
19
  import { GRAFANA_SCHEMA_PIN } from "./pin";
19
20
  import { compositeCatalog } from "./composites/catalog";
@@ -129,6 +130,14 @@ export const grafanaPlugin: LexiconPlugin = {
129
130
  return [catalogResource];
130
131
  },
131
132
 
133
+ // `chant init --lexicon grafana [--template red|k8s-pods|slo]`; see ./init-templates.ts.
134
+ initTemplates(template?: string) {
135
+ if (template === "red") return RED_TEMPLATE;
136
+ if (template === "k8s-pods") return K8S_PODS_TEMPLATE;
137
+ if (template === "slo") return SLO_TEMPLATE;
138
+ return DEFAULT_TEMPLATE;
139
+ },
140
+
132
141
  detectTemplate(data: unknown) {
133
142
  return detectTemplate(data);
134
143
  },
@@ -164,7 +164,11 @@ function usesOf(dashboard: Json, known: ReadonlyMap<string, KnownDatasource>, li
164
164
  const expr = use.target?.expr;
165
165
  if (typeof expr !== "string" || expr.trim() === "") continue;
166
166
  out.push({ where: library ? `library panel "${library}" query ${String(use.target?.refId ?? "?")}` : use.where, expr });
167
- } else if (use.kind === "variable" && use.variable && !library) {
167
+ } else if (use.kind === "annotation" && use.annotation) {
168
+ // A Prometheus annotation keeps its query in `target.expr`, or at the top level when saved before Grafana 10.
169
+ const expr = use.target?.expr ?? use.annotation.expr;
170
+ if (typeof expr === "string" && expr.trim() !== "") out.push({ where: `${use.where} query`, expr });
171
+ } else if (use.kind === "variable" && use.variable?.type === "query" && !library) {
168
172
  const text = variableQueryText(use.variable);
169
173
  if (text) for (const expr of variablePromql(text)) out.push({ where: `${use.where} query`, expr });
170
174
  }