@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.
- package/README.md +12 -6
- package/dist/annotations.d.ts +61 -0
- package/dist/annotations.d.ts.map +1 -0
- package/dist/api/apply.d.ts +8 -4
- package/dist/api/apply.d.ts.map +1 -1
- package/dist/api/fake-grafana.d.ts +2 -0
- package/dist/api/fake-grafana.d.ts.map +1 -1
- package/dist/api/folders.d.ts +53 -17
- package/dist/api/folders.d.ts.map +1 -1
- package/dist/build.d.ts +26 -5
- package/dist/build.d.ts.map +1 -1
- package/dist/catalog.d.ts +1 -1
- package/dist/catalog.d.ts.map +1 -1
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/codegen/generate.d.ts +7 -1
- package/dist/codegen/generate.d.ts.map +1 -1
- package/dist/composites/shared.d.ts +4 -3
- package/dist/composites/shared.d.ts.map +1 -1
- package/dist/dashboard.d.ts +42 -14
- package/dist/dashboard.d.ts.map +1 -1
- package/dist/datasource-refs.d.ts +9 -5
- package/dist/datasource-refs.d.ts.map +1 -1
- package/dist/datasource-settings.d.ts +834 -0
- package/dist/datasource-settings.d.ts.map +1 -0
- package/dist/datasource.d.ts +53 -11
- package/dist/datasource.d.ts.map +1 -1
- package/dist/deep-observe-hooks.d.ts +1 -1
- package/dist/deep-observe-hooks.d.ts.map +1 -1
- package/dist/deep-observe.d.ts +9 -4
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/describe-resources.d.ts +1 -0
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/detect.d.ts +4 -2
- package/dist/detect.d.ts.map +1 -1
- package/dist/folder.d.ts +68 -0
- package/dist/folder.d.ts.map +1 -0
- package/dist/import/generator.d.ts.map +1 -1
- package/dist/import/mappings.d.ts +12 -6
- package/dist/import/mappings.d.ts.map +1 -1
- package/dist/import/model.d.ts +7 -0
- package/dist/import/model.d.ts.map +1 -1
- package/dist/import/normalize.d.ts.map +1 -1
- package/dist/import/parser.d.ts +16 -1
- package/dist/import/parser.d.ts.map +1 -1
- package/dist/import/testdata/fixtures.d.ts +6 -0
- package/dist/import/testdata/fixtures.d.ts.map +1 -1
- package/dist/import/v2.d.ts +4 -3
- package/dist/import/v2.d.ts.map +1 -1
- package/dist/index.d.ts +11 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/init-templates.d.ts +27 -0
- package/dist/init-templates.d.ts.map +1 -0
- package/dist/integrity.json +11 -9
- package/dist/lint/audit-catalog.d.ts +1 -1
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/graf101.d.ts +1 -1
- package/dist/lint/post-synth/graf104.d.ts +2 -2
- package/dist/lint/post-synth/graf106.d.ts +2 -2
- package/dist/lint/post-synth/graf110.d.ts +8 -0
- package/dist/lint/post-synth/graf110.d.ts.map +1 -0
- package/dist/lint/post-synth/grafana-helpers.d.ts.map +1 -1
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/meta.json +70 -0
- package/dist/okf/index.md +18 -3
- package/dist/okf/rules/GRAF101.md +2 -2
- package/dist/okf/rules/GRAF104.md +2 -2
- package/dist/okf/rules/GRAF106.md +2 -2
- package/dist/okf/rules/GRAF110.md +11 -0
- package/dist/okf/types/AdhocVariable.md +9 -0
- package/dist/okf/types/AzureMonitorQuery.md +9 -0
- package/dist/okf/types/BigQueryQuery.md +9 -0
- package/dist/okf/types/CloudMonitoringQuery.md +9 -0
- package/dist/okf/types/CloudWatchQuery.md +9 -0
- package/dist/okf/types/Dashboard.md +1 -1
- package/dist/okf/types/Datasource.md +1 -1
- package/dist/okf/types/DatasourceProvisioning.md +9 -0
- package/dist/okf/types/ElasticsearchQuery.md +9 -0
- package/dist/okf/types/ExternalDatasource.md +1 -1
- package/dist/okf/types/Folder.md +9 -0
- package/dist/okf/types/GroupByVariable.md +9 -0
- package/dist/okf/types/MSSQLQuery.md +9 -0
- package/dist/okf/types/MySQLQuery.md +9 -0
- package/dist/okf/types/PostgresQuery.md +9 -0
- package/dist/okf/types/PyroscopeQuery.md +9 -0
- package/dist/okf/types/SwitchVariable.md +9 -0
- package/dist/op/activities/grafana-apply.d.ts +11 -0
- package/dist/op/activities/grafana-apply.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +1 -1
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/ownership.d.ts +11 -0
- package/dist/ownership.d.ts.map +1 -1
- package/dist/pin.d.ts +1 -1
- package/dist/pin.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/promql-check.d.ts.map +1 -1
- package/dist/query-models.d.ts +95 -0
- package/dist/query-models.d.ts.map +1 -0
- package/dist/query.d.ts +107 -10
- package/dist/query.d.ts.map +1 -1
- package/dist/rules/graf101.ts +2 -2
- package/dist/rules/graf104.ts +3 -3
- package/dist/rules/graf106.ts +3 -3
- package/dist/rules/graf110.ts +17 -0
- package/dist/rules/grafana-helpers.ts +13 -3
- package/dist/schema/azuremonitor.gen.d.ts +354 -0
- package/dist/schema/azuremonitor.gen.d.ts.map +1 -0
- package/dist/schema/bigquery.gen.d.ts +93 -0
- package/dist/schema/bigquery.gen.d.ts.map +1 -0
- package/dist/schema/cloudwatch.gen.d.ts +394 -0
- package/dist/schema/cloudwatch.gen.d.ts.map +1 -0
- package/dist/schema/elasticsearch.gen.d.ts +444 -0
- package/dist/schema/elasticsearch.gen.d.ts.map +1 -0
- package/dist/schema/googlecloudmonitoring.gen.d.ts +312 -0
- package/dist/schema/googlecloudmonitoring.gen.d.ts.map +1 -0
- package/dist/schema/grafanapyroscope.gen.d.ts +60 -0
- package/dist/schema/grafanapyroscope.gen.d.ts.map +1 -0
- package/dist/schema/index.d.ts +6 -0
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/table.gen.d.ts +13 -0
- package/dist/schema/table.gen.d.ts.map +1 -1
- package/dist/schema-validate.d.ts +9 -0
- package/dist/schema-validate.d.ts.map +1 -1
- package/dist/skill-defs.d.ts.map +1 -1
- package/dist/skills/chant-grafana-operations.md +108 -0
- package/dist/skills/chant-grafana.md +5 -4
- package/dist/spec/schemas.d.ts +9 -7
- package/dist/spec/schemas.d.ts.map +1 -1
- package/dist/spec/schemas.gen.d.ts +3 -0
- package/dist/spec/schemas.gen.d.ts.map +1 -0
- package/dist/validate-alerting.d.ts.map +1 -1
- package/dist/validate-output.d.ts +15 -2
- package/dist/validate-output.d.ts.map +1 -1
- package/dist/validate.d.ts.map +1 -1
- package/dist/validation.d.ts +14 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/variables.d.ts +102 -5
- package/dist/variables.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/annotations.ts +84 -0
- package/src/api/apply.test.ts +19 -1
- package/src/api/apply.ts +18 -8
- package/src/api/fake-grafana.ts +6 -2
- package/src/api/folders.ts +106 -32
- package/src/build.test.ts +142 -6
- package/src/build.ts +126 -24
- package/src/catalog.ts +20 -2
- package/src/codegen/docs.ts +13 -6
- package/src/codegen/generate.ts +27 -1
- package/src/composites/shared.ts +3 -2
- package/src/dashboard.ts +59 -14
- package/src/datasource-refs.ts +30 -6
- package/src/datasource-settings.test.ts +106 -0
- package/src/datasource-settings.ts +941 -0
- package/src/datasource.ts +71 -11
- package/src/deep-observe-hooks.ts +16 -1
- package/src/deep-observe.test.ts +47 -1
- package/src/deep-observe.ts +36 -13
- package/src/describe-resources.test.ts +16 -0
- package/src/describe-resources.ts +36 -1
- package/src/detect.ts +3 -3
- package/src/folder.ts +118 -0
- package/src/generated/lexicon-grafana.json +70 -0
- package/src/import/generated-types.e2e.test.ts +15 -3
- package/src/import/generator.ts +2 -1
- package/src/import/mappings.ts +98 -26
- package/src/import/model.ts +7 -0
- package/src/import/normalize.ts +32 -3
- package/src/import/parser.test.ts +97 -21
- package/src/import/parser.ts +169 -18
- package/src/import/roundtrip.test.ts +104 -9
- package/src/import/testdata/fixtures.ts +8 -0
- package/src/import/v2.ts +4 -3
- package/src/index.ts +122 -13
- package/src/init-templates.test.ts +74 -0
- package/src/init-templates.ts +349 -0
- package/src/lint/audit-catalog.ts +9 -1
- package/src/lint/post-synth/graf101.ts +2 -2
- package/src/lint/post-synth/graf104.ts +3 -3
- package/src/lint/post-synth/graf106.ts +3 -3
- package/src/lint/post-synth/graf110.ts +17 -0
- package/src/lint/post-synth/grafana-helpers.ts +13 -3
- package/src/lint/post-synth/index.ts +2 -0
- package/src/lint/post-synth/post-synth.test.ts +99 -2
- package/src/load-cost.test.ts +35 -0
- package/src/op/activities/grafana-apply.e2e.test.ts +14 -5
- package/src/op/activities/grafana-apply.test.ts +16 -2
- package/src/op/activities/grafana-apply.ts +29 -3
- package/src/op/activities/index.ts +1 -1
- package/src/ownership.ts +19 -0
- package/src/panels.test.ts +3 -1
- package/src/pin.ts +12 -0
- package/src/plugin.test.ts +9 -2
- package/src/plugin.ts +9 -0
- package/src/promql-check.ts +5 -1
- package/src/query-models.ts +94 -0
- package/src/query.test.ts +166 -0
- package/src/query.ts +177 -12
- package/src/schema/azuremonitor.gen.ts +381 -0
- package/src/schema/bigquery.gen.ts +110 -0
- package/src/schema/cloudwatch.gen.ts +423 -0
- package/src/schema/elasticsearch.gen.ts +508 -0
- package/src/schema/googlecloudmonitoring.gen.ts +330 -0
- package/src/schema/grafanapyroscope.gen.ts +66 -0
- package/src/schema/index.ts +6 -0
- package/src/schema/table.gen.ts +14 -0
- package/src/schema-validate.ts +58 -7
- package/src/skill-defs.ts +17 -0
- package/src/skills/chant-grafana-operations.md +108 -0
- package/src/skills/chant-grafana.md +5 -4
- package/src/spec/overlay/table.overlay.json +19 -0
- package/src/spec/schemas/azuremonitor.jsonschema.json +679 -0
- package/src/spec/schemas/bigquery.jsonschema.json +290 -0
- package/src/spec/schemas/cloudwatch.jsonschema.json +721 -0
- package/src/spec/schemas/elasticsearch.jsonschema.json +1695 -0
- package/src/spec/schemas/googlecloudmonitoring.jsonschema.json +488 -0
- package/src/spec/schemas/grafanapyroscope.jsonschema.json +93 -0
- package/src/spec/schemas.gen.ts +40 -0
- package/src/spec/schemas.ts +9 -7
- package/src/validate-alerting.ts +4 -2
- package/src/validate-output.ts +96 -4
- package/src/validate.ts +14 -0
- package/src/validation.ts +27 -0
- 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
|
-
/**
|
|
15
|
-
|
|
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
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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?:
|
|
48
|
+
jsonData?: DatasourceJsonData<T>;
|
|
41
49
|
/**
|
|
42
|
-
* Secrets. Write them as Grafana provisioning
|
|
43
|
-
* (`$__env{NAME}`, `$__file{/path}`, `${NAME}`), not as
|
|
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?:
|
|
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
|
},
|
package/src/deep-observe.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/deep-observe.ts
CHANGED
|
@@ -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
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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,
|
|
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
|
-
/**
|
|
57
|
-
function
|
|
58
|
-
return
|
|
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
|
|
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 &&
|
|
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
|
-
:
|
|
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 {
|
|
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
|
|
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
|
+
}
|