dashboard: generate the sample dashboard with grafonnet (#2570)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jan-Otto Kröpke <mail@jkroepke.de>
This commit is contained in:
Jan-Otto Kröpke
2026-10-09 09:30:54 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 54aeb2b391
commit bf2dfc05fe
12 changed files with 1972 additions and 15 deletions
+19 -3
View File
@@ -1,19 +1,23 @@
JSONNET ?= jsonnet
JSONNETFMT ?= jsonnetfmt
JB ?= jb
PROMTOOL ?= promtool
PINT ?= pint
PYTHON ?= python3
# renovate: go=github.com/google/go-jsonnet
JSONNET_VERSION := v0.22.0
# renovate: go=github.com/jsonnet-bundler/jsonnet-bundler
JB_VERSION := v0.6.0
# renovate: go=github.com/cloudflare/pint
PINT_VERSION := v0.89.2
SOURCES := $(wildcard *.libsonnet *.jsonnet lib/*.libsonnet rules/*.libsonnet alerts/*.libsonnet tests/*.libsonnet tests/*.jsonnet)
SOURCES := $(wildcard *.libsonnet *.jsonnet lib/*.libsonnet rules/*.libsonnet alerts/*.libsonnet dashboards/*.libsonnet tests/*.libsonnet tests/*.jsonnet)
FMT_FLAGS := -n 2 --max-blank-lines 2 --string-style s --comment-style s
DASHBOARD := ../../dashboard/windows-exporter-dashboard.json
.DEFAULT_GOAL := generate
.DELETE_ON_ERROR:
.PHONY: generate tools jsonnet-tools fmt lint test check-runbooks clean
.PHONY: generate dashboard tools jsonnet-tools fmt lint test check-runbooks clean
generate: windows_rules.yaml windows_alerts.yaml
@@ -23,6 +27,12 @@ tools: jsonnet-tools
jsonnet-tools:
go install github.com/google/go-jsonnet/cmd/jsonnet@$(JSONNET_VERSION)
go install github.com/google/go-jsonnet/cmd/jsonnetfmt@$(JSONNET_VERSION)
go install github.com/jsonnet-bundler/jsonnet-bundler/cmd/jb@$(JB_VERSION)
# Only the dashboard needs the Jsonnet libraries (grafonnet).
vendor: jsonnetfile.json jsonnetfile.lock.json
$(JB) install
@touch $@
windows_rules.yaml: $(SOURCES)
$(JSONNET) -S rules.jsonnet > $@
@@ -36,13 +46,19 @@ tests/custom_rules.yaml: $(SOURCES)
tests/custom_alerts.yaml: $(SOURCES)
$(JSONNET) -S -e 'std.manifestYamlDoc((import "tests/config.libsonnet").prometheusAlerts)' > $@
dashboard: vendor
$(JSONNET) -J vendor -S dashboard.jsonnet > $(DASHBOARD).tmp
mv $(DASHBOARD).tmp $(DASHBOARD)
fmt:
$(JSONNETFMT) $(FMT_FLAGS) -i $(SOURCES)
lint: generate tests/custom_rules.yaml tests/custom_alerts.yaml
lint: generate tests/custom_rules.yaml tests/custom_alerts.yaml vendor
@set -e; for file in $(SOURCES); do \
$(JSONNETFMT) $(FMT_FLAGS) "$$file" | diff -u "$$file" -; \
done
@$(JSONNET) -J vendor -S dashboard.jsonnet | diff -u $(DASHBOARD) - \
|| { echo "$(DASHBOARD) is out of date; run make dashboard"; exit 1; }
$(PROMTOOL) check rules windows_rules.yaml windows_alerts.yaml tests/custom_rules.yaml tests/custom_alerts.yaml
$(PINT) --offline lint windows_rules.yaml windows_alerts.yaml
$(PINT) --offline lint tests/custom_rules.yaml tests/custom_alerts.yaml
+38 -10
View File
@@ -1,12 +1,13 @@
# Windows exporter mixin
Configurable Prometheus recording rules and alerts for windows_exporter. The
source uses Jsonnet and exposes the standard `prometheusRules` and
`prometheusAlerts` mixin fields. Dashboard source is maintained separately.
Configurable Prometheus recording rules and alerts for windows_exporter, and the
source of the sample dashboard. The source uses Jsonnet and exposes the standard
`prometheusRules`, `prometheusAlerts` and `grafanaDashboards` mixin fields.
## Generate rules
Install the pinned Jsonnet and [pint](https://github.com/cloudflare/pint) tools:
Install the pinned Jsonnet, [jsonnet-bundler](https://github.com/jsonnet-bundler/jsonnet-bundler)
and [pint](https://github.com/cloudflare/pint) tools:
```sh
cd contrib/mixin
@@ -15,8 +16,8 @@ export PATH="$(go env GOPATH)/bin:$PATH"
make generate
```
The mixin has no Jsonnet library dependencies, so `jb install` is not required
to generate rules. Generated files are ignored by Git:
Only the dashboard depends on Jsonnet libraries, so `jb install` is not required
to generate rules. Generated rule files are ignored by Git:
- `windows_rules.yaml`: recording rules.
- `windows_alerts.yaml`: alerting rules.
@@ -193,6 +194,31 @@ repository's GitHub Pages **Source: GitHub Actions** setting. The workflow reads
the configured Pages URL, including custom domains and repository subpaths.
Fork users should override `runbookURLPattern` to match their published URL.
## Dashboard
`grafanaDashboards` contains `windows-exporter.json`, the sample dashboard in
[dashboard/windows-exporter-dashboard.json](../../dashboard/windows-exporter-dashboard.json).
It is a Grafana v2 dashboard with tabs and needs Grafana 13 or later. See the
[dashboard documentation](../../dashboard/README.md) for its content.
The dashboard is built with [grafonnet](https://github.com/grafana/grafonnet),
pinned in `jsonnetfile.json`:
- `dashboards/windows-exporter.libsonnet` defines the tabs, panels and queries.
- `lib/dashboard.libsonnet` wraps grafonnet panel options and queries into v2
panels, and lays them out in tabs, rows and grids.
- `lib/manifest.libsonnet` renders JSON with the four-space indentation of the
committed file.
Regenerate the committed file after changing the dashboard source:
```sh
make dashboard
```
This runs `jb install` and writes `../../dashboard/windows-exporter-dashboard.json`.
The dashboard uses its own variables and does not read `_config`.
## Validate
Install Python 3 and `promtool` from a [Prometheus release](https://github.com/prometheus/prometheus/releases),
@@ -202,7 +228,8 @@ then run:
make test
```
This checks Jsonnet formatting, validates generated rules with `promtool`, runs
This checks Jsonnet formatting, checks that the committed dashboard is up to
date, validates generated rules with `promtool`, runs
`pint --offline lint`, and evaluates the rules against synthetic time series.
Tests cover all 25 recordings, alert timing and recovery, threshold
boundaries, counter resets, zero denominators, disk forecasts, target-label
@@ -217,9 +244,10 @@ If you prefer to run `promtool` through Docker:
make test PROMTOOL="docker run --rm --entrypoint promtool -v \"$PWD:/mixin:ro\" -w /mixin prom/prometheus:v3.15.0"
```
The dedicated `Mixin` workflow runs on changes to `contrib/mixin/**` or its own
workflow file, and can also be started manually. It caches pinned tool binaries
and runs the same `make test` checks used locally.
The dedicated `Mixin` workflow runs on changes to `contrib/mixin/**`, the
committed dashboard JSON or its own workflow file, and can also be started
manually. It caches pinned tool binaries and runs the same `make test` checks
used locally.
`.pint.hcl` requires a non-empty severity label, summary/description annotations,
and an HTTP(S) runbook URL on alerts, in addition to pint's built-in checks.
+2
View File
@@ -0,0 +1,2 @@
// Renders dashboard/windows-exporter-dashboard.json. Run with jsonnet -J vendor -S.
(import 'lib/manifest.libsonnet')((import 'mixin.libsonnet').grafanaDashboards['windows-exporter.json'])
@@ -0,0 +1,5 @@
{
grafanaDashboards+:: {
'windows-exporter.json': import 'windows-exporter.libsonnet',
},
}
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -1,5 +1,15 @@
{
"version": 1,
"dependencies": [],
"dependencies": [
{
"source": {
"git": {
"remote": "https://github.com/grafana/grafonnet.git",
"subdir": "gen/grafonnet-v13.0.0"
}
},
"version": "41e6bfcbe4ef1054298ffff8664f1354c897dd2d"
}
],
"legacyImports": false
}
+36
View File
@@ -0,0 +1,36 @@
{
"version": 1,
"dependencies": [
{
"source": {
"git": {
"remote": "https://github.com/grafana/grafonnet.git",
"subdir": "gen/grafonnet-v13.0.0"
}
},
"version": "41e6bfcbe4ef1054298ffff8664f1354c897dd2d",
"sum": "hZfIVja9O6czp3Pg8fzqmN891FLz8nPvy2IJND5qlGw="
},
{
"source": {
"git": {
"remote": "https://github.com/jsonnet-libs/docsonnet.git",
"subdir": "doc-util"
}
},
"version": "bf6f08ae02a51c48bdcec4629b1c1a5a62c6f803",
"sum": "BrAL/k23jq+xy9oA7TWIhUx07dsA/QLm3g7ktCwe//U="
},
{
"source": {
"git": {
"remote": "https://github.com/jsonnet-libs/xtd.git",
"subdir": ""
}
},
"version": "4d7f8cb24d613430799f9d56809cc6964f35cea9",
"sum": "hOrwkOx34tOXqoDVnwuI/Uf/dr9HFFSPWpDPOvnEGrk="
}
],
"legacyImports": false
}
+467
View File
@@ -0,0 +1,467 @@
// Helpers to build a Grafana v2 dashboard (dashboard.grafana.app/v2) with tabs.
//
// Grafonnet builds the resource, the panel options and field config, and the Prometheus queries.
// The panel options and field config have the same shape in v1 panels and in v2 VizConfig.
// The helpers wrap them into v2 elements and lay the elements out in tabs, rows and grids.
local g = import 'github.com/grafana/grafonnet/gen/grafonnet-v13.0.0/main.libsonnet';
local prometheus = g.query.prometheus;
local stat = g.panel.stat;
local stateTimeline = g.panel.stateTimeline;
local table = g.panel.table;
local timeSeries = g.panel.timeSeries;
local datasource = { name: '${datasource}' };
// steps builds absolute thresholds from [color, value] pairs. The first value is null (base).
local steps(pairs) = {
mode: 'absolute',
steps: [{ color: p[0], value: p[1] } for p in pairs],
};
// Thresholds inside field overrides leave out the null value of the base step.
local overrideSteps(pairs) = {
mode: 'absolute',
steps: [{ color: p[0] } + (if p[1] == null then {} else { value: p[1] }) for p in pairs],
};
local optional(value, fn) = if value == null then {} else fn(value);
// vizConfig keeps the fields of a grafonnet panel that make up the v2 VizConfig spec.
local vizConfig(group, panel) = {
group: group,
kind: 'VizConfig',
spec: { fieldConfig: panel.fieldConfig, options: panel.options },
version: '',
};
local query(q, refId) = {
kind: 'PanelQuery',
spec: {
hidden: false,
query: {
datasource: datasource,
group: 'prometheus',
kind: 'DataQuery',
spec:
prometheus.withEditorMode('code')
+ prometheus.withExpr(q.expr)
+ prometheus.withLegendFormat(if q.legend == '' then '__auto' else q.legend)
+ prometheus.withInstant(q.instant)
+ prometheus.withRange(!q.instant)
+ (if q.table then prometheus.withFormat('table') else {}),
version: 'v0',
},
refId: refId,
},
};
// element is a v2 panel. The dashboard function adds its ID and grid position.
local element(title, description, queries, viz, width, height, transformations=[]) = {
width:: width,
height:: height,
kind: 'Panel',
spec: {
data: {
kind: 'QueryGroup',
spec: {
queries: [query(queries[i], std.char(std.codepoint('A') + i)) for i in std.range(0, std.length(queries) - 1)],
queryOptions: {},
transformations: [
{ group: t.id, kind: 'Transformation', spec: { options: t.options } }
for t in transformations
],
},
},
description: description,
links: [],
title: title,
vizConfig: viz,
},
};
local gridItems(bands) =
local heights = [std.foldl(std.max, [p.height for p in band], 0) for band in bands];
std.flattenArrays([
local y = std.foldl(function(sum, h) sum + h, heights[0:b], 0);
[
{
panel:: bands[b][i],
kind: 'GridLayoutItem',
spec: {
height: bands[b][i].height,
width: bands[b][i].width,
x: std.foldl(function(sum, p) sum + p.width, bands[b][0:i], 0),
y: y,
},
}
for i in std.range(0, std.length(bands[b]) - 1)
]
for b in std.range(0, std.length(bands) - 1)
]);
{
// host selects the host chosen in the Instance variable.
host:: 'job=~"$job", instance="$instance"',
// fleet selects all hosts of the chosen jobs. Fleet queries add fleetJoin or fleetFilter for the Hostname variable.
fleet:: 'job=~"$job"',
// fleetJoin adds the hostname label and restricts to the selected hostnames.
fleetJoin(expr)::
'(%s) * on (instance) group_left (hostname) max by (instance, hostname) (windows_os_hostname{%s, hostname=~"$hostname"})' % [expr, self.fleet],
// fleetFilter restricts to the selected hostnames without adding labels (for tables).
fleetFilter(expr)::
'(%s) and on (instance) windows_os_hostname{%s, hostname=~"$hostname"}' % [expr, self.fleet],
// q is a Prometheus query. An empty legend uses Grafana's automatic legend.
q(expr, legend=''):: { expr: expr, legend: legend, instant: false, table: false },
steps:: steps,
pctSteps:: steps([['green', null], ['orange', 80], ['red', 90]]),
plainSteps:: steps([['green', null]]),
textSteps:: steps([['text', null]]),
prop(id, value):: { id: id, value: value },
fixedColor(color):: self.prop('color', { fixedColor: color, mode: 'fixed' }),
dashed:: [self.prop('custom.fillOpacity', 0), self.prop('custom.lineStyle', { dash: [10, 10], fill: 'dash' })],
byName(name, props):: timeSeries.fieldOverride.byName.new(name) + { properties: props },
byRegexp(regex, props):: timeSeries.fieldOverride.byRegexp.new(regex) + { properties: props },
// negativeY mirrors series whose display name matches the regular expression below the X axis.
negativeY(regex):: self.byRegexp(regex, [self.prop('custom.transform', 'negative-Y')]),
// valueMapping maps the values 0 and 1 to texts and colors.
valueMapping(zero, one):: [{ type: 'value', options: { '0': zero { index: 0 }, '1': one { index: 1 } } }],
// gaugeCell shows a table cell as a gauge from 0 to maxValue.
gaugeCell(unit, maxValue):: [
self.prop('unit', unit),
self.prop('min', 0),
self.prop('max', maxValue),
self.prop('thresholds', overrideSteps([['green', null], ['orange', 80], ['red', 90]])),
self.prop('color', { mode: 'continuous-GrYlRd' }),
self.prop('custom.cellOptions', { type: 'gauge', mode: 'basic', valueDisplayMode: 'text' }),
self.prop('decimals', 1),
],
overrideSteps:: overrideSteps,
unitCell(unit):: [self.prop('unit', unit)],
// timeseries draws a time series panel. legend is table (with calcs), list or hidden.
// sortBy sorts the table legend by that column, descending.
timeseries(
title,
targets,
width,
height,
description='',
unit='',
stack=false,
min=null,
max=null,
decimals=null,
legend='table',
calcs=['mean', 'max', 'lastNotNull'],
overrides=[],
fill=10,
thresholds=self.plainSteps,
showThresholds=false,
sortBy='',
)::
local custom = timeSeries.fieldConfig.defaults.custom;
local panel =
custom.withAxisBorderShow(false)
+ custom.withAxisCenteredZero(false)
+ custom.withAxisColorMode('text')
+ custom.withAxisLabel('')
+ custom.withAxisPlacement('auto')
+ custom.withBarAlignment(0)
+ custom.withDrawStyle('line')
+ custom.withFillOpacity(fill)
+ custom.withGradientMode('opacity')
+ custom.hideFrom.withLegend(false)
+ custom.hideFrom.withTooltip(false)
+ custom.hideFrom.withViz(false)
+ custom.withInsertNulls(false)
+ custom.withLineInterpolation('linear')
+ custom.withLineWidth(1)
+ custom.withPointSize(4)
+ custom.scaleDistribution.withType('linear')
+ custom.withShowPoints('never')
+ custom.withSpanNulls(false)
+ custom.stacking.withGroup('A')
+ custom.stacking.withMode(if stack then 'normal' else 'none')
+ custom.thresholdsStyle.withMode(if showThresholds then 'dashed' else 'off')
+ timeSeries.standardOptions.color.withMode('palette-classic')
+ timeSeries.standardOptions.thresholds.withMode(thresholds.mode)
+ timeSeries.standardOptions.thresholds.withSteps(thresholds.steps)
+ timeSeries.standardOptions.withUnit(unit)
+ optional(min, timeSeries.standardOptions.withMin)
+ optional(max, timeSeries.standardOptions.withMax)
+ optional(decimals, timeSeries.standardOptions.withDecimals)
+ timeSeries.standardOptions.withOverrides(overrides)
+ timeSeries.options.legend.withPlacement('bottom')
+ (
if legend == 'table' then
timeSeries.options.legend.withCalcs(calcs)
+ timeSeries.options.legend.withDisplayMode('table')
+ timeSeries.options.legend.withShowLegend(true)
+ (if sortBy == '' then {} else timeSeries.options.legend.withSortBy(sortBy) + timeSeries.options.legend.withSortDesc(true))
else
timeSeries.options.legend.withCalcs([])
+ timeSeries.options.legend.withDisplayMode('list')
+ timeSeries.options.legend.withShowLegend(legend == 'list')
)
+ timeSeries.options.tooltip.withHideZeros(false)
+ timeSeries.options.tooltip.withMode('multi')
+ timeSeries.options.tooltip.withSort('desc');
element(title, description, targets, vizConfig('timeseries', panel), width=width, height=height),
// stat shows the last value of expr. With text, it shows that label of the result instead of the value.
stat(
title,
expr,
width,
height,
description='',
unit='',
thresholds=self.textSteps,
graph=false,
colorMode='value',
decimals=null,
min=null,
max=null,
text='',
mappings=[],
)::
local panel =
stat.standardOptions.color.withMode('thresholds')
+ (if mappings == [] then {} else stat.standardOptions.withMappings(mappings))
+ stat.standardOptions.thresholds.withMode(thresholds.mode)
+ stat.standardOptions.thresholds.withSteps(thresholds.steps)
+ stat.standardOptions.withUnit(unit)
+ optional(decimals, stat.standardOptions.withDecimals)
+ optional(min, stat.standardOptions.withMin)
+ optional(max, stat.standardOptions.withMax)
+ stat.standardOptions.withOverrides([])
+ stat.options.withColorMode(colorMode)
+ stat.options.withGraphMode(if graph then 'area' else 'none')
+ stat.options.withJustifyMode('auto')
+ stat.options.withOrientation('auto')
+ stat.options.withPercentChangeColorMode('standard')
+ stat.options.reduceOptions.withCalcs(['lastNotNull'])
+ stat.options.reduceOptions.withFields(if text == '' then '' else '/^' + text + '$/')
+ stat.options.reduceOptions.withValues(false)
+ stat.options.withShowPercentChange(false)
+ stat.options.withTextMode(if text == '' then 'auto' else 'value')
+ stat.options.withWideLayout(true);
// A label shown instead of the value needs the query result as a table.
local target = self.q(expr) { instant: !graph, table: text != '' };
element(title, description, [target], vizConfig('stat', panel), width=width, height=height),
// table joins instant queries into one table. The value columns are named "Value #A", "Value #B" and
// so on by query; rename, order and the overrides refer to those names. footer lists columns summed
// in the table footer.
table(
title,
targets,
width,
height,
description='',
joinBy='',
exclude=[],
rename={},
order={},
overrides=[],
sortBy=[],
transformations=[],
footer=null,
)::
local excluded = { Time: true } + { [e]: true for e in exclude } + {
['Time ' + (i + 1)]: true
for i in std.range(0, std.length(targets) - 1)
};
local panel =
table.standardOptions.color.withMode('thresholds')
+ table.fieldConfig.defaults.custom.withAlign('auto')
+ table.fieldConfig.defaults.custom.withCellOptions({ type: 'auto' })
+ table.fieldConfig.defaults.custom.withFilterable(false)
+ table.fieldConfig.defaults.custom.withInspect(false)
+ table.fieldConfig.defaults.custom.withMinWidth(60)
+ table.standardOptions.thresholds.withMode(self.plainSteps.mode)
+ table.standardOptions.thresholds.withSteps(self.plainSteps.steps)
+ table.standardOptions.withOverrides(overrides)
+ table.options.withCellHeight('sm')
+ table.options.withFooter({
countRows: false,
fields: if footer == null then '' else footer,
reducer: ['sum'],
show: footer != null,
})
+ table.options.withShowHeader(true)
+ table.options.withSortBy(sortBy)
+ { options+: { enablePagination: false } };
element(
title,
description,
[t { instant: true, table: true } for t in targets],
vizConfig('table', panel),
width=width,
height=height,
transformations=(if joinBy == '' then [] else [{ id: 'joinByField', options: { byField: joinBy, mode: 'outer' } }])
+ transformations
+ [{
id: 'organize',
options: { excludeByName: excluded, includeByName: {}, indexByName: order, renameByName: rename },
}],
),
local timeline(title, description, target, defaults, width, height) =
local custom = stateTimeline.fieldConfig.defaults.custom;
local panel =
{ fieldConfig+: { defaults+: defaults } }
+ custom.withFillOpacity(90)
+ custom.hideFrom.withLegend(false)
+ custom.hideFrom.withTooltip(false)
+ custom.hideFrom.withViz(false)
+ custom.withInsertNulls(false)
+ custom.withLineWidth(0)
+ custom.withSpanNulls(false)
+ stateTimeline.standardOptions.withMin(0)
+ stateTimeline.standardOptions.withOverrides([])
+ stateTimeline.options.withAlignValue('left')
+ stateTimeline.options.legend.withDisplayMode('list')
+ stateTimeline.options.legend.withPlacement('bottom')
+ stateTimeline.options.legend.withShowLegend(false)
+ stateTimeline.options.withMergeValues(false)
+ stateTimeline.options.withRowHeight(1)
+ stateTimeline.options.withShowValue('never')
+ stateTimeline.options.tooltip.withHideZeros(false)
+ stateTimeline.options.tooltip.withMode('single')
+ stateTimeline.options.tooltip.withSort('none');
element(title, description, [target], vizConfig('state-timeline', panel), width=width, height=height),
// stateTimeline draws one row per series, colored from green to red by value. It is used for per-core
// CPU load, where a line per core becomes unreadable.
stateTimeline(title, description, unit, maxValue, target, width, height)::
timeline(title, description, target, {
color: { mode: 'continuous-GrYlRd' },
max: maxValue,
thresholds: $.plainSteps,
unit: unit,
}, width, height),
// statusTimeline draws a 0/1 metric per series as a red/green timeline.
statusTimeline(title, description, target, okText, failText, width, height)::
timeline(title, description, target, {
color: { mode: 'thresholds' },
mappings: $.valueMapping({ color: 'red', text: failText }, { color: 'green', text: okText }),
max: 1,
thresholds: steps([['red', null], ['green', 1]]),
unit: 'none',
}, width, height),
// variable is a Prometheus query variable.
variable(name, label, query, multi=false, includeAll=false, hide=false, regex=''):: {
kind: 'QueryVariable',
spec: {
allowCustomValue: true,
current: if includeAll then { text: ['All'], value: ['$__all'] } else { text: '', value: '' },
definition: query,
hide: if hide then 'hideVariable' else 'dontHide',
includeAll: includeAll,
[if label != '' then 'label']: label,
multi: multi,
name: name,
options: [],
query: {
datasource: datasource,
group: 'prometheus',
kind: 'DataQuery',
spec: { qryType: 1, query: query, refId: 'PrometheusVariableQueryEditor-VariableQuery' },
version: 'v0',
},
refresh: 'onTimeRangeChanged',
regex: regex,
skipUrlSync: false,
sort: 'alphabeticalAsc',
},
},
// row is a row of a tab. bands is a list of panel lists; the panels of a band sit side by side
// and the bands are stacked.
row(title, bands):: { title: title, bands: bands },
// tab is a tab of the dashboard. A tab is either one grid of bands, or a list of rows.
tab(title, bands=[], rows=[]):: { title: title, rows: if rows == [] then [$.row('', bands)] else rows },
// dashboard builds the v2 dashboard from the tabs. Panels are numbered in order, and every row
// takes one number before its panels, as in the classic dashboard the layout was converted from.
// The element names (panel-<number>) are taken from these numbers.
dashboard(name, title, tabs, variables=[], annotations=[], spec={})::
local v2 = g.apps.dashboard.v2;
local rows = [
tabs[t].rows[r] { tab:: t }
for t in std.range(0, std.length(tabs) - 1)
for r in std.range(0, std.length(tabs[t].rows) - 1)
];
local panelCount(row) = std.length(std.flattenArrays(row.bands));
// The row itself takes the number before its first panel.
local firstID = [
std.foldl(function(sum, r) sum + 1 + panelCount(r), rows[0:i], 0) + 2
for i in std.range(0, std.length(rows) - 1)
];
local numbered = [
local items = gridItems(rows[i].bands);
rows[i] {
items:: [
items[j] {
id:: firstID[i] + j,
spec+: { element: { kind: 'ElementReference', name: 'panel-' + (firstID[i] + j) } },
}
for j in std.range(0, std.length(items) - 1)
],
}
for i in std.range(0, std.length(rows) - 1)
];
local grid(row) = { kind: 'GridLayout', spec: { items: row.items } };
local tabRows(t) = [r for r in numbered if r.tab == t];
v2.new(name, title)
+ v2.spec.withAnnotations(annotations)
+ v2.spec.withElements({
[item.spec.element.name]: item.panel { spec+: { id: item.id } }
for row in numbered
for item in row.items
})
+ v2.spec.withLayout({
kind: 'TabsLayout',
spec: {
tabs: [
local tab = tabs[t];
local r = tabRows(t);
{
kind: 'TabsLayoutTab',
spec: {
layout:
if std.length(r) == 1 && r[0].title == '' then grid(r[0])
else {
kind: 'RowsLayout',
spec: {
rows: [
{ kind: 'RowsLayoutRow', spec: { collapse: false, layout: grid(x), title: x.title } }
for x in r
],
},
},
title: tab.title,
},
}
for t in std.range(0, std.length(tabs) - 1)
],
},
})
+ v2.spec.withVariables(variables)
+ { spec+: spec },
}
+19
View File
@@ -0,0 +1,19 @@
// manifestJson renders a value as JSON indented by four spaces, as .editorconfig requires for JSON
// files. Unlike std.manifestJsonEx, it writes empty arrays and objects as [] and {}.
// It concatenates strings instead of using std.format, which runs out of stack frames on large output.
local manifestJson(value, indent='') =
local inner = indent + ' ';
if std.isArray(value) then
if std.length(value) == 0 then '[]'
else '[\n' + std.join(',\n', [inner + manifestJson(v, inner) for v in value]) + '\n' + indent + ']'
else if std.isObject(value) then
local fields = std.objectFields(value);
if std.length(fields) == 0 then '{}'
else
'{\n'
+ std.join(',\n', [inner + std.escapeStringJson(f) + ': ' + manifestJson(value[f], inner) for f in fields])
+ '\n' + indent + '}'
else std.manifestJson(value);
// Render with jsonnet -S, which appends the final newline.
function(value) manifestJson(value)
+2 -1
View File
@@ -1,4 +1,5 @@
(import 'config.libsonnet') +
(import 'rules/rules.libsonnet') +
(import 'alerts/alerts.libsonnet') +
(import 'lib/add-runbook-links.libsonnet')
(import 'lib/add-runbook-links.libsonnet') +
(import 'dashboards/dashboards.libsonnet')