@intentius/chant-lexicon-grafana 0.97.0 → 0.99.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 +15 -5
- package/dist/actions/grafana.d.ts +24 -0
- package/dist/actions/grafana.d.ts.map +1 -0
- package/dist/actions/index.d.ts +2 -0
- package/dist/actions/index.d.ts.map +1 -0
- package/dist/alerting-build.d.ts +128 -0
- package/dist/alerting-build.d.ts.map +1 -0
- package/dist/alerting.d.ts +273 -0
- package/dist/alerting.d.ts.map +1 -0
- package/dist/api/apply.d.ts +156 -0
- package/dist/api/apply.d.ts.map +1 -0
- package/dist/api/bind.d.ts +32 -0
- package/dist/api/bind.d.ts.map +1 -0
- package/dist/api/client.d.ts +76 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/converge.d.ts +33 -0
- package/dist/api/converge.d.ts.map +1 -0
- package/dist/api/dashboards.d.ts +102 -0
- package/dist/api/dashboards.d.ts.map +1 -0
- package/dist/api/datasources.d.ts +26 -0
- package/dist/api/datasources.d.ts.map +1 -0
- package/dist/api/fake-grafana-writes.d.ts +41 -0
- package/dist/api/fake-grafana-writes.d.ts.map +1 -0
- package/dist/api/fake-grafana.d.ts +61 -0
- package/dist/api/fake-grafana.d.ts.map +1 -0
- package/dist/api/folders.d.ts +110 -0
- package/dist/api/folders.d.ts.map +1 -0
- package/dist/api/library-panels.d.ts +52 -0
- package/dist/api/library-panels.d.ts.map +1 -0
- package/dist/build.d.ts +30 -2
- 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/schema-types.d.ts +3 -3
- package/dist/codegen/schema-types.d.ts.map +1 -1
- package/dist/composites/agent-dashboard.d.ts.map +1 -1
- package/dist/composites/catalog.d.ts.map +1 -1
- package/dist/composites/index.d.ts +4 -2
- package/dist/composites/index.d.ts.map +1 -1
- package/dist/composites/red-dashboard.d.ts +21 -2
- package/dist/composites/red-dashboard.d.ts.map +1 -1
- package/dist/composites/shared.d.ts +7 -0
- package/dist/composites/shared.d.ts.map +1 -1
- package/dist/composites/slo-alert-rules.d.ts +92 -0
- package/dist/composites/slo-alert-rules.d.ts.map +1 -0
- package/dist/config.d.ts +111 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/contact-point-secrets.d.ts +17 -0
- package/dist/contact-point-secrets.d.ts.map +1 -0
- package/dist/dashboard.d.ts +16 -2
- package/dist/dashboard.d.ts.map +1 -1
- package/dist/datasource-refs.d.ts +95 -0
- package/dist/datasource-refs.d.ts.map +1 -0
- package/dist/datasource.d.ts +29 -0
- package/dist/datasource.d.ts.map +1 -1
- package/dist/deep-observe-hooks.d.ts +64 -0
- package/dist/deep-observe-hooks.d.ts.map +1 -0
- package/dist/deep-observe.d.ts +42 -0
- package/dist/deep-observe.d.ts.map +1 -0
- package/dist/describe-resources.d.ts +49 -0
- package/dist/describe-resources.d.ts.map +1 -0
- package/dist/detect.d.ts +28 -1
- package/dist/detect.d.ts.map +1 -1
- package/dist/export-resources.d.ts +38 -0
- package/dist/export-resources.d.ts.map +1 -0
- package/dist/import/alerting.d.ts +40 -0
- package/dist/import/alerting.d.ts.map +1 -0
- package/dist/import/edits.d.ts +47 -0
- package/dist/import/edits.d.ts.map +1 -0
- package/dist/import/embedded.d.ts +14 -0
- package/dist/import/embedded.d.ts.map +1 -0
- package/dist/import/generator.d.ts +60 -0
- package/dist/import/generator.d.ts.map +1 -0
- package/dist/import/live-export.d.ts +108 -0
- package/dist/import/live-export.d.ts.map +1 -0
- package/dist/import/mappings.d.ts +83 -0
- package/dist/import/mappings.d.ts.map +1 -0
- package/dist/import/model.d.ts +98 -0
- package/dist/import/model.d.ts.map +1 -0
- package/dist/import/normalize-alerting.d.ts +26 -0
- package/dist/import/normalize-alerting.d.ts.map +1 -0
- package/dist/import/normalize.d.ts +74 -0
- package/dist/import/normalize.d.ts.map +1 -0
- package/dist/import/parser.d.ts +110 -0
- package/dist/import/parser.d.ts.map +1 -0
- package/dist/import/report.d.ts +26 -0
- package/dist/import/report.d.ts.map +1 -0
- package/dist/import/testdata/fixtures.d.ts +35 -0
- package/dist/import/testdata/fixtures.d.ts.map +1 -0
- package/dist/import/v2.d.ts +72 -0
- package/dist/import/v2.d.ts.map +1 -0
- package/dist/index.d.ts +26 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +18 -10
- package/dist/k8s.d.ts +107 -0
- package/dist/k8s.d.ts.map +1 -0
- package/dist/lint/audit-catalog.d.ts +2 -1
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/graf101.d.ts +2 -2
- package/dist/lint/post-synth/graf102.d.ts +1 -1
- package/dist/lint/post-synth/graf107.d.ts +1 -1
- package/dist/lint/post-synth/graf108.d.ts +8 -0
- package/dist/lint/post-synth/graf108.d.ts.map +1 -0
- package/dist/lint/post-synth/graf109.d.ts +8 -0
- package/dist/lint/post-synth/graf109.d.ts.map +1 -0
- package/dist/lint/post-synth/graf111.d.ts +8 -0
- package/dist/lint/post-synth/graf111.d.ts.map +1 -0
- package/dist/lint/post-synth/graf112.d.ts +8 -0
- package/dist/lint/post-synth/graf112.d.ts.map +1 -0
- package/dist/lint/post-synth/graf113.d.ts +8 -0
- package/dist/lint/post-synth/graf113.d.ts.map +1 -0
- package/dist/lint/post-synth/graf114.d.ts +8 -0
- package/dist/lint/post-synth/graf114.d.ts.map +1 -0
- package/dist/lint/post-synth/graf115.d.ts +8 -0
- package/dist/lint/post-synth/graf115.d.ts.map +1 -0
- package/dist/lint/post-synth/grafana-helpers.d.ts +7 -5
- 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/lint/rules/literal-secret.d.ts +7 -3
- package/dist/lint/rules/literal-secret.d.ts.map +1 -1
- package/dist/lsp/hover.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/members.d.ts +27 -0
- package/dist/members.d.ts.map +1 -0
- package/dist/meta.json +135 -0
- package/dist/okf/index.md +36 -2
- package/dist/okf/rules/GRAF002.md +3 -2
- package/dist/okf/rules/GRAF101.md +3 -2
- package/dist/okf/rules/GRAF102.md +1 -0
- package/dist/okf/rules/GRAF108.md +11 -0
- package/dist/okf/rules/GRAF109.md +15 -0
- package/dist/okf/rules/GRAF111.md +11 -0
- package/dist/okf/rules/GRAF112.md +16 -0
- package/dist/okf/rules/GRAF113.md +16 -0
- package/dist/okf/rules/GRAF114.md +11 -0
- package/dist/okf/rules/GRAF115.md +11 -0
- package/dist/okf/types/AlertListPanel.md +9 -0
- package/dist/okf/types/AlertQuery.md +9 -0
- package/dist/okf/types/AlertRule.md +9 -0
- package/dist/okf/types/AlertRuleGroup.md +9 -0
- package/dist/okf/types/BarChartPanel.md +9 -0
- package/dist/okf/types/BarGaugePanel.md +9 -0
- package/dist/okf/types/CanvasPanel.md +9 -0
- package/dist/okf/types/ClassicConditionsExpression.md +9 -0
- package/dist/okf/types/ContactPoint.md +14 -0
- package/dist/okf/types/Dashboard.md +1 -0
- package/dist/okf/types/Datasource.md +3 -2
- package/dist/okf/types/ExternalDatasource.md +15 -0
- package/dist/okf/types/FlameGraphPanel.md +9 -0
- package/dist/okf/types/GeomapPanel.md +9 -0
- package/dist/okf/types/HistogramPanel.md +9 -0
- package/dist/okf/types/MathExpression.md +9 -0
- package/dist/okf/types/MuteTiming.md +13 -0
- package/dist/okf/types/NodeGraphPanel.md +9 -0
- package/dist/okf/types/NotificationPolicy.md +9 -0
- package/dist/okf/types/NotificationTemplate.md +9 -0
- package/dist/okf/types/PieChartPanel.md +9 -0
- package/dist/okf/types/ReduceExpression.md +9 -0
- package/dist/okf/types/ResampleExpression.md +9 -0
- package/dist/okf/types/SqlExpression.md +9 -0
- package/dist/okf/types/StateTimelinePanel.md +9 -0
- package/dist/okf/types/StatusHistoryPanel.md +9 -0
- package/dist/okf/types/ThresholdExpression.md +9 -0
- package/dist/okf/types/TrendPanel.md +9 -0
- package/dist/okf/types/XYChartPanel.md +9 -0
- package/dist/op/activities/grafana-apply.d.ts +75 -0
- package/dist/op/activities/grafana-apply.d.ts.map +1 -0
- package/dist/op/activities/index.d.ts +9 -0
- package/dist/op/activities/index.d.ts.map +1 -0
- package/dist/op/builders.d.ts +24 -0
- package/dist/op/builders.d.ts.map +1 -0
- package/dist/ownership.d.ts +67 -0
- package/dist/ownership.d.ts.map +1 -0
- package/dist/panel-options.d.ts +65 -0
- package/dist/panel-options.d.ts.map +1 -0
- package/dist/panels.d.ts +46 -9
- package/dist/panels.d.ts.map +1 -1
- package/dist/pin.d.ts +29 -7
- package/dist/pin.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/promql-check.d.ts +51 -0
- package/dist/promql-check.d.ts.map +1 -0
- package/dist/query.d.ts +9 -8
- package/dist/query.d.ts.map +1 -1
- package/dist/rules/graf101.ts +3 -3
- package/dist/rules/graf102.ts +1 -1
- package/dist/rules/graf107.ts +1 -1
- package/dist/rules/graf108.ts +17 -0
- package/dist/rules/graf109.ts +17 -0
- package/dist/rules/graf111.ts +17 -0
- package/dist/rules/graf112.ts +17 -0
- package/dist/rules/graf113.ts +17 -0
- package/dist/rules/graf114.ts +17 -0
- package/dist/rules/graf115.ts +17 -0
- package/dist/rules/grafana-helpers.ts +15 -8
- package/dist/rules/literal-secret.ts +60 -6
- package/dist/schema/barchart.gen.d.ts +198 -0
- package/dist/schema/barchart.gen.d.ts.map +1 -0
- package/dist/schema/bargauge.gen.d.ts +97 -0
- package/dist/schema/bargauge.gen.d.ts.map +1 -0
- package/dist/schema/canvas.gen.d.ts +152 -0
- package/dist/schema/canvas.gen.d.ts.map +1 -0
- package/dist/schema/dashboard.gen.d.ts +196 -21
- package/dist/schema/dashboard.gen.d.ts.map +1 -1
- package/dist/schema/expr.gen.d.ts +606 -0
- package/dist/schema/expr.gen.d.ts.map +1 -0
- package/dist/schema/gauge.gen.d.ts.map +1 -1
- package/dist/schema/geomap.gen.d.ts +100 -0
- package/dist/schema/geomap.gen.d.ts.map +1 -0
- package/dist/schema/heatmap.gen.d.ts.map +1 -1
- package/dist/schema/histogram.gen.d.ts +132 -0
- package/dist/schema/histogram.gen.d.ts.map +1 -0
- package/dist/schema/index.d.ts +12 -0
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/logs.gen.d.ts +1 -0
- package/dist/schema/logs.gen.d.ts.map +1 -1
- package/dist/schema/loki.gen.d.ts.map +1 -1
- package/dist/schema/nodegraph.gen.d.ts +49 -0
- package/dist/schema/nodegraph.gen.d.ts.map +1 -0
- package/dist/schema/piechart.gen.d.ts +127 -0
- package/dist/schema/piechart.gen.d.ts.map +1 -0
- package/dist/schema/prometheus.gen.d.ts.map +1 -1
- package/dist/schema/stat.gen.d.ts.map +1 -1
- package/dist/schema/statetimeline.gen.d.ts +124 -0
- package/dist/schema/statetimeline.gen.d.ts.map +1 -0
- package/dist/schema/statushistory.gen.d.ts +114 -0
- package/dist/schema/statushistory.gen.d.ts.map +1 -0
- package/dist/schema/table.gen.d.ts +22 -3
- package/dist/schema/table.gen.d.ts.map +1 -1
- package/dist/schema/tempo.gen.d.ts.map +1 -1
- package/dist/schema/text.gen.d.ts.map +1 -1
- package/dist/schema/timeseries.gen.d.ts +5 -1
- package/dist/schema/timeseries.gen.d.ts.map +1 -1
- package/dist/schema/trend.gen.d.ts +180 -0
- package/dist/schema/trend.gen.d.ts.map +1 -0
- package/dist/schema/xychart.gen.d.ts +145 -0
- package/dist/schema/xychart.gen.d.ts.map +1 -0
- package/dist/schema-validate.d.ts +30 -4
- package/dist/schema-validate.d.ts.map +1 -1
- package/dist/serializer.d.ts +11 -3
- package/dist/serializer.d.ts.map +1 -1
- package/dist/skill-defs.d.ts.map +1 -1
- package/dist/skills/chant-grafana-alerting.md +49 -0
- package/dist/skills/chant-grafana-provisioning.md +3 -3
- package/dist/skills/chant-grafana.md +12 -6
- package/dist/spec/fetch-units-cli.d.ts +3 -0
- package/dist/spec/fetch-units-cli.d.ts.map +1 -0
- package/dist/spec/overlay.d.ts +43 -0
- package/dist/spec/overlay.d.ts.map +1 -0
- package/dist/spec/schemas.d.ts +13 -7
- package/dist/spec/schemas.d.ts.map +1 -1
- package/dist/spec/units.d.ts +35 -0
- package/dist/spec/units.d.ts.map +1 -0
- package/dist/spec/units.gen.d.ts +2 -0
- package/dist/spec/units.gen.d.ts.map +1 -0
- package/dist/transformations.d.ts +459 -0
- package/dist/transformations.d.ts.map +1 -0
- package/dist/util.d.ts +6 -0
- package/dist/util.d.ts.map +1 -1
- package/dist/validate-alerting.d.ts +65 -0
- package/dist/validate-alerting.d.ts.map +1 -0
- package/dist/validate-output.d.ts +49 -9
- package/dist/validate-output.d.ts.map +1 -1
- package/dist/validate.d.ts.map +1 -1
- package/dist/variables.d.ts +7 -2
- package/dist/variables.d.ts.map +1 -1
- package/package.json +12 -4
- package/src/actions/actions.test.ts +24 -0
- package/src/actions/grafana.ts +40 -0
- package/src/actions/index.ts +1 -0
- package/src/alerting-build.ts +376 -0
- package/src/alerting.e2e.test.ts +212 -0
- package/src/alerting.test.ts +272 -0
- package/src/alerting.ts +435 -0
- package/src/api/apply.test.ts +238 -0
- package/src/api/apply.ts +384 -0
- package/src/api/bind.ts +58 -0
- package/src/api/client.ts +148 -0
- package/src/api/converge.ts +63 -0
- package/src/api/dashboards.ts +247 -0
- package/src/api/datasources.ts +33 -0
- package/src/api/fake-grafana-writes.ts +201 -0
- package/src/api/fake-grafana.ts +186 -0
- package/src/api/folders.ts +265 -0
- package/src/api/library-panels.ts +101 -0
- package/src/build.test.ts +210 -5
- package/src/build.ts +180 -43
- package/src/catalog.ts +86 -2
- package/src/codegen/docs.ts +42 -9
- package/src/codegen/schema-types.ts +16 -6
- package/src/composites/agent-dashboard.ts +6 -3
- package/src/composites/catalog.test.ts +2 -2
- package/src/composites/catalog.ts +82 -0
- package/src/composites/composites.test.ts +73 -9
- package/src/composites/index.ts +4 -2
- package/src/composites/queries.e2e.test.ts +374 -0
- package/src/composites/red-dashboard.ts +83 -12
- package/src/composites/shared.ts +11 -0
- package/src/composites/slo-alert-rules.ts +149 -0
- package/src/config.test.ts +61 -0
- package/src/config.ts +138 -0
- package/src/contact-point-secrets.ts +63 -0
- package/src/dashboard.ts +16 -2
- package/src/datasource-refs.test.ts +56 -0
- package/src/datasource-refs.ts +189 -0
- package/src/datasource.ts +52 -0
- package/src/deep-observe-hooks.ts +252 -0
- package/src/deep-observe.test.ts +265 -0
- package/src/deep-observe.ts +164 -0
- package/src/describe-resources.test.ts +255 -0
- package/src/describe-resources.ts +209 -0
- package/src/detect.ts +79 -2
- package/src/export-resources.test.ts +129 -0
- package/src/export-resources.ts +84 -0
- package/src/generated/lexicon-grafana.json +135 -0
- package/src/import/alerting-roundtrip.test.ts +161 -0
- package/src/import/alerting.ts +500 -0
- package/src/import/edits.ts +95 -0
- package/src/import/embedded.test.ts +45 -0
- package/src/import/embedded.ts +47 -0
- package/src/import/generated-types.e2e.test.ts +118 -0
- package/src/import/generator.test.ts +210 -0
- package/src/import/generator.ts +462 -0
- package/src/import/live-export.ts +264 -0
- package/src/import/mappings.ts +264 -0
- package/src/import/model.ts +110 -0
- package/src/import/normalize-alerting.ts +101 -0
- package/src/import/normalize.test.ts +117 -0
- package/src/import/normalize.ts +364 -0
- package/src/import/parser.test.ts +440 -0
- package/src/import/parser.ts +1236 -0
- package/src/import/report.ts +62 -0
- package/src/import/roundtrip.test.ts +468 -0
- package/src/import/testdata/fixtures.ts +129 -0
- package/src/import/v2.test.ts +292 -0
- package/src/import/v2.ts +803 -0
- package/src/import.e2e.test.ts +31 -84
- package/src/import.test.ts +9 -3
- package/src/index.ts +190 -5
- package/src/k8s.test.ts +98 -0
- package/src/k8s.ts +197 -0
- package/src/lint/audit-catalog.ts +60 -3
- package/src/lint/core-lint.test.ts +43 -0
- package/src/lint/post-synth/graf101.ts +3 -3
- package/src/lint/post-synth/graf102.ts +1 -1
- package/src/lint/post-synth/graf107.ts +1 -1
- package/src/lint/post-synth/graf108.ts +17 -0
- package/src/lint/post-synth/graf109.ts +17 -0
- package/src/lint/post-synth/graf111.ts +17 -0
- package/src/lint/post-synth/graf112.ts +17 -0
- package/src/lint/post-synth/graf113.ts +17 -0
- package/src/lint/post-synth/graf114.ts +17 -0
- package/src/lint/post-synth/graf115.ts +17 -0
- package/src/lint/post-synth/grafana-helpers.ts +15 -8
- package/src/lint/post-synth/index.ts +14 -0
- package/src/lint/post-synth/post-synth.test.ts +235 -11
- package/src/lint/rules/literal-secret.ts +60 -6
- package/src/lint/rules/rules.test.ts +27 -0
- package/src/lsp/completions.test.ts +5 -0
- package/src/lsp/hover.test.ts +7 -0
- package/src/lsp/hover.ts +8 -1
- package/src/members.ts +68 -0
- package/src/observe.e2e.test.ts +255 -0
- package/src/op/activities/grafana-apply.e2e.test.ts +175 -0
- package/src/op/activities/grafana-apply.test.ts +229 -0
- package/src/op/activities/grafana-apply.ts +171 -0
- package/src/op/activities/index.ts +8 -0
- package/src/op/builders.test.ts +40 -0
- package/src/op/builders.ts +34 -0
- package/src/ownership.ts +103 -0
- package/src/panel-options.ts +66 -0
- package/src/panels.test.ts +51 -0
- package/src/panels.ts +148 -10
- package/src/pin.ts +57 -6
- package/src/plugin.test.ts +5 -2
- package/src/plugin.ts +66 -0
- package/src/promql-check.test.ts +108 -0
- package/src/promql-check.ts +189 -0
- package/src/query.ts +13 -8
- package/src/schema/barchart.gen.ts +221 -0
- package/src/schema/bargauge.gen.ts +111 -0
- package/src/schema/canvas.gen.ts +176 -0
- package/src/schema/dashboard.gen.ts +206 -22
- package/src/schema/expr.gen.ts +618 -0
- package/src/schema/gauge.gen.ts +2 -1
- package/src/schema/geomap.gen.ts +112 -0
- package/src/schema/heatmap.gen.ts +2 -1
- package/src/schema/histogram.gen.ts +151 -0
- package/src/schema/index.ts +12 -0
- package/src/schema/logs.gen.ts +3 -1
- package/src/schema/loki.gen.ts +2 -1
- package/src/schema/nodegraph.gen.ts +58 -0
- package/src/schema/piechart.gen.ts +146 -0
- package/src/schema/prometheus.gen.ts +2 -1
- package/src/schema/stat.gen.ts +2 -1
- package/src/schema/statetimeline.gen.ts +143 -0
- package/src/schema/statushistory.gen.ts +132 -0
- package/src/schema/table.gen.ts +27 -4
- package/src/schema/tempo.gen.ts +2 -1
- package/src/schema/text.gen.ts +2 -1
- package/src/schema/timeseries.gen.ts +7 -2
- package/src/schema/trend.gen.ts +208 -0
- package/src/schema/xychart.gen.ts +167 -0
- package/src/schema-overlay.test.ts +282 -0
- package/src/schema-validate.ts +141 -50
- package/src/schema.test.ts +4 -2
- package/src/serializer.test.ts +13 -1
- package/src/serializer.ts +11 -3
- package/src/skill-defs.ts +17 -0
- package/src/skills/chant-grafana-alerting.md +49 -0
- package/src/skills/chant-grafana-provisioning.md +3 -3
- package/src/skills/chant-grafana.md +12 -6
- package/src/spec/fetch-cli.ts +3 -3
- package/src/spec/fetch-units-cli.ts +37 -0
- package/src/spec/overlay/canvas.overlay.json +142 -0
- package/src/spec/overlay/dashboard.overlay.json +378 -0
- package/src/spec/overlay/expr.overlay.json +14 -0
- package/src/spec/overlay/geomap.overlay.json +35 -0
- package/src/spec/overlay/logs.overlay.json +14 -0
- package/src/spec/overlay/nodegraph.overlay.json +31 -0
- package/src/spec/overlay/piechart.overlay.json +17 -0
- package/src/spec/overlay/table.overlay.json +84 -0
- package/src/spec/overlay/timeseries.overlay.json +21 -0
- package/src/spec/overlay/trend.overlay.json +17 -0
- package/src/spec/overlay/xychart.overlay.json +16 -0
- package/src/spec/overlay.ts +105 -0
- package/src/spec/schemas/barchart.jsonschema.json +419 -0
- package/src/spec/schemas/bargauge.jsonschema.json +224 -0
- package/src/spec/schemas/canvas.jsonschema.json +356 -0
- package/src/spec/schemas/dashboardv2.jsonschema.json +3387 -0
- package/src/spec/schemas/expr.jsonschema.json +987 -0
- package/src/spec/schemas/geomap.jsonschema.json +220 -0
- package/src/spec/schemas/histogram.jsonschema.json +308 -0
- package/src/spec/schemas/nodegraph.jsonschema.json +77 -0
- package/src/spec/schemas/piechart.jsonschema.json +275 -0
- package/src/spec/schemas/statetimeline.jsonschema.json +324 -0
- package/src/spec/schemas/statushistory.jsonschema.json +292 -0
- package/src/spec/schemas/trend.jsonschema.json +449 -0
- package/src/spec/schemas/xychart.jsonschema.json +428 -0
- package/src/spec/schemas.ts +22 -10
- package/src/spec/units.gen.ts +281 -0
- package/src/spec/units.ts +77 -0
- package/src/transformations.test.ts +65 -0
- package/src/transformations.ts +574 -0
- package/src/util.ts +8 -0
- package/src/validate-alerting.test.ts +255 -0
- package/src/validate-alerting.ts +428 -0
- package/src/validate-output.ts +332 -101
- package/src/validate.ts +14 -0
- package/src/variables.ts +7 -2
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { GrafanaClient } from "./client";
|
|
3
|
+
import { applyGrafana, carriesMarker, planFromDashboards, planRefs, type GrafanaApplyPlan } from "./apply";
|
|
4
|
+
import { converged } from "./converge";
|
|
5
|
+
import { childrenFirst, folderUidFor, foldersForDashboards, type LiveFolder } from "./folders";
|
|
6
|
+
import { libraryPanelsOf, withoutExportKeys } from "./library-panels";
|
|
7
|
+
import { emptyGrafana, writableGrafana, type WritableGrafanaState } from "./fake-grafana-writes";
|
|
8
|
+
|
|
9
|
+
type Json = Record<string, unknown>;
|
|
10
|
+
|
|
11
|
+
const TARGET = { url: "http://grafana.test", source: "grafana.profiles.test" };
|
|
12
|
+
const MARKER = { stack: "shop", env: "prod" };
|
|
13
|
+
const OURS = { "app.kubernetes.io/managed-by": "chant", "chant.intentius.io/stack": "shop", "chant.intentius.io/env": "prod" };
|
|
14
|
+
|
|
15
|
+
function dashboard(uid: string, extra: Json = {}): Json {
|
|
16
|
+
return { uid, title: uid, schemaVersion: 41, time: { from: "now-6h", to: "now" }, panels: [{ id: 1, type: "stat", title: "a", gridPos: { x: 0, y: 0, w: 6, h: 4 }, options: {}, datasource: null }], ...extra };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const LIB = { name: "Shared burn", uid: "burn", kind: 1, model: { type: "stat", title: "Burn", targets: [] } };
|
|
20
|
+
|
|
21
|
+
function client(state: WritableGrafanaState, calls: string[] = []): GrafanaClient {
|
|
22
|
+
return new GrafanaClient(writableGrafana(state, calls), TARGET);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const writes = (calls: string[]) => calls.filter((c) => !c.startsWith("GET "));
|
|
26
|
+
|
|
27
|
+
describe("planFromDashboards", () => {
|
|
28
|
+
test("one folder per title, library panels from __elements, export-only keys dropped", () => {
|
|
29
|
+
const plan = planFromDashboards([
|
|
30
|
+
{ json: dashboard("a", { __inputs: [], __requires: [], __elements: { burn: LIB } }), folder: "Team A" },
|
|
31
|
+
{ json: dashboard("b", { __elements: { burn: LIB } }), folder: "Team A" },
|
|
32
|
+
{ json: dashboard("c") },
|
|
33
|
+
]);
|
|
34
|
+
expect(plan.folders).toEqual([{ uid: "team-a", title: "Team A" }]);
|
|
35
|
+
expect(plan.libraryPanels).toEqual([{ uid: "burn", name: "Shared burn", model: LIB.model, folderUid: "team-a" }]);
|
|
36
|
+
expect(plan.dashboards.map((d) => [d.uid, d.folderUid])).toEqual([["a", "team-a"], ["b", "team-a"], ["c", undefined]]);
|
|
37
|
+
expect(Object.keys(plan.dashboards[0].json)).not.toEqual(expect.arrayContaining(["__elements"]));
|
|
38
|
+
expect(planRefs(plan).map((r) => `${r.kind}/${r.name}`)).toEqual(["Folder/team-a", "LibraryPanel/burn", "Dashboard/a", "Dashboard/b", "Dashboard/c"]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("a library variable in __elements is reported unsupported, not dropped", () => {
|
|
42
|
+
const plan = planFromDashboards([{ json: dashboard("a", { __elements: { v: { uid: "v", name: "v", kind: 2, model: {} } } }) }]);
|
|
43
|
+
expect(plan.unsupported).toEqual([expect.objectContaining({ kind: "LibraryElement", name: "v" })]);
|
|
44
|
+
expect(planRefs(plan).map((r) => `${r.kind}/${r.name}`)).toContain("LibraryElement/v");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("refuses a plan no apply could satisfy", () => {
|
|
48
|
+
expect(() => planFromDashboards([{ json: { title: "x" } }])).toThrow(/has no uid/);
|
|
49
|
+
expect(() => planFromDashboards([{ json: dashboard("a") }, { json: dashboard("a") }])).toThrow(/two dashboards have the uid "a"/);
|
|
50
|
+
const other = { ...LIB, model: { ...LIB.model, title: "Other" } };
|
|
51
|
+
expect(() => planFromDashboards([{ json: dashboard("a", { __elements: { burn: LIB } }) }, { json: dashboard("b", { __elements: { burn: other } }) }])).toThrow(/different content/);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("folders", () => {
|
|
56
|
+
test("the uid is the slug of the title, and two titles that share one are refused", () => {
|
|
57
|
+
expect(folderUidFor("Team A / Payments")).toBe("team-a-payments");
|
|
58
|
+
expect(() => foldersForDashboards([{ folder: "Team A" }, { folder: "team-a" }])).toThrow(/would both get the uid "team-a"/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("children come before their parents for deletion", () => {
|
|
62
|
+
const f = (uid: string, parentUid?: string): LiveFolder => ({ uid, title: uid, ...(parentUid ? { parentUid } : {}), labels: {}, via: "apis", address: uid });
|
|
63
|
+
expect(childrenFirst([f("root"), f("leaf", "mid"), f("mid", "root")]).map((x) => x.uid)).toEqual(["leaf", "mid", "root"]);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("library panels", () => {
|
|
68
|
+
test("uid falls back to the __elements key; export-only keys are dropped", () => {
|
|
69
|
+
const { panels } = libraryPanelsOf({ __elements: { k: { name: "n", kind: 1, model: { type: "stat" } } } });
|
|
70
|
+
expect(panels).toEqual([{ uid: "k", name: "n", model: { type: "stat" } }]);
|
|
71
|
+
expect(withoutExportKeys({ uid: "a", __elements: {}, __inputs: [], __requires: [] })).toEqual({ uid: "a" });
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("converged", () => {
|
|
76
|
+
test("what Grafana adds on top is ignored; what the build sends must be there", () => {
|
|
77
|
+
const want = dashboard("a", { annotations: { list: [{ name: "Deploys" }] } });
|
|
78
|
+
const live = {
|
|
79
|
+
...want,
|
|
80
|
+
uid: undefined,
|
|
81
|
+
id: 9,
|
|
82
|
+
version: 4,
|
|
83
|
+
schemaVersion: 42,
|
|
84
|
+
fiscalYearStartMonth: 0,
|
|
85
|
+
annotations: { list: [{ builtIn: 1, name: "Annotations & Alerts" }, { name: "Deploys" }] },
|
|
86
|
+
panels: [{ id: 1, type: "stat", title: "a", gridPos: { x: 0, y: 0, w: 6, h: 4 } }],
|
|
87
|
+
};
|
|
88
|
+
expect(converged(want, live, { dashboard: true })).toBe(true);
|
|
89
|
+
expect(converged(want, { ...live, title: "edited" }, { dashboard: true })).toBe(false);
|
|
90
|
+
expect(converged(want, { ...live, panels: [...live.panels, { id: 2, type: "text" }] }, { dashboard: true })).toBe(false);
|
|
91
|
+
expect(converged({ tags: [] }, {})).toBe(true);
|
|
92
|
+
expect(converged({ tags: ["a"] }, {})).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("carriesMarker", () => {
|
|
97
|
+
test("this stack and this env only", () => {
|
|
98
|
+
expect(carriesMarker(OURS, MARKER)).toBe(true);
|
|
99
|
+
expect(carriesMarker({ ...OURS, "chant.intentius.io/stack": "other" }, MARKER)).toBe(false);
|
|
100
|
+
expect(carriesMarker({ ...OURS, "chant.intentius.io/env": "staging" }, MARKER)).toBe(false);
|
|
101
|
+
const { "chant.intentius.io/env": _env, ...envless } = OURS;
|
|
102
|
+
expect(carriesMarker(envless, MARKER)).toBe(false);
|
|
103
|
+
expect(carriesMarker(envless, { stack: "shop" })).toBe(true);
|
|
104
|
+
expect(carriesMarker({}, MARKER)).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
function planWithEverything(): GrafanaApplyPlan {
|
|
109
|
+
return planFromDashboards([
|
|
110
|
+
{ json: dashboard("api", { __elements: { burn: LIB }, panels: [{ id: 1, gridPos: { x: 0, y: 0, w: 6, h: 4 }, libraryPanel: { uid: "burn", name: "Shared burn" } }] }), folder: "Team A" },
|
|
111
|
+
{ json: dashboard("home") },
|
|
112
|
+
]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
describe.each(["v1", "v1beta1"] as const)("applyGrafana over /apis (%s)", (api) => {
|
|
116
|
+
test("creates the folder, then the library panel, then the dashboards, stamped with the marker", async () => {
|
|
117
|
+
const state = emptyGrafana(api);
|
|
118
|
+
const calls: string[] = [];
|
|
119
|
+
const out = await applyGrafana(client(state, calls), planWithEverything(), { marker: MARKER });
|
|
120
|
+
expect(out.api).toBe(`apis/${api}`);
|
|
121
|
+
expect(out.applied.map((a) => `${a.action} ${a.kind}/${a.name}`)).toEqual(["created Folder/team-a", "created LibraryPanel/burn", "created Dashboard/api", "created Dashboard/home"]);
|
|
122
|
+
expect(writes(calls).map((c) => c.split(" ")[0] + " " + c.split("/").slice(-1)[0])).toEqual(["POST folders", "POST library-elements", "POST dashboards", "POST dashboards"]);
|
|
123
|
+
expect(state.dashboards.api.labels).toEqual(OURS);
|
|
124
|
+
expect(state.dashboards.api.annotations).toEqual({ "grafana.app/folder": "team-a" });
|
|
125
|
+
expect(state.dashboards.api.spec.__elements).toBeUndefined();
|
|
126
|
+
expect(state.folders["team-a"]).toMatchObject({ spec: { title: "Team A" }, labels: OURS });
|
|
127
|
+
expect(state.libraryElements.burn).toMatchObject({ name: "Shared burn", folderUid: "team-a" });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("a second apply of the same plan is unchanged and writes nothing", async () => {
|
|
131
|
+
const state = emptyGrafana(api);
|
|
132
|
+
await applyGrafana(client(state), planWithEverything(), { marker: MARKER });
|
|
133
|
+
const calls: string[] = [];
|
|
134
|
+
const out = await applyGrafana(client(state, calls), planWithEverything(), { marker: MARKER });
|
|
135
|
+
expect(out.applied.every((a) => a.action === "unchanged")).toBe(true);
|
|
136
|
+
expect(writes(calls)).toEqual([]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("an edited declaration, or an edit made in Grafana, is updated", async () => {
|
|
140
|
+
const state = emptyGrafana(api);
|
|
141
|
+
await applyGrafana(client(state), planWithEverything(), { marker: MARKER });
|
|
142
|
+
state.dashboards.home.spec.title = "saved in the UI";
|
|
143
|
+
const edited = planFromDashboards([
|
|
144
|
+
{ json: dashboard("api", { __elements: { burn: { ...LIB, model: { ...LIB.model, title: "Burn rate" } } } }), folder: "Team A" },
|
|
145
|
+
{ json: dashboard("home") },
|
|
146
|
+
]);
|
|
147
|
+
const out = await applyGrafana(client(state), edited, { marker: MARKER });
|
|
148
|
+
expect(out.applied.map((a) => `${a.action} ${a.kind}/${a.name}`)).toEqual(["unchanged Folder/team-a", "updated LibraryPanel/burn", "updated Dashboard/api", "updated Dashboard/home"]);
|
|
149
|
+
expect(state.dashboards.home.spec.title).toBe("home");
|
|
150
|
+
expect(state.libraryElements.burn).toMatchObject({ version: 2, model: { title: "Burn rate" } });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("a foreign dashboard at a declared uid is taken over, keeping its other labels", async () => {
|
|
154
|
+
const state = emptyGrafana(api);
|
|
155
|
+
state.dashboards.home = { spec: { title: "theirs" }, labels: { team: "sre" }, annotations: {} };
|
|
156
|
+
await applyGrafana(client(state), planWithEverything(), { marker: MARKER });
|
|
157
|
+
expect(state.dashboards.home.labels).toEqual({ team: "sre", ...OURS });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("prune deletes this project's orphans only, dashboards before folders, children before parents", async () => {
|
|
161
|
+
const state = emptyGrafana(api);
|
|
162
|
+
const other = { ...OURS, "chant.intentius.io/stack": "other" };
|
|
163
|
+
state.dashboards = {
|
|
164
|
+
orphan: { spec: { title: "o" }, labels: OURS, annotations: { "grafana.app/folder": "old-child" } },
|
|
165
|
+
foreign: { spec: { title: "f" }, labels: {}, annotations: {} },
|
|
166
|
+
theirs: { spec: { title: "t" }, labels: other, annotations: {} },
|
|
167
|
+
staging: { spec: { title: "s" }, labels: { ...OURS, "chant.intentius.io/env": "staging" }, annotations: {} },
|
|
168
|
+
};
|
|
169
|
+
state.folders = {
|
|
170
|
+
"old-parent": { spec: { title: "Old" }, labels: OURS, annotations: {} },
|
|
171
|
+
"old-child": { spec: { title: "Old child" }, labels: OURS, annotations: { "grafana.app/folder": "old-parent" } },
|
|
172
|
+
"kept-busy": { spec: { title: "Busy" }, labels: OURS, annotations: {} },
|
|
173
|
+
"their-folder": { spec: { title: "Theirs" }, labels: {}, annotations: {} },
|
|
174
|
+
};
|
|
175
|
+
state.dashboards.saved = { spec: { title: "saved in the UI" }, labels: {}, annotations: { "grafana.app/folder": "kept-busy" } };
|
|
176
|
+
const calls: string[] = [];
|
|
177
|
+
const out = await applyGrafana(client(state, calls), planWithEverything(), { marker: MARKER, prune: true });
|
|
178
|
+
expect(out.pruned.map((p) => `${p.kind}/${p.name}`)).toEqual(["Dashboard/orphan", "Folder/old-child", "Folder/old-parent"]);
|
|
179
|
+
expect(calls.filter((c) => c.startsWith("DELETE")).map((c) => c.split("/").pop())).toEqual(["orphan", "old-child", "old-parent"]);
|
|
180
|
+
expect(Object.keys(state.dashboards).sort()).toEqual(["api", "foreign", "home", "saved", "staging", "theirs"]);
|
|
181
|
+
expect(out.notAttempted).toEqual([expect.objectContaining({ kind: "Folder", name: "kept-busy", reason: "not-prunable", detail: expect.stringContaining("still holds 1 item(s)") })]);
|
|
182
|
+
expect(out.notPrunable).toEqual([{ kind: "LibraryPanel", detail: expect.stringContaining("no labels") }]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("prune without an ownership stack deletes nothing and says why", async () => {
|
|
186
|
+
const state = emptyGrafana(api);
|
|
187
|
+
state.dashboards.orphan = { spec: { title: "o" }, labels: OURS, annotations: {} };
|
|
188
|
+
const calls: string[] = [];
|
|
189
|
+
const out = await applyGrafana(client(state, calls), planFromDashboards([{ json: dashboard("home") }]), { prune: true });
|
|
190
|
+
expect(calls.filter((c) => c.startsWith("DELETE"))).toEqual([]);
|
|
191
|
+
expect(out.notPrunable.map((n) => n.kind)).toEqual(["Dashboard", "Folder"]);
|
|
192
|
+
expect(state.dashboards.home.labels).toEqual({ "app.kubernetes.io/managed-by": "chant" });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("a refused token is no-credentials for every resource, with nothing written", async () => {
|
|
196
|
+
const state = { ...emptyGrafana(api), status: 401 };
|
|
197
|
+
const out = await applyGrafana(client(state), planWithEverything(), { marker: MARKER });
|
|
198
|
+
expect(out.applied).toEqual([]);
|
|
199
|
+
expect(out.notAttempted.map((n) => `${n.kind}/${n.name}:${n.reason}`)).toEqual([
|
|
200
|
+
"Folder/team-a:no-credentials",
|
|
201
|
+
"LibraryPanel/burn:no-credentials",
|
|
202
|
+
"Dashboard/api:no-credentials",
|
|
203
|
+
"Dashboard/home:no-credentials",
|
|
204
|
+
]);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("a failed write throws with the status and path", async () => {
|
|
208
|
+
const state = emptyGrafana(api);
|
|
209
|
+
const http = writableGrafana(state);
|
|
210
|
+
const failing = new GrafanaClient(async (method, path, body) => (method === "POST" && path.endsWith("/dashboards") ? { status: 500, json: { message: "boom" } } : http(method, path, body)), TARGET);
|
|
211
|
+
await expect(applyGrafana(failing, planFromDashboards([{ json: dashboard("home") }]), { marker: MARKER })).rejects.toThrow(/POST .*\/dashboards returned 500: boom/);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("applyGrafana on Grafana 11 (legacy API)", () => {
|
|
216
|
+
test("writes over /api/dashboards/db and /api/folders, unchanged the second time", async () => {
|
|
217
|
+
const state = emptyGrafana("legacy");
|
|
218
|
+
const calls: string[] = [];
|
|
219
|
+
const first = await applyGrafana(client(state, calls), planWithEverything(), { marker: MARKER });
|
|
220
|
+
expect(first.api).toBe("legacy");
|
|
221
|
+
expect(first.applied.map((a) => `${a.action} ${a.kind}/${a.name}`)).toEqual(["created Folder/team-a", "created LibraryPanel/burn", "created Dashboard/api", "created Dashboard/home"]);
|
|
222
|
+
expect(writes(calls)).toEqual(["POST /api/folders", "POST /api/library-elements", "POST /api/dashboards/db", "POST /api/dashboards/db"]);
|
|
223
|
+
expect(state.dashboards.api.annotations).toEqual({ "grafana.app/folder": "team-a" });
|
|
224
|
+
const again: string[] = [];
|
|
225
|
+
const second = await applyGrafana(client(state, again), planWithEverything(), { marker: MARKER });
|
|
226
|
+
expect(second.applied.every((a) => a.action === "unchanged")).toBe(true);
|
|
227
|
+
expect(writes(again)).toEqual([]);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("prune cannot read a marker there, so it deletes nothing and reports both kinds", async () => {
|
|
231
|
+
const state = emptyGrafana("legacy");
|
|
232
|
+
state.dashboards.orphan = { spec: { title: "o" }, labels: {}, annotations: {} };
|
|
233
|
+
const calls: string[] = [];
|
|
234
|
+
const out = await applyGrafana(client(state, calls), planFromDashboards([{ json: dashboard("home") }]), { marker: MARKER, prune: true });
|
|
235
|
+
expect(calls.filter((c) => c.startsWith("DELETE"))).toEqual([]);
|
|
236
|
+
expect(out.notPrunable.map((n) => n.kind)).toEqual(["Dashboard", "Folder"]);
|
|
237
|
+
});
|
|
238
|
+
});
|
package/src/api/apply.ts
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Grafana API applier (#2948): folders, library panels and dashboards
|
|
3
|
+
* written over Grafana's HTTP API, stamped with chant's ownership labels,
|
|
4
|
+
* and an owned-only prune.
|
|
5
|
+
*
|
|
6
|
+
* ## The API path
|
|
7
|
+
*
|
|
8
|
+
* The server's dashboard API is discovered once per client
|
|
9
|
+
* (`dashboardApi` in ./dashboards.ts, shared with observe):
|
|
10
|
+
*
|
|
11
|
+
* - **Grafana 12.4 and 13.x**: `dashboard.grafana.app` (`v1beta1` on 12.4,
|
|
12
|
+
* `v1` on 13.x). A dashboard is created with `POST` to the collection and
|
|
13
|
+
* updated with `PUT` to its name, without a `resourceVersion`, which both
|
|
14
|
+
* versions accept as an unconditional update. The resource carries the
|
|
15
|
+
* classic JSON in `spec`, chant's labels in `metadata.labels`
|
|
16
|
+
* (`grafanaOwnershipLabels`, the marker observe reads back), and its
|
|
17
|
+
* folder in the `grafana.app/folder` annotation.
|
|
18
|
+
* - **Grafana 11**: `POST /api/dashboards/db` with `overwrite: true` and the
|
|
19
|
+
* folder's uid. There is no metadata channel, so nothing is stamped and
|
|
20
|
+
* nothing is pruned: the prune reports both kinds as `not-prunable`.
|
|
21
|
+
*
|
|
22
|
+
* Folders are ./folders.ts and library panels ./library-panels.ts. The write
|
|
23
|
+
* order is folders (parents first), then library panels, then dashboards, so
|
|
24
|
+
* nothing is written before what it references.
|
|
25
|
+
*
|
|
26
|
+
* ## Unchanged
|
|
27
|
+
*
|
|
28
|
+
* Every write is preceded by a read, and a resource whose live content
|
|
29
|
+
* already covers what would be sent (./converge.ts), with the same folder
|
|
30
|
+
* and labels, is `unchanged` and not written.
|
|
31
|
+
*
|
|
32
|
+
* ## Prune
|
|
33
|
+
*
|
|
34
|
+
* Only with `prune`, and only what carries this project's marker: chant's
|
|
35
|
+
* managed-by label with the same stack and the same env (an env-less marker
|
|
36
|
+
* matches only an env-less apply). Dashboards go first, then folders, a
|
|
37
|
+
* child before its parent, and a folder that still holds anything after
|
|
38
|
+
* that (a dashboard saved into it in the UI, a library panel) is left, as
|
|
39
|
+
* `not-prunable` with what it holds. Library panels are never pruned: their
|
|
40
|
+
* API has no labels.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { hasOwnershipMarker, type OwnershipMarker } from "@intentius/chant/ownership";
|
|
44
|
+
import type { NotAttemptedReason } from "@intentius/chant/apply";
|
|
45
|
+
import { GrafanaApiError, type GrafanaClient } from "./client";
|
|
46
|
+
import { DASHBOARD_GROUP, classicDashboardOf, dashboardApi, dashboardPath, listDashboards, readDashboard, type DashboardApi } from "./dashboards";
|
|
47
|
+
import {
|
|
48
|
+
FOLDER_ANNOTATION,
|
|
49
|
+
childrenFirst,
|
|
50
|
+
deleteFolder,
|
|
51
|
+
ensureFolder,
|
|
52
|
+
folderApi,
|
|
53
|
+
folderContentCount,
|
|
54
|
+
foldersForDashboards,
|
|
55
|
+
listFolders,
|
|
56
|
+
send,
|
|
57
|
+
type FolderPlan,
|
|
58
|
+
} from "./folders";
|
|
59
|
+
import { ensureLibraryPanel, libraryPanelsOf, withoutExportKeys, type LibraryPanelPlan } from "./library-panels";
|
|
60
|
+
import { converged } from "./converge";
|
|
61
|
+
import { GRAFANA_OWNERSHIP_KEYS, grafanaOwnershipLabels } from "../ownership";
|
|
62
|
+
|
|
63
|
+
type Json = Record<string, unknown>;
|
|
64
|
+
|
|
65
|
+
function isObject(v: unknown): v is Json {
|
|
66
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The kinds the applier names in its result: Grafana's own type names. */
|
|
70
|
+
export const GRAFANA_APPLY_KINDS = {
|
|
71
|
+
dashboard: "Dashboard",
|
|
72
|
+
folder: "Folder",
|
|
73
|
+
libraryPanel: "LibraryPanel",
|
|
74
|
+
/** An `__elements` entry that is not a panel (a library variable), which the applier cannot write. */
|
|
75
|
+
libraryElement: "LibraryElement",
|
|
76
|
+
} as const;
|
|
77
|
+
|
|
78
|
+
/** One dashboard a build needs. */
|
|
79
|
+
export interface DashboardPlan {
|
|
80
|
+
readonly uid: string;
|
|
81
|
+
/** The classic dashboard JSON, without the export-only keys. */
|
|
82
|
+
readonly json: Json;
|
|
83
|
+
readonly folderUid?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Everything one apply writes, in the order it writes it. */
|
|
87
|
+
export interface GrafanaApplyPlan {
|
|
88
|
+
readonly folders: FolderPlan[];
|
|
89
|
+
readonly libraryPanels: LibraryPanelPlan[];
|
|
90
|
+
readonly dashboards: DashboardPlan[];
|
|
91
|
+
/** What the input declared that the applier has no writer for. */
|
|
92
|
+
readonly unsupported: Array<{ kind: string; name: string; detail: string }>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A dashboard as the build hands it over: its JSON and the folder title it names. */
|
|
96
|
+
export interface BuiltDashboardInput {
|
|
97
|
+
readonly json: Json;
|
|
98
|
+
readonly folder?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function sameJson(a: unknown, b: unknown): boolean {
|
|
102
|
+
return converged(a, b) && converged(b, a);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Turn built dashboards into an apply plan: the folders they name, the
|
|
107
|
+
* library panels their `__elements` carry, and the dashboards themselves.
|
|
108
|
+
* Throws on a plan no apply could satisfy: a dashboard with no uid, two
|
|
109
|
+
* dashboards with one uid, or one library panel uid with two models.
|
|
110
|
+
*/
|
|
111
|
+
export function planFromDashboards(dashboards: readonly BuiltDashboardInput[]): GrafanaApplyPlan {
|
|
112
|
+
const folders = foldersForDashboards(dashboards);
|
|
113
|
+
const folderUid = new Map(folders.map((f) => [f.title, f.uid]));
|
|
114
|
+
const libraryPanels = new Map<string, LibraryPanelPlan>();
|
|
115
|
+
const unsupported: GrafanaApplyPlan["unsupported"] = [];
|
|
116
|
+
const out: DashboardPlan[] = [];
|
|
117
|
+
const seen = new Set<string>();
|
|
118
|
+
for (const d of dashboards) {
|
|
119
|
+
const uid = d.json.uid;
|
|
120
|
+
if (typeof uid !== "string" || uid === "") throw new Error(`grafana apply: dashboard "${String(d.json.title ?? "?")}" has no uid`);
|
|
121
|
+
if (seen.has(uid)) throw new Error(`grafana apply: two dashboards have the uid "${uid}"`);
|
|
122
|
+
seen.add(uid);
|
|
123
|
+
const inFolder = d.folder ? folderUid.get(d.folder) : undefined;
|
|
124
|
+
const { panels, skipped } = libraryPanelsOf(d.json, inFolder);
|
|
125
|
+
for (const p of panels) {
|
|
126
|
+
const prior = libraryPanels.get(p.uid);
|
|
127
|
+
if (prior && (prior.name !== p.name || !sameJson(prior.model, p.model))) {
|
|
128
|
+
throw new Error(`grafana apply: library panel "${p.uid}" is carried by more than one dashboard with different content`);
|
|
129
|
+
}
|
|
130
|
+
if (!prior) libraryPanels.set(p.uid, p);
|
|
131
|
+
}
|
|
132
|
+
for (const name of skipped) {
|
|
133
|
+
unsupported.push({ kind: GRAFANA_APPLY_KINDS.libraryElement, name, detail: `dashboard "${uid}" carries it in __elements, and only library panels (kind 1) can be applied` });
|
|
134
|
+
}
|
|
135
|
+
out.push({ uid, json: withoutExportKeys(d.json), ...(inFolder ? { folderUid: inFolder } : {}) });
|
|
136
|
+
}
|
|
137
|
+
return { folders: parentsFirst(folders), libraryPanels: [...libraryPanels.values()], dashboards: out, unsupported };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function parentsFirst(folders: FolderPlan[]): FolderPlan[] {
|
|
141
|
+
const byUid = new Map(folders.map((f) => [f.uid, f]));
|
|
142
|
+
const out: FolderPlan[] = [];
|
|
143
|
+
const done = new Set<string>();
|
|
144
|
+
const visit = (f: FolderPlan, active: Set<string>): void => {
|
|
145
|
+
if (done.has(f.uid)) return;
|
|
146
|
+
if (active.has(f.uid)) throw new Error(`grafana apply: folder "${f.uid}" is its own ancestor`);
|
|
147
|
+
active.add(f.uid);
|
|
148
|
+
const parent = f.parentUid ? byUid.get(f.parentUid) : undefined;
|
|
149
|
+
if (parent) visit(parent, active);
|
|
150
|
+
done.add(f.uid);
|
|
151
|
+
out.push(f);
|
|
152
|
+
};
|
|
153
|
+
for (const f of folders) visit(f, new Set());
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Every resource in a plan, as `{ kind, name }`: what the result must account for. */
|
|
158
|
+
export function planRefs(plan: GrafanaApplyPlan): Array<{ kind: string; name: string }> {
|
|
159
|
+
return [
|
|
160
|
+
...plan.folders.map((f) => ({ kind: GRAFANA_APPLY_KINDS.folder, name: f.uid })),
|
|
161
|
+
...plan.libraryPanels.map((p) => ({ kind: GRAFANA_APPLY_KINDS.libraryPanel, name: p.uid })),
|
|
162
|
+
...plan.dashboards.map((d) => ({ kind: GRAFANA_APPLY_KINDS.dashboard, name: d.uid })),
|
|
163
|
+
...plan.unsupported.map((u) => ({ kind: u.kind, name: u.name })),
|
|
164
|
+
];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export type AppliedAction = "created" | "updated" | "unchanged";
|
|
168
|
+
|
|
169
|
+
/** What the applier did, in its own terms. `toApplyResult` projects it onto core's envelope. */
|
|
170
|
+
export interface GrafanaApplyOutcome {
|
|
171
|
+
/** Where the binding came from: `grafana.profiles.prod`, `env GRAFANA_URL`. Empty when nothing was bound. */
|
|
172
|
+
target: string;
|
|
173
|
+
/** The dashboard API written to: `apis/v1`, `apis/v1beta1` or `legacy`. Empty when nothing was written. */
|
|
174
|
+
api: string;
|
|
175
|
+
applied: Array<{ kind: string; name: string; action: AppliedAction; address: string }>;
|
|
176
|
+
pruned: Array<{ kind: string; name: string; deleted: boolean; address: string }>;
|
|
177
|
+
notAttempted: Array<{ kind: string; name: string; reason: NotAttemptedReason; detail?: string }>;
|
|
178
|
+
/** Kinds the prune could not consider at all, and why. Empty without `prune`. */
|
|
179
|
+
notPrunable: Array<{ kind: string; detail: string }>;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface ApplyGrafanaOptions {
|
|
183
|
+
/**
|
|
184
|
+
* The stack and env stamped on every dashboard and folder, and the only
|
|
185
|
+
* marker the prune deletes. Without it, only the managed-by label is
|
|
186
|
+
* stamped and the prune does nothing (it reports why).
|
|
187
|
+
*/
|
|
188
|
+
marker?: OwnershipMarker;
|
|
189
|
+
/** Delete this project's dashboards and folders that the plan no longer has. Off by default. */
|
|
190
|
+
prune?: boolean;
|
|
191
|
+
signal?: AbortSignal;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function apiLabel(api: DashboardApi): string {
|
|
195
|
+
return api.kind === "apis" ? `apis/${api.version}` : "legacy";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The labels an apply stamps: the full marker, or only managed-by when no stack is known. */
|
|
199
|
+
export function stampLabels(marker: OwnershipMarker | undefined): Record<string, string> {
|
|
200
|
+
return marker ? grafanaOwnershipLabels(marker) : { [GRAFANA_OWNERSHIP_KEYS.managedBy]: "chant" };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** True when `labels` carry exactly this project's marker: chant's, this stack, this env. */
|
|
204
|
+
export function carriesMarker(labels: Readonly<Record<string, string>>, marker: OwnershipMarker): boolean {
|
|
205
|
+
if (!hasOwnershipMarker(labels, GRAFANA_OWNERSHIP_KEYS)) return false;
|
|
206
|
+
return labels[GRAFANA_OWNERSHIP_KEYS.stack] === marker.stack && (labels[GRAFANA_OWNERSHIP_KEYS.env] ?? undefined) === (marker.env || undefined);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function sameLabels(live: Readonly<Record<string, string>>, want: Readonly<Record<string, string>>): boolean {
|
|
210
|
+
return Object.entries(want).every(([k, v]) => live[k] === v);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The spec sent for a dashboard: its JSON without the identity keys the resource carries elsewhere. */
|
|
214
|
+
function specOf(json: Json): Json {
|
|
215
|
+
const { id: _id, uid: _uid, version: _version, ...spec } = json;
|
|
216
|
+
return spec;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Create or update one dashboard. Unchanged, with nothing written, when the
|
|
221
|
+
* live dashboard already covers the JSON and sits in the same folder with
|
|
222
|
+
* the same labels.
|
|
223
|
+
*/
|
|
224
|
+
export async function ensureDashboard(
|
|
225
|
+
client: GrafanaClient,
|
|
226
|
+
plan: DashboardPlan,
|
|
227
|
+
labels: Readonly<Record<string, string>>,
|
|
228
|
+
): Promise<{ action: AppliedAction; address: string }> {
|
|
229
|
+
const api = await dashboardApi(client);
|
|
230
|
+
const read = await readDashboard(client, plan.uid);
|
|
231
|
+
const live = "present" in read ? read.present : undefined;
|
|
232
|
+
const address = dashboardPath(api, client.namespace, plan.uid);
|
|
233
|
+
const classic = live ? classicDashboardOf(live) : undefined;
|
|
234
|
+
const sameFolder = (live?.folderUid ?? "") === (plan.folderUid ?? "");
|
|
235
|
+
const sameContent = classic !== undefined && "json" in classic && converged(plan.json, classic.json, { dashboard: true });
|
|
236
|
+
|
|
237
|
+
if (api.kind === "apis") {
|
|
238
|
+
if (live && sameContent && sameFolder && sameLabels(live.labels, labels)) return { action: "unchanged", address };
|
|
239
|
+
// Labels are merged, so a label another tool put on the dashboard stays.
|
|
240
|
+
const metadata = {
|
|
241
|
+
name: plan.uid,
|
|
242
|
+
labels: { ...(live?.labels ?? {}), ...labels },
|
|
243
|
+
...(plan.folderUid ? { annotations: { [FOLDER_ANNOTATION]: plan.folderUid } } : {}),
|
|
244
|
+
};
|
|
245
|
+
const resource = { apiVersion: `${DASHBOARD_GROUP}/${api.version}`, kind: "Dashboard", metadata, spec: specOf(plan.json) };
|
|
246
|
+
if (live) {
|
|
247
|
+
await send(client, "PUT", address, resource);
|
|
248
|
+
return { action: "updated", address };
|
|
249
|
+
}
|
|
250
|
+
await send(client, "POST", address.slice(0, address.lastIndexOf("/")), resource);
|
|
251
|
+
return { action: "created", address };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (live && sameContent && sameFolder) return { action: "unchanged", address };
|
|
255
|
+
const { id: _id, version: _version, ...dashboard } = plan.json;
|
|
256
|
+
await send(client, "POST", "/api/dashboards/db", {
|
|
257
|
+
dashboard: { ...dashboard, uid: plan.uid },
|
|
258
|
+
folderUid: plan.folderUid ?? "",
|
|
259
|
+
overwrite: true,
|
|
260
|
+
message: "chant apply",
|
|
261
|
+
});
|
|
262
|
+
return { action: live ? "updated" : "created", address };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Delete one dashboard over `/apis`. False when it was already gone. */
|
|
266
|
+
async function deleteDashboard(client: GrafanaClient, address: string): Promise<boolean> {
|
|
267
|
+
const res = await client.http("DELETE", address);
|
|
268
|
+
if (res.status === 404) return false;
|
|
269
|
+
if (res.status < 200 || res.status >= 300) throw new GrafanaApiError(res.status, "DELETE", address, res.json);
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function checkAborted(signal: AbortSignal | undefined): void {
|
|
274
|
+
if (signal?.aborted) throw new Error("grafana apply aborted");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Apply `plan` to the Grafana behind `client`. Throws `GrafanaApiError` when
|
|
279
|
+
* a write fails; a refused first read (401/403) is returned instead, with
|
|
280
|
+
* every resource `no-credentials`, since nothing was written.
|
|
281
|
+
*/
|
|
282
|
+
export async function applyGrafana(client: GrafanaClient, plan: GrafanaApplyPlan, opts: ApplyGrafanaOptions = {}): Promise<GrafanaApplyOutcome> {
|
|
283
|
+
const outcome: GrafanaApplyOutcome = {
|
|
284
|
+
target: client.target.source,
|
|
285
|
+
api: "",
|
|
286
|
+
applied: [],
|
|
287
|
+
pruned: [],
|
|
288
|
+
notAttempted: plan.unsupported.map((u) => ({ kind: u.kind, name: u.name, reason: "unsupported-kind" as const, detail: u.detail })),
|
|
289
|
+
notPrunable: [],
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
let api: DashboardApi;
|
|
293
|
+
try {
|
|
294
|
+
api = await dashboardApi(client);
|
|
295
|
+
} catch (err) {
|
|
296
|
+
if (err instanceof GrafanaApiError && err.verdict === "refused") {
|
|
297
|
+
const detail = `${err.message} (the credentials for ${client.target.source} were refused)`;
|
|
298
|
+
for (const ref of planRefs(plan)) {
|
|
299
|
+
if (!plan.unsupported.some((u) => u.kind === ref.kind && u.name === ref.name)) outcome.notAttempted.push({ ...ref, reason: "no-credentials", detail });
|
|
300
|
+
}
|
|
301
|
+
return outcome;
|
|
302
|
+
}
|
|
303
|
+
throw err;
|
|
304
|
+
}
|
|
305
|
+
outcome.api = apiLabel(api);
|
|
306
|
+
const labels = stampLabels(opts.marker);
|
|
307
|
+
|
|
308
|
+
for (const folder of plan.folders) {
|
|
309
|
+
checkAborted(opts.signal);
|
|
310
|
+
const r = await ensureFolder(client, folder, labels);
|
|
311
|
+
outcome.applied.push({ kind: GRAFANA_APPLY_KINDS.folder, name: folder.uid, action: r.action, address: r.address });
|
|
312
|
+
}
|
|
313
|
+
for (const panel of plan.libraryPanels) {
|
|
314
|
+
checkAborted(opts.signal);
|
|
315
|
+
const r = await ensureLibraryPanel(client, panel);
|
|
316
|
+
outcome.applied.push({ kind: GRAFANA_APPLY_KINDS.libraryPanel, name: panel.uid, action: r.action, address: r.address });
|
|
317
|
+
}
|
|
318
|
+
for (const dashboard of plan.dashboards) {
|
|
319
|
+
checkAborted(opts.signal);
|
|
320
|
+
const r = await ensureDashboard(client, dashboard, labels);
|
|
321
|
+
outcome.applied.push({ kind: GRAFANA_APPLY_KINDS.dashboard, name: dashboard.uid, action: r.action, address: r.address });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (opts.prune) await prune(client, plan, api, opts, outcome);
|
|
325
|
+
return outcome;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function prune(client: GrafanaClient, plan: GrafanaApplyPlan, api: DashboardApi, opts: ApplyGrafanaOptions, outcome: GrafanaApplyOutcome): Promise<void> {
|
|
329
|
+
const marker = opts.marker;
|
|
330
|
+
if (plan.libraryPanels.length > 0) {
|
|
331
|
+
outcome.notPrunable.push({ kind: GRAFANA_APPLY_KINDS.libraryPanel, detail: "the library elements API has no labels, so a library panel cannot carry chant's marker" });
|
|
332
|
+
}
|
|
333
|
+
if (!marker) {
|
|
334
|
+
const detail = "no ownership stack: set ownership.stack in chant.config.ts, or pass stack, so the prune can tell this project's resources from another's";
|
|
335
|
+
outcome.notPrunable.push({ kind: GRAFANA_APPLY_KINDS.dashboard, detail }, { kind: GRAFANA_APPLY_KINDS.folder, detail });
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (api.kind === "legacy") {
|
|
340
|
+
outcome.notPrunable.push({ kind: GRAFANA_APPLY_KINDS.dashboard, detail: "this Grafana has no dashboard.grafana.app API (Grafana 11), and /api/dashboards has no labels to read the marker from" });
|
|
341
|
+
} else {
|
|
342
|
+
const keep = new Set(plan.dashboards.map((d) => d.uid));
|
|
343
|
+
for (const live of await listDashboards(client)) {
|
|
344
|
+
if (keep.has(live.uid) || !carriesMarker(live.labels, marker)) continue;
|
|
345
|
+
checkAborted(opts.signal);
|
|
346
|
+
const address = dashboardPath(api, client.namespace, live.uid);
|
|
347
|
+
outcome.pruned.push({ kind: GRAFANA_APPLY_KINDS.dashboard, name: live.uid, deleted: await deleteDashboard(client, address), address });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const fApi = await folderApi(client);
|
|
352
|
+
if (fApi.kind === "legacy") {
|
|
353
|
+
outcome.notPrunable.push({ kind: GRAFANA_APPLY_KINDS.folder, detail: "this Grafana has no folder.grafana.app API, and /api/folders has no labels to read the marker from" });
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const keep = new Set(plan.folders.map((f) => f.uid));
|
|
357
|
+
const orphans = (await listFolders(client)).filter((f) => !keep.has(f.uid) && carriesMarker(f.labels, marker));
|
|
358
|
+
for (const folder of childrenFirst(orphans)) {
|
|
359
|
+
checkAborted(opts.signal);
|
|
360
|
+
const held = await folderContentCount(client, folder.uid);
|
|
361
|
+
if (held > 0) {
|
|
362
|
+
outcome.notAttempted.push({
|
|
363
|
+
kind: GRAFANA_APPLY_KINDS.folder,
|
|
364
|
+
name: folder.uid,
|
|
365
|
+
reason: "not-prunable",
|
|
366
|
+
detail: `folder "${folder.title}" is this project's and no longer declared, but still holds ${held} item(s) chant does not prune (a dashboard saved into it, a library panel); it is left`,
|
|
367
|
+
});
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
outcome.pruned.push({ kind: GRAFANA_APPLY_KINDS.folder, name: folder.uid, deleted: await deleteFolder(client, folder), address: folder.address });
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Parse a stored dashboard JSON file's content; throws with the file name when it is not a JSON object. */
|
|
375
|
+
export function parseDashboardFile(content: string, file: string): Json {
|
|
376
|
+
let parsed: unknown;
|
|
377
|
+
try {
|
|
378
|
+
parsed = JSON.parse(content);
|
|
379
|
+
} catch (err) {
|
|
380
|
+
throw new Error(`grafana apply: ${file} is not JSON (${err instanceof Error ? err.message : String(err)})`);
|
|
381
|
+
}
|
|
382
|
+
if (!isObject(parsed)) throw new Error(`grafana apply: ${file} is not a dashboard JSON object`);
|
|
383
|
+
return parsed;
|
|
384
|
+
}
|