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
+2
View File
@@ -4,6 +4,7 @@ on:
pull_request: pull_request:
paths: paths:
- contrib/mixin/** - contrib/mixin/**
- dashboard/windows-exporter-dashboard.json
- .github/workflows/mixin.yml - .github/workflows/mixin.yml
push: push:
branches: branches:
@@ -12,6 +13,7 @@ on:
- main - main
paths: paths:
- contrib/mixin/** - contrib/mixin/**
- dashboard/windows-exporter-dashboard.json
- .github/workflows/mixin.yml - .github/workflows/mixin.yml
workflow_dispatch: workflow_dispatch:
+19 -3
View File
@@ -1,19 +1,23 @@
JSONNET ?= jsonnet JSONNET ?= jsonnet
JSONNETFMT ?= jsonnetfmt JSONNETFMT ?= jsonnetfmt
JB ?= jb
PROMTOOL ?= promtool PROMTOOL ?= promtool
PINT ?= pint PINT ?= pint
PYTHON ?= python3 PYTHON ?= python3
# renovate: go=github.com/google/go-jsonnet # renovate: go=github.com/google/go-jsonnet
JSONNET_VERSION := v0.22.0 JSONNET_VERSION := v0.22.0
# renovate: go=github.com/jsonnet-bundler/jsonnet-bundler
JB_VERSION := v0.6.0
# renovate: go=github.com/cloudflare/pint # renovate: go=github.com/cloudflare/pint
PINT_VERSION := v0.89.2 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 FMT_FLAGS := -n 2 --max-blank-lines 2 --string-style s --comment-style s
DASHBOARD := ../../dashboard/windows-exporter-dashboard.json
.DEFAULT_GOAL := generate .DEFAULT_GOAL := generate
.DELETE_ON_ERROR: .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 generate: windows_rules.yaml windows_alerts.yaml
@@ -23,6 +27,12 @@ tools: jsonnet-tools
jsonnet-tools: jsonnet-tools:
go install github.com/google/go-jsonnet/cmd/jsonnet@$(JSONNET_VERSION) 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/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) windows_rules.yaml: $(SOURCES)
$(JSONNET) -S rules.jsonnet > $@ $(JSONNET) -S rules.jsonnet > $@
@@ -36,13 +46,19 @@ tests/custom_rules.yaml: $(SOURCES)
tests/custom_alerts.yaml: $(SOURCES) tests/custom_alerts.yaml: $(SOURCES)
$(JSONNET) -S -e 'std.manifestYamlDoc((import "tests/config.libsonnet").prometheusAlerts)' > $@ $(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: fmt:
$(JSONNETFMT) $(FMT_FLAGS) -i $(SOURCES) $(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 \ @set -e; for file in $(SOURCES); do \
$(JSONNETFMT) $(FMT_FLAGS) "$$file" | diff -u "$$file" -; \ $(JSONNETFMT) $(FMT_FLAGS) "$$file" | diff -u "$$file" -; \
done 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 $(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 windows_rules.yaml windows_alerts.yaml
$(PINT) --offline lint tests/custom_rules.yaml tests/custom_alerts.yaml $(PINT) --offline lint tests/custom_rules.yaml tests/custom_alerts.yaml
+38 -10
View File
@@ -1,12 +1,13 @@
# Windows exporter mixin # Windows exporter mixin
Configurable Prometheus recording rules and alerts for windows_exporter. The Configurable Prometheus recording rules and alerts for windows_exporter, and the
source uses Jsonnet and exposes the standard `prometheusRules` and source of the sample dashboard. The source uses Jsonnet and exposes the standard
`prometheusAlerts` mixin fields. Dashboard source is maintained separately. `prometheusRules`, `prometheusAlerts` and `grafanaDashboards` mixin fields.
## Generate rules ## 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 ```sh
cd contrib/mixin cd contrib/mixin
@@ -15,8 +16,8 @@ export PATH="$(go env GOPATH)/bin:$PATH"
make generate make generate
``` ```
The mixin has no Jsonnet library dependencies, so `jb install` is not required Only the dashboard depends on Jsonnet libraries, so `jb install` is not required
to generate rules. Generated files are ignored by Git: to generate rules. Generated rule files are ignored by Git:
- `windows_rules.yaml`: recording rules. - `windows_rules.yaml`: recording rules.
- `windows_alerts.yaml`: alerting 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. the configured Pages URL, including custom domains and repository subpaths.
Fork users should override `runbookURLPattern` to match their published URL. 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 ## Validate
Install Python 3 and `promtool` from a [Prometheus release](https://github.com/prometheus/prometheus/releases), Install Python 3 and `promtool` from a [Prometheus release](https://github.com/prometheus/prometheus/releases),
@@ -202,7 +228,8 @@ then run:
make test 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. `pint --offline lint`, and evaluates the rules against synthetic time series.
Tests cover all 25 recordings, alert timing and recovery, threshold Tests cover all 25 recordings, alert timing and recovery, threshold
boundaries, counter resets, zero denominators, disk forecasts, target-label 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" 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 The dedicated `Mixin` workflow runs on changes to `contrib/mixin/**`, the
workflow file, and can also be started manually. It caches pinned tool binaries committed dashboard JSON or its own workflow file, and can also be started
and runs the same `make test` checks used locally. 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, `.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. 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, "version": 1,
"dependencies": [], "dependencies": [
{
"source": {
"git": {
"remote": "https://github.com/grafana/grafonnet.git",
"subdir": "gen/grafonnet-v13.0.0"
}
},
"version": "41e6bfcbe4ef1054298ffff8664f1354c897dd2d"
}
],
"legacyImports": false "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 'config.libsonnet') +
(import 'rules/rules.libsonnet') + (import 'rules/rules.libsonnet') +
(import 'alerts/alerts.libsonnet') + (import 'alerts/alerts.libsonnet') +
(import 'lib/add-runbook-links.libsonnet') (import 'lib/add-runbook-links.libsonnet') +
(import 'dashboards/dashboards.libsonnet')
+21
View File
@@ -44,6 +44,27 @@ The **Reboots** annotation marks each reboot of the selected host on all graphs.
Click a hostname in the Fleet tab to open that host in the Overview tab. Click a hostname in the Fleet tab to open that host in the Overview tab.
### Changing the dashboard
Don't edit the JSON file by hand. It is generated from Jsonnet with [grafonnet](https://github.com/grafana/grafonnet) as part of the [windows_exporter mixin](../contrib/mixin):
- [dashboards/windows-exporter.libsonnet](../contrib/mixin/dashboards/windows-exporter.libsonnet) defines the tabs, panels and queries.
- [lib/dashboard.libsonnet](../contrib/mixin/lib/dashboard.libsonnet) holds the panel and layout helpers.
The generator writes the v2 schema directly, so it needs neither Grafana nor Prometheus.
Install the pinned tools and regenerate the file in `contrib/mixin`:
```sh
cd contrib/mixin
make tools
export PATH="$(go env GOPATH)/bin:$PATH"
make dashboard
```
`make dashboard` runs `jb install` to fetch grafonnet into `contrib/mixin/vendor` and renders the dashboard.
`make lint` fails if the committed JSON file differs from the generated one.
Check new or changed queries in Grafana before you open a pull request, for example by importing the file into a local Grafana 13.
![Screenshot of the Fleet tab.](dashboard-fleet.png) ![Screenshot of the Fleet tab.](dashboard-fleet.png)
![Screenshot of the Overview tab.](dashboard-overview.png) ![Screenshot of the Overview tab.](dashboard-overview.png)