@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
package/src/datasource.ts CHANGED
@@ -10,9 +10,14 @@
10
10
  import { createResource } from "@intentius/chant/runtime";
11
11
  import type { Declarable } from "@intentius/chant/declarable";
12
12
  import { slugUid } from "./util";
13
+ import type { DatasourceJsonData, DatasourceSecureJsonData, TypedDatasourceType } from "./datasource-settings";
13
14
 
14
- /** Datasource plugin types this lexicon types queries for. Any other plugin id is accepted as a string. */
15
- export type KnownDatasourceType = "prometheus" | "tempo" | "loki";
15
+ /**
16
+ * Datasource plugin types this lexicon types queries and settings for
17
+ * (`postgres` is the PostgreSQL plugin's old id). Any other plugin id is
18
+ * accepted as a string.
19
+ */
20
+ export type KnownDatasourceType = TypedDatasourceType;
16
21
 
17
22
  /** How Grafana reaches the datasource: through its backend (`proxy`) or from the browser (`direct`). */
18
23
  export type DatasourceAccess = "proxy" | "direct";
@@ -20,7 +25,7 @@ export type DatasourceAccess = "proxy" | "direct";
20
25
  export interface DatasourceProps<T extends string = string> {
21
26
  /** Display name, unique within the Grafana organisation. */
22
27
  name: string;
23
- /** The datasource plugin id, e.g. `prometheus`, `tempo`, `loki`. */
28
+ /** The datasource plugin id, e.g. `prometheus`, `tempo`, `loki`, `grafana-postgresql-datasource`. */
24
29
  type: T;
25
30
  /** Stable id panels refer to. Defaults to the name as a uid (`Prometheus` becomes `prometheus`). */
26
31
  uid?: string;
@@ -33,17 +38,20 @@ export interface DatasourceProps<T extends string = string> {
33
38
  database?: string;
34
39
  withCredentials?: boolean;
35
40
  /**
36
- * Plugin settings. A declared `Datasource` anywhere in here is written as
37
- * its uid, so `tracesToLogsV2: { datasourceUid: loki }` links Tempo to a
38
- * declared Loki.
41
+ * Plugin settings, typed for the plugins in `DatasourceJsonDataTypes`
42
+ * (`./datasource-settings.ts`) and any object for others. A declared
43
+ * `Datasource` anywhere in here is written as its uid, so
44
+ * `tracesToLogsV2: { datasourceUid: loki }` links Tempo to a declared Loki;
45
+ * a typed field that names a datasource accepts only the plugin types
46
+ * Grafana offers there.
39
47
  */
40
- jsonData?: Record<string, unknown>;
48
+ jsonData?: DatasourceJsonData<T>;
41
49
  /**
42
- * Secrets. Write them as Grafana provisioning expands them
43
- * (`$__env{NAME}`, `$__file{/path}`, `${NAME}`), not as literals: GRAF002
44
- * flags a literal.
50
+ * Secrets, by the keys the plugin reads. Write them as Grafana provisioning
51
+ * expands them (`$__env{NAME}`, `$__file{/path}`, `${NAME}`), not as
52
+ * literals: GRAF002 flags a literal.
45
53
  */
46
- secureJsonData?: Record<string, string>;
54
+ secureJsonData?: DatasourceSecureJsonData<T>;
47
55
  /** Whether users may edit the provisioned datasource in the UI. Defaults to false. */
48
56
  editable?: boolean;
49
57
  orgId?: number;
@@ -143,3 +151,55 @@ export interface DatasourceRef<T extends string = string> {
143
151
 
144
152
  /** Grafana's own pseudo-datasources, which need no declaration. */
145
153
  export const BUILTIN_DATASOURCE_UIDS: ReadonlySet<string> = new Set(["grafana", "-- Grafana --", "-- Mixed --", "-- Dashboard --"]);
154
+
155
+ export const DATASOURCE_PROVISIONING_TYPE = "Grafana::DatasourceProvisioning";
156
+
157
+ /** A datasource the provisioning file deletes, by its name in an organisation. */
158
+ export interface DeletedDatasource {
159
+ name: string;
160
+ /** Defaults to 1, as in Grafana. */
161
+ orgId?: number;
162
+ }
163
+
164
+ export interface DatasourceProvisioningProps {
165
+ /**
166
+ * Written as the file's `prune`. Grafana marks every datasource this file
167
+ * provisions as prunable, and deletes a prunable datasource once no
168
+ * provisioning file lists it any more, so removing a `Datasource` removes
169
+ * it from Grafana on the next provisioning run. Datasources provisioned
170
+ * without it, or made in the UI or API, are never pruned. Defaults to
171
+ * true; set false to keep a removed datasource in Grafana.
172
+ */
173
+ prune?: boolean;
174
+ /**
175
+ * Written as the file's `deleteDatasources`: Grafana deletes these by name
176
+ * before it provisions the file's datasources. Name one the file also
177
+ * provisions to have Grafana re-create it from the file on every run.
178
+ */
179
+ deleteDatasources?: DeletedDatasource[];
180
+ }
181
+
182
+ export interface DatasourceProvisioningEntity extends Declarable {
183
+ readonly props: DatasourceProvisioningProps;
184
+ }
185
+
186
+ const ProvisioningBase = createResource(DATASOURCE_PROVISIONING_TYPE, "grafana", {}) as unknown as (this: object, props: Record<string, unknown>) => void;
187
+
188
+ /**
189
+ * The settings of the datasource provisioning file itself: `prune` and
190
+ * `deleteDatasources`. Optional: without one the file prunes. Declare at
191
+ * most one per build.
192
+ */
193
+ export const DatasourceProvisioning = function (this: object, props: DatasourceProvisioningProps) {
194
+ ProvisioningBase.call(this, props as unknown as Record<string, unknown>);
195
+ } as unknown as new (props: DatasourceProvisioningProps) => DatasourceProvisioningEntity;
196
+ Object.defineProperty(DatasourceProvisioning, "name", { value: "DatasourceProvisioning" });
197
+
198
+ export function isDatasourceProvisioningEntity(value: unknown): value is DatasourceProvisioningEntity {
199
+ return (
200
+ typeof value === "object" &&
201
+ value !== null &&
202
+ (value as Declarable).entityType === DATASOURCE_PROVISIONING_TYPE &&
203
+ (value as Declarable).lexicon === "grafana"
204
+ );
205
+ }
@@ -41,6 +41,7 @@
41
41
  import type { DeepArrayElement, DeepNode, DeepNormalizationHooks } from "@intentius/chant/deep-observation";
42
42
  import { DASHBOARD_TYPE } from "./dashboard";
43
43
  import { DATASOURCE_TYPE, EXTERNAL_DATASOURCE_TYPE } from "./datasource";
44
+ import { FOLDER_TYPE } from "./folder";
44
45
  import { registeredQueries } from "./query";
45
46
  import { DASHBOARD_SCHEMA_VERSION } from "./schema/dashboard.gen";
46
47
  import { BOOKKEEPING_KEYS, LINK_DEFAULTS, deepEqual, isBuiltinAnnotation } from "./import/normalize";
@@ -49,7 +50,7 @@ import { DATASOURCE_API_DEFAULTS } from "./import/live-export";
49
50
  type Json = Record<string, unknown>;
50
51
 
51
52
  /** Where a node sits in a dashboard tree, from its index-erased pattern. */
52
- type Level = "dashboard" | "panel" | "target" | "variable" | "link";
53
+ type Level = "dashboard" | "panel" | "target" | "variable" | "link" | "annotation";
53
54
 
54
55
  const SEGMENT = "([^.\\[\\]]+)";
55
56
  const PANEL = "panels\\[\\](?:\\.panels\\[\\])?";
@@ -59,6 +60,7 @@ const LEVELS: ReadonlyArray<[Level, RegExp]> = [
59
60
  ["target", new RegExp(`^${PANEL}\\.targets\\[\\]\\.${SEGMENT}$`)],
60
61
  ["variable", new RegExp(`^variables\\[\\]\\.${SEGMENT}$`)],
61
62
  ["link", new RegExp(`^(?:${PANEL}\\.)?links\\[\\]\\.${SEGMENT}$`)],
63
+ ["annotation", new RegExp(`^annotations\\[\\]\\.${SEGMENT}$`)],
62
64
  ];
63
65
 
64
66
  function levelOf(pattern: string): { level: Level; key: string } | undefined {
@@ -122,8 +124,13 @@ export const PROP_DEFAULTS: Readonly<Record<Level, Readonly<Json>>> = {
122
124
  autoMin: "10s",
123
125
  },
124
126
  link: LINK_DEFAULTS,
127
+ // Grafana 13.2 drops `hide: false` from an annotation it stores.
128
+ annotation: { hide: false },
125
129
  };
126
130
 
131
+ /** What the build writes into an annotation that leaves them out (annotations.ts `annotationJson`). */
132
+ const ANNOTATION_BUILD_DEFAULTS: Readonly<Json> = { enable: true, iconColor: "red" };
133
+
127
134
  /**
128
135
  * Keys the build fills in when a declaration leaves them out, by level:
129
136
  * the dashboard's uid (from its export name) and schema version, a panel's
@@ -141,6 +148,7 @@ export const BUILD_DERIVED: Readonly<Record<Level, ReadonlySet<string>>> = {
141
148
  // A query variable's selection is whatever the viewer last saved; the build writes one only when it is declared.
142
149
  variable: new Set(["current", "datasource"]),
143
150
  link: new Set(),
151
+ annotation: new Set(),
144
152
  };
145
153
 
146
154
  /** Defaults the build merges into a query from its class (`queryType: "traceql"`, `filters: []`): any key some class defaults, at that value. */
@@ -214,6 +222,7 @@ function pruneDashboard(node: DeepNode): boolean {
214
222
  if (at.level === "dashboard" && at.key === "timezone" && node.value === "browser") return true;
215
223
  if (at.level === "target" && queryDefault(at.key, node.value)) return true;
216
224
  if (at.level === "link" && at.key in LINK_DEFAULTS && deepEqual(LINK_DEFAULTS[at.key], node.value)) return true;
225
+ if (at.level === "annotation" && at.key in ANNOTATION_BUILD_DEFAULTS && deepEqual(ANNOTATION_BUILD_DEFAULTS[at.key], node.value)) return true;
217
226
  return false;
218
227
  }
219
228
 
@@ -223,9 +232,15 @@ function pruneDashboard(node: DeepNode): boolean {
223
232
  return at.key in defaults && deepEqual(defaults[at.key], node.value);
224
233
  }
225
234
 
235
+ /** A folder's uid comes from its path when it is not declared. */
236
+ function pruneFolder(node: DeepNode): boolean {
237
+ return node.side === "live" && node.counterpart === "absent" && node.pattern === "uid";
238
+ }
239
+
226
240
  export const grafanaDeepNormalizationHooks: DeepNormalizationHooks = {
227
241
  prune(node: DeepNode): boolean {
228
242
  if (node.entityType === DASHBOARD_TYPE) return pruneDashboard(node);
243
+ if (node.entityType === FOLDER_TYPE) return pruneFolder(node);
229
244
  if (isDatasourceType(node.entityType)) return pruneDatasource(node);
230
245
  return false;
231
246
  },
@@ -22,7 +22,9 @@ import { observeResourcesDeepGrafana } from "./deep-observe";
22
22
  import { grafanaDeepNormalizationHooks } from "./deep-observe-hooks";
23
23
  import { fakeGrafana, liveDatasource, storedDashboard, type FakeGrafanaState } from "./api/fake-grafana";
24
24
  import { load } from "js-yaml";
25
- import { DATASOURCES_FILE, type GrafanaIndex } from "./build";
25
+ import { DATASOURCES_FILE, buildGrafana, type GrafanaIndex } from "./build";
26
+ import { Dashboard } from "./dashboard";
27
+ import { Folder } from "./folder";
26
28
 
27
29
  const { diffDeepObservation } = await import("@intentius/chant/lifecycle/deep-observe");
28
30
  const { normalizeDeepObservation } = await import("@intentius/chant/deep-observation");
@@ -263,3 +265,47 @@ describe("what cannot be read is not observed, never a clean tree", () => {
263
265
  expect(unobserved.prometheus.reason).toBe("filtered");
264
266
  });
265
267
  });
268
+
269
+ describe("folders (#2953)", () => {
270
+ const platform = new Folder({ title: "Platform", uid: "plat" });
271
+ const k8s = new Folder({ title: "Kubernetes", parent: platform });
272
+ const pods = new Dashboard({ title: "Pods", uid: "pods", folder: k8s });
273
+
274
+ function nested(): { built: Built; state: FakeGrafanaState } {
275
+ const out = buildGrafana(new Map<string, never>([["pods", pods as never], ["k8s", k8s as never]]));
276
+ const entities: Entities = new Map([
277
+ ["pods", { entityType: pods.entityType, props: pods.props as Record<string, unknown> }],
278
+ ["k8s", { entityType: k8s.entityType, props: k8s.props as unknown as Record<string, unknown> }],
279
+ ]);
280
+ const state: FakeGrafanaState = {
281
+ api: "v1",
282
+ dashboards: {
283
+ pods: {
284
+ spec: storedDashboard(JSON.parse(out.files["dashboards/Platform/Kubernetes/pods.json"]) as Json),
285
+ annotations: { "grafana.app/managedBy": "classic-file-provisioning", "grafana.app/managerId": "chant" },
286
+ folderUid: "platform-kubernetes",
287
+ },
288
+ },
289
+ folders: { plat: "Platform", "platform-kubernetes": "Kubernetes", other: "Other" },
290
+ folderParents: { "platform-kubernetes": "plat" },
291
+ };
292
+ return { built: { entities, index: out.index, files: out.files }, state };
293
+ }
294
+
295
+ it("a dashboard in a nested Folder reads back as its path, and the Folder by its uid, with no drift", async () => {
296
+ const { built, state } = nested();
297
+ const { live, result } = await diff(built, state);
298
+ expect(normalizeDeepObservation(live).unobserved).toEqual({});
299
+ expect(driftPaths(result)).toEqual([]);
300
+ expect(unclaimedPaths(result)).toEqual([]);
301
+ });
302
+
303
+ it("a nested folder moved or renamed shows on the dashboard's path and the Folder's title", async () => {
304
+ const { built, state } = nested();
305
+ state.folderParents!["platform-kubernetes"] = "other";
306
+ expect(driftPaths((await diff(built, state)).result)).toEqual(["pods:changed:folder"]);
307
+ state.folderParents!["platform-kubernetes"] = "plat";
308
+ state.folders!["platform-kubernetes"] = "K8s";
309
+ expect(driftPaths((await diff(built, state)).result).sort()).toEqual(["k8s:changed:title", "pods:changed:folder"]);
310
+ });
311
+ });
@@ -18,10 +18,15 @@
18
18
  * diffed against the declaration: a tab added in the UI is a row that
19
19
  * the declaration does not have.
20
20
  * - The folder is not in the JSON. It is read from the resource's folder
21
- * and written as its title, the form `Dashboard.folder` takes; when the
22
- * declaration's folder name is one the build had to rewrite to make a
23
- * directory (`Ops/Team` becomes `Ops-Team`), the declared spelling is
24
- * kept, since the two name the same folder.
21
+ * and written as its path, its titles from the root joined with `/`, the
22
+ * form `Dashboard.folder` takes (a `Folder` given for it is held as its
23
+ * path too); when the declared path is one the build had to rewrite to
24
+ * make directories (` Ops / Team ` becomes `Ops/Team`), the declared
25
+ * spelling is kept, since the two name the same folder.
26
+ * - A `Folder` is read by its uid: its title, and its parent's uid when it
27
+ * is nested. A declared `parent` is a reference, which core does not
28
+ * compare, so a folder moved to another parent shows as drift on the
29
+ * dashboards it holds, whose paths change.
25
30
  * - A datasource's API payload is put into `DatasourceProps` vocabulary,
26
31
  * secrets as key names only.
27
32
  *
@@ -39,7 +44,9 @@ import { deepObservation, normalizeDeepProperties } from "@intentius/chant/deep-
39
44
  import { boundedConcurrently, unobservedAll } from "@intentius/chant/observation";
40
45
  import { bindGrafana, classifyGrafanaFailure } from "./api/bind";
41
46
  import type { GrafanaClient } from "./api/client";
42
- import { classicDashboardOf, dashboardApi, folderTitleOf, readDashboard } from "./api/dashboards";
47
+ import { classicDashboardOf, dashboardApi, readDashboard } from "./api/dashboards";
48
+ import { liveFolderPath, readFolder } from "./api/folders";
49
+ import { FOLDER_TYPE, folderLevels, folderUidOf } from "./folder";
43
50
  import { readDatasource } from "./api/datasources";
44
51
  import { DASHBOARD_PROVIDER_TYPE, DASHBOARD_TYPE } from "./dashboard";
45
52
  import { DATASOURCE_TYPE, EXTERNAL_DATASOURCE_TYPE } from "./datasource";
@@ -47,15 +54,15 @@ import { grafanaDeepNormalizationHooks } from "./deep-observe-hooks";
47
54
  import { ORPHAN_PART, PROVIDER_NOT_OBSERVABLE, declaredUid, type GrafanaObserveOptions } from "./describe-resources";
48
55
  import { dashboardMembers, isDashboardPart } from "./members";
49
56
  import { dashboardTree, datasourceProps } from "./import/live-export";
50
- import { chantProviderNames, dashboardOwnership, ownershipGap } from "./ownership";
57
+ import { chantProviderNames, dashboardOwnership, folderOwnership, ownershipGap } from "./ownership";
51
58
 
52
59
  export { grafanaDeepNormalizationHooks };
53
60
 
54
61
  type Json = Record<string, unknown>;
55
62
 
56
- /** The directory name the build writes a folder to (build.ts `folderDir`), which is the title Grafana gives it. */
57
- function folderDirName(folder: string): string {
58
- return folder.replace(/[/\\]+/g, "-").replace(/^\.+/, "").trim() || "General";
63
+ /** A folder path as the build writes it (build.ts `folderDir`), which is the path Grafana's folders take. */
64
+ function samePath(declared: string, live: string): boolean {
65
+ return folderLevels(declared).join("/") === live;
59
66
  }
60
67
 
61
68
  async function dashboardProperties(client: GrafanaClient, uid: string, declared: Json, providers: ReadonlySet<string>, owned: boolean | undefined): Promise<DeepResourceObservation | UnobservedEntity | undefined> {
@@ -73,10 +80,10 @@ async function dashboardProperties(client: GrafanaClient, uid: string, declared:
73
80
  }
74
81
  }
75
82
  const { tree } = dashboardTree(classic.json);
76
- const folder = await folderTitleOf(client, live);
83
+ const folder = live.folderUid ? await liveFolderPath(client, live.folderUid) : undefined;
77
84
  if (folder !== undefined) {
78
85
  const declaredFolder = typeof declared.folder === "string" ? declared.folder : undefined;
79
- tree.folder = declaredFolder !== undefined && folderDirName(declaredFolder) === folder ? declaredFolder : folder;
86
+ tree.folder = declaredFolder !== undefined && samePath(declaredFolder, folder) ? declaredFolder : folder;
80
87
  }
81
88
  return {
82
89
  type: DASHBOARD_TYPE,
@@ -85,6 +92,19 @@ async function dashboardProperties(client: GrafanaClient, uid: string, declared:
85
92
  };
86
93
  }
87
94
 
95
+ async function folderProperties(client: GrafanaClient, uid: string, providers: ReadonlySet<string>, owned: boolean | undefined): Promise<DeepResourceObservation | UnobservedEntity | undefined> {
96
+ const live = await readFolder(client, uid);
97
+ if (!live) return undefined;
98
+ if (owned) {
99
+ const { ownership } = folderOwnership(live, providers);
100
+ if (ownership !== "owned") {
101
+ return { type: FOLDER_TYPE, reason: "filtered", detail: `folder "${uid}" is ${ownership === "unknown" ? "of unknown ownership (read over /api/folders, which has no labels)" : "not chant's"}`, queried: live.address };
102
+ }
103
+ }
104
+ const tree: Json = { title: live.title, uid: live.uid, ...(live.parentUid ? { parent: live.parentUid } : {}) };
105
+ return { type: FOLDER_TYPE, physicalId: uid, properties: normalizeDeepProperties(tree, { entityType: FOLDER_TYPE, side: "live", hooks: grafanaDeepNormalizationHooks }) };
106
+ }
107
+
88
108
  async function datasourceProperties(client: GrafanaClient, uid: string, entityType: string, owned: boolean | undefined): Promise<DeepResourceObservation | UnobservedEntity | undefined> {
89
109
  const read = await readDatasource(client, uid);
90
110
  if ("absent" in read) return undefined;
@@ -135,8 +155,9 @@ export async function observeResourcesDeepGrafana(options: GrafanaObserveOptions
135
155
  return;
136
156
  }
137
157
  const isDashboard = entityType === DASHBOARD_TYPE;
158
+ const isFolder = entityType === FOLDER_TYPE;
138
159
  const isDatasource = entityType === DATASOURCE_TYPE || entityType === EXTERNAL_DATASOURCE_TYPE;
139
- if (!isDashboard && !isDatasource) {
160
+ if (!isDashboard && !isDatasource && !isFolder) {
140
161
  unobserved[name] = { type: entityType, reason: "unsupported-kind", detail: `no grafana deep reader for ${entityType}` };
141
162
  return;
142
163
  }
@@ -148,7 +169,9 @@ export async function observeResourcesDeepGrafana(options: GrafanaObserveOptions
148
169
  try {
149
170
  const result = isDashboard
150
171
  ? await dashboardProperties(client, uid, props, providers, options.owned)
151
- : await datasourceProperties(client, uid, entityType, options.owned);
172
+ : isFolder
173
+ ? await folderProperties(client, uid, providers, options.owned)
174
+ : await datasourceProperties(client, uid, entityType, options.owned);
152
175
  // Not there: the thin read reports it missing; restating it as a property hole would say it twice.
153
176
  if (result === undefined) return;
154
177
  if (isUnobserved(result)) unobserved[name] = result;
@@ -14,6 +14,7 @@ import { Datasource, ExternalDatasource } from "./datasource";
14
14
  import { StatPanel, Row } from "./panels";
15
15
  import { PromQuery } from "./query";
16
16
  import { CustomVariable } from "./variables";
17
+ import { Folder } from "./folder";
17
18
 
18
19
  type Entities = GrafanaObserveOptions["entities"];
19
20
 
@@ -253,3 +254,18 @@ describe("unobserved reasons", () => {
253
254
  expect(failing.unobserved.overview.reason).toBe("read-failed");
254
255
  });
255
256
  });
257
+
258
+ describe("folders (#2953)", () => {
259
+ it("a Folder is read by its uid, from its own or its path", async () => {
260
+ const platform = new Folder({ title: "Platform" });
261
+ const k8s = new Folder({ title: "Kubernetes", parent: platform, uid: "k8s" });
262
+ const gone = new Folder({ title: "Gone" });
263
+ const folders = entities({ platform, k8s, gone });
264
+ const s = state({ folders: { platform: "Platform", k8s: "Kubernetes" }, folderParents: { k8s: "platform" } });
265
+ const out = normalizeObservation(await describeResources({ environment: "prod", entityNames: [...folders.keys()], entities: folders, config: CONFIG, env: ENV, http: fakeGrafana(s) }));
266
+ expect(out.resources.platform).toMatchObject({ physicalId: "platform", status: "PRESENT", attributes: { title: "Platform" } });
267
+ // The fake serves folders over /api/folders only (Grafana 11), which carries no labels.
268
+ expect(out.resources.k8s).toMatchObject({ physicalId: "k8s", ownership: "unknown", attributes: { title: "Kubernetes", parentUid: "platform" } });
269
+ expect(out.resources.gone).toBeUndefined();
270
+ });
271
+ });
@@ -11,6 +11,7 @@
11
11
  * |---|---|---|---|
12
12
  * | `Dashboard` | `/apis/dashboard.grafana.app/<v>/.../dashboards/<uid>` (Grafana 11: `/api/dashboards/uid/<uid>`) | absent | a v2-stored dashboard the server does not serve at v2: `unsupported-kind` |
13
13
  * | `Datasource`, `ExternalDatasource` | `/api/datasources/uid/<uid>` | absent | |
14
+ * | `Folder` | `/apis/folder.grafana.app/<v>/.../folders/<uid>` (Grafana 11: `/api/folders/<uid>`) | absent | |
14
15
  * | a panel, row, query or variable | its dashboard's read (./members.ts) | its dashboard's | its dashboard's |
15
16
  * | `DashboardProvider` | none: a provisioning-file setting Grafana serves no API for | | `unsupported-kind` |
16
17
  *
@@ -42,7 +43,9 @@ import { readDatasource } from "./api/datasources";
42
43
  import { DASHBOARD_PROVIDER_TYPE, DASHBOARD_TYPE } from "./dashboard";
43
44
  import { DATASOURCE_TYPE, EXTERNAL_DATASOURCE_TYPE } from "./datasource";
44
45
  import { dashboardMembers, isDashboardPart } from "./members";
45
- import { chantProviderNames, dashboardOwnership, ownershipGap } from "./ownership";
46
+ import { readFolder } from "./api/folders";
47
+ import { FOLDER_TYPE, folderUidOf } from "./folder";
48
+ import { chantProviderNames, dashboardOwnership, folderOwnership, ownershipGap } from "./ownership";
46
49
  import { slugUid } from "./util";
47
50
 
48
51
  export interface GrafanaObserveOptions extends Omit<BindOptions, "environment"> {
@@ -55,6 +58,7 @@ export interface GrafanaObserveOptions extends Omit<BindOptions, "environment">
55
58
 
56
59
  /** The uid a declared dashboard or datasource has in Grafana: the one the build gives it. */
57
60
  export function declaredUid(name: string, entityType: string, props: Record<string, unknown>): string | undefined {
61
+ if (entityType === FOLDER_TYPE) return folderUidOf(props);
58
62
  if (typeof props.uid === "string" && props.uid !== "") return props.uid;
59
63
  if (entityType === DASHBOARD_TYPE) return slugUid(name || String(props.title ?? ""));
60
64
  if (entityType === DATASOURCE_TYPE && typeof props.name === "string") return slugUid(props.name);
@@ -121,6 +125,34 @@ async function observeDashboard(client: GrafanaClient, uid: string, providers: R
121
125
  return { present: meta, queried: live.address };
122
126
  }
123
127
 
128
+ async function observeFolder(client: GrafanaClient, uid: string, providers: ReadonlySet<string>, owned: boolean | undefined): Promise<EntityObservation> {
129
+ let live;
130
+ try {
131
+ live = await readFolder(client, uid);
132
+ } catch (err) {
133
+ return unobservedFrom(err);
134
+ }
135
+ if (!live) return { absent: true };
136
+ const { ownership, marker } = folderOwnership(live, providers);
137
+ if (owned && ownership !== "owned") {
138
+ return {
139
+ unobserved: { reason: "filtered", detail: `folder "${uid}" exists but ${ownership === "unknown" ? "its ownership cannot be read: /api/folders returns no labels" : "is not chant's"}` },
140
+ queried: live.address,
141
+ };
142
+ }
143
+ return {
144
+ present: {
145
+ type: FOLDER_TYPE,
146
+ physicalId: uid,
147
+ status: "PRESENT",
148
+ ownership,
149
+ ...(marker ? { marker } : {}),
150
+ attributes: { title: live.title, via: live.via, ...(live.parentUid ? { parentUid: live.parentUid } : {}) },
151
+ },
152
+ queried: live.address,
153
+ };
154
+ }
155
+
124
156
  async function observeDatasource(client: GrafanaClient, entity: DeclaredEntity, uid: string, owned: boolean | undefined): Promise<EntityObservation> {
125
157
  let read;
126
158
  try {
@@ -189,6 +221,9 @@ function adapter(options: GrafanaObserveOptions): ObserverAdapter<GrafanaClient>
189
221
  if (entity.type === DASHBOARD_TYPE) {
190
222
  return uid ? dashboardOnce(client, uid) : { unobserved: { reason: "read-failed", detail: `"${entity.name}" does not resolve to a uid` } };
191
223
  }
224
+ if (entity.type === FOLDER_TYPE) {
225
+ return uid ? observeFolder(client, uid, providers, options.owned) : { unobserved: { reason: "read-failed", detail: `"${entity.name}" does not resolve to a uid` } };
226
+ }
192
227
  if (entity.type === DATASOURCE_TYPE || entity.type === EXTERNAL_DATASOURCE_TYPE) {
193
228
  return uid ? observeDatasource(client, entity, uid, options.owned) : { unobserved: { reason: "read-failed", detail: `"${entity.name}" does not resolve to a uid` } };
194
229
  }
package/src/detect.ts CHANGED
@@ -41,9 +41,9 @@ export function looksLikeDashboardApiResponse(data: unknown): data is { dashboar
41
41
  return isObject(data) && isObject(data.meta) && (looksLikeDashboard(data.dashboard) || looksLikeLegacyRowsDashboard(data.dashboard));
42
42
  }
43
43
 
44
- /** A datasource provisioning file: `apiVersion` and a `datasources` list. */
45
- export function looksLikeDatasourceProvisioning(data: unknown): data is { apiVersion: unknown; datasources: unknown[] } {
46
- return isObject(data) && data.apiVersion !== undefined && Array.isArray(data.datasources);
44
+ /** A datasource provisioning file: `apiVersion` and a `datasources` or `deleteDatasources` list. */
45
+ export function looksLikeDatasourceProvisioning(data: unknown): data is { apiVersion: unknown; datasources?: unknown[]; deleteDatasources?: unknown[]; prune?: unknown } {
46
+ return isObject(data) && data.apiVersion !== undefined && (Array.isArray(data.datasources) || Array.isArray(data.deleteDatasources));
47
47
  }
48
48
 
49
49
  /** A dashboard provisioning file: `apiVersion` and a `providers` list. */
package/src/folder.ts ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * `Folder`: a Grafana folder with a stable uid, nested with `parent`.
3
+ *
4
+ * A dashboard names its folder either as a path string
5
+ * (`folder: "Platform/Kubernetes"`) or as a `Folder`. Both give the same
6
+ * folders: one per level, each with the uid `folderUidFor` makes from its
7
+ * path unless a `Folder` sets one. A `Folder` is needed only to pin a uid
8
+ * (for links, alert rules, permissions set elsewhere) or to declare a folder
9
+ * that holds no dashboard yet.
10
+ *
11
+ * How each delivery carries it:
12
+ *
13
+ * - File provisioning writes the dashboard under `dashboards/<level>/...`,
14
+ * and a provider with `foldersFromFilesStructure` makes one folder per
15
+ * level (nested on Grafana 13.1 and later; GRAF109 says what earlier
16
+ * versions do). Grafana finds each level's folder by title under its
17
+ * parent and creates it when missing, with a uid of its own: file
18
+ * provisioning has no field for a nested folder's uid. A folder the API
19
+ * applier made first keeps the uid it was given. A `DashboardProvider`
20
+ * whose `folder` is a root-level `Folder` writes its `folderUid`.
21
+ * - The API applier (./api/apply.ts) creates every folder with its uid and
22
+ * parent, parents first.
23
+ * - Observation reads a dashboard's folder back as its path, and a `Folder`
24
+ * by its uid.
25
+ *
26
+ * The rules for turning folders into uids and parents are in one place,
27
+ * `resolveFolders` in ./api/folders.ts, which the build, the applier and
28
+ * observation all call.
29
+ */
30
+
31
+ import { createResource } from "@intentius/chant/runtime";
32
+ import type { Declarable } from "@intentius/chant/declarable";
33
+ import { slugUid } from "./util";
34
+
35
+ export interface FolderProps {
36
+ /** The title Grafana shows. A single level: nest with `parent`, not with `/`. */
37
+ title: string;
38
+ /**
39
+ * Stable id: letters, digits, `-` and `_`, at most 40 characters, and not
40
+ * `general`, which Grafana keeps for its root. Defaults to the folder's
41
+ * path as a uid (`Platform/Kubernetes` becomes `platform-kubernetes`), the
42
+ * same uid a dashboard's `folder: "Platform/Kubernetes"` gives it.
43
+ */
44
+ uid?: string;
45
+ /** The folder this one is in. Leave it out for a folder at the root. */
46
+ parent?: FolderEntity;
47
+ }
48
+
49
+ export interface FolderEntity extends Declarable {
50
+ readonly props: FolderProps;
51
+ /** The uid Grafana gives the folder: its own, else one made from its path. */
52
+ readonly uid: string;
53
+ /** Its titles from the root, joined with `/`: the directory the build writes it to. */
54
+ readonly path: string;
55
+ }
56
+
57
+ export const FOLDER_TYPE = "Grafana::Folder";
58
+
59
+ /**
60
+ * The levels of a folder path, as the build writes them to directories:
61
+ * split on `/`, a backslash made `-`, leading dots and surrounding blanks
62
+ * dropped, so no level names a directory outside `dashboards/`. Empty levels
63
+ * are dropped; no levels at all is the General folder.
64
+ */
65
+ export function folderLevels(path: string): string[] {
66
+ return path
67
+ .split("/")
68
+ .map((level) => level.replace(/\\+/g, "-").trim().replace(/^\.+/, "").trim())
69
+ .filter((level) => level !== "");
70
+ }
71
+
72
+ /** The uid chant gives a folder it only knows by path. Stable, so a second apply finds the folder the first one made. */
73
+ export function folderUidFor(path: string): string {
74
+ return slugUid(folderLevels(path).join("/"));
75
+ }
76
+
77
+ const Base = createResource(FOLDER_TYPE, "grafana", {}) as unknown as (this: object, props: Record<string, unknown>) => void;
78
+
79
+ /** A Grafana folder. */
80
+ export const Folder = function (this: object, props: FolderProps) {
81
+ const title = props.title;
82
+ if (typeof title !== "string" || folderLevels(title).length !== 1 || folderLevels(title)[0] !== title) {
83
+ throw new Error(
84
+ `grafana: Folder title ${JSON.stringify(title)} is not one folder level: use parent to nest folders, and leave out "/", "\\", leading dots and surrounding blanks, which file provisioning cannot write as a directory`,
85
+ );
86
+ }
87
+ if (props.parent !== undefined && !isFolderEntity(props.parent)) throw new Error(`grafana: Folder "${title}" has a parent that is not a Folder`);
88
+ Base.call(this, props as unknown as Record<string, unknown>);
89
+ const path = props.parent ? `${props.parent.path}/${title}` : title;
90
+ Object.defineProperty(this, "path", { value: path, enumerable: false });
91
+ Object.defineProperty(this, "uid", { value: props.uid ?? folderUidFor(path), enumerable: false });
92
+ } as unknown as new (props: FolderProps) => FolderEntity;
93
+ Object.defineProperty(Folder, "name", { value: "Folder" });
94
+
95
+ export function isFolderEntity(value: unknown): value is FolderEntity {
96
+ return (
97
+ typeof value === "object" &&
98
+ value !== null &&
99
+ (value as Declarable).entityType === FOLDER_TYPE &&
100
+ (value as Declarable).lexicon === "grafana"
101
+ );
102
+ }
103
+
104
+ /** A folder's uid from its props, as observation sees them (the props of a `Folder`, parents included). */
105
+ export function folderUidOf(props: Record<string, unknown>): string | undefined {
106
+ if (typeof props.uid === "string" && props.uid !== "") return props.uid;
107
+ const path = folderPathOf(props);
108
+ return path ? folderUidFor(path) : undefined;
109
+ }
110
+
111
+ /** A folder's path from its props, walking `parent`. */
112
+ export function folderPathOf(props: Record<string, unknown>): string | undefined {
113
+ if (typeof props.title !== "string") return undefined;
114
+ const parent = props.parent as { props?: Record<string, unknown> } | undefined;
115
+ if (!parent) return props.title;
116
+ const up = parent.props ? folderPathOf(parent.props) : undefined;
117
+ return up === undefined ? undefined : `${up}/${props.title}`;
118
+ }