aboutsummaryrefslogtreecommitdiffstats
path: root/dashboards/panels.py
diff options
context:
space:
mode:
Diffstat (limited to 'dashboards/panels.py')
-rw-r--r--dashboards/panels.py291
1 files changed, 291 insertions, 0 deletions
diff --git a/dashboards/panels.py b/dashboards/panels.py
new file mode 100644
index 0000000..faa60f7
--- /dev/null
+++ b/dashboards/panels.py
@@ -0,0 +1,291 @@
+"""Shared Grafana panel builders for the dashboard generators in this directory.
+
+Hand-writing Grafana panel JSON at any volume invites subtle inconsistencies -- one
+panel with a slightly different fieldConfig shape, a typo'd unit. Every panel here goes
+through one code path per type, so they are structurally identical by construction.
+
+Used by gen_zfs.py and gen_smart.py. Both are run as scripts from this directory, so a
+plain `from panels import ...` resolves.
+"""
+
+DS = {"type": "prometheus", "uid": "prometheus"}
+
+# Thresholds: green until the value reaches 1, then red. For "any of these is a problem"
+# counters.
+OK_BAD = {"mode": "absolute", "steps": [
+ {"color": "green", "value": None},
+ {"color": "red", "value": 1},
+]}
+
+PLAIN = {"mode": "absolute", "steps": [{"color": "green", "value": None}]}
+
+_id = [0]
+
+
+def nid():
+ _id[0] += 1
+ return _id[0]
+
+
+def target(expr, legend=None, instant=False, fmt=None, ref="A"):
+ t = {
+ "datasource": DS, "editorMode": "code", "expr": expr, "refId": ref,
+ "instant": instant, "range": not instant,
+ }
+ if legend is not None:
+ t["legendFormat"] = legend
+ if fmt:
+ t["format"] = fmt
+ return t
+
+
+def targets(specs, instant=False, fmt=None):
+ return [
+ target(expr, legend, instant=instant, fmt=fmt, ref=chr(ord("A") + i))
+ for i, (expr, legend) in enumerate(specs)
+ ]
+
+
+def gp(x, y, w, h):
+ return {"h": h, "w": w, "x": x, "y": y}
+
+
+def timeseries(title, specs, pos, unit=None, desc="", stack=False, minval=None,
+ maxval=None, custom=None, mappings=None):
+ cust = {
+ "drawStyle": "line", "lineInterpolation": "linear", "lineWidth": 1,
+ "fillOpacity": 40 if stack else 15, "showPoints": "never",
+ "spanNulls": True, "axisBorderShow": False, "axisCenteredZero": False,
+ "gradientMode": "none", "pointSize": 5,
+ "scaleDistribution": {"type": "linear"},
+ "stacking": {"group": "A", "mode": "normal" if stack else "none"},
+ "thresholdsStyle": {"mode": "off"},
+ }
+ if custom:
+ cust.update(custom)
+ defaults = {
+ "color": {"mode": "palette-classic"},
+ "custom": cust,
+ "mappings": mappings or [],
+ "thresholds": PLAIN,
+ }
+ if unit:
+ defaults["unit"] = unit
+ if minval is not None:
+ defaults["min"] = minval
+ if maxval is not None:
+ defaults["max"] = maxval
+ return {
+ "type": "timeseries", "title": title, "description": desc,
+ "id": nid(), "datasource": DS, "gridPos": pos,
+ "fieldConfig": {"defaults": defaults, "overrides": []},
+ "options": {
+ "legend": {"calcs": ["lastNotNull", "max"], "displayMode": "table",
+ "placement": "bottom", "showLegend": True},
+ "tooltip": {"mode": "multi", "sort": "desc"},
+ },
+ "targets": targets(specs),
+ }
+
+
+def stat(title, specs, pos, unit=None, mappings=None, thresholds=None, desc="",
+ colormode="value", textmode="auto", graphmode="none", decimals=None,
+ no_value=None):
+ defaults = {
+ "color": {"mode": "thresholds"},
+ "mappings": mappings or [],
+ "thresholds": thresholds or PLAIN,
+ }
+ if unit:
+ defaults["unit"] = unit
+ if decimals is not None:
+ defaults["decimals"] = decimals
+ if no_value is not None:
+ # Beats a bare "No data" when an empty result is itself the answer.
+ defaults["noValue"] = no_value
+ return {
+ "type": "stat", "title": title, "description": desc,
+ "id": nid(), "datasource": DS, "gridPos": pos,
+ "fieldConfig": {"defaults": defaults, "overrides": []},
+ "options": {
+ "colorMode": colormode, "graphMode": graphmode, "justifyMode": "auto",
+ "orientation": "auto", "textMode": textmode, "wideLayout": True,
+ "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": False},
+ },
+ "targets": targets(specs, instant=True),
+ }
+
+
+def gauge(title, specs, pos, unit="percentunit", thresholds=None, desc="", maxv=1):
+ return {
+ "type": "gauge", "title": title, "description": desc,
+ "id": nid(), "datasource": DS, "gridPos": pos,
+ "fieldConfig": {"defaults": {
+ "color": {"mode": "thresholds"},
+ "mappings": [], "min": 0, "max": maxv, "unit": unit,
+ "thresholds": thresholds or {"mode": "absolute", "steps": [
+ {"color": "green", "value": None},
+ {"color": "yellow", "value": 0.8},
+ {"color": "red", "value": 0.9},
+ ]},
+ }, "overrides": []},
+ "options": {
+ "showThresholdLabels": False, "showThresholdMarkers": True,
+ "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": False},
+ },
+ "targets": targets(specs, instant=True),
+ }
+
+
+def bargauge(title, specs, pos, unit=None, thresholds=None, desc="", maxv=None,
+ display="basic"):
+ # "basic" draws one solid bar coloured by the value's own threshold. "lcd" splits
+ # the bar into segments coloured by position along the threshold scale, which makes
+ # a perfectly healthy value look mostly red — avoid it for anything with
+ # thresholds.
+ defaults = {
+ "color": {"mode": "thresholds"},
+ "mappings": [], "min": 0, "thresholds": thresholds or PLAIN,
+ }
+ if unit:
+ defaults["unit"] = unit
+ if maxv is not None:
+ defaults["max"] = maxv
+ return {
+ "type": "bargauge", "title": title, "description": desc,
+ "id": nid(), "datasource": DS, "gridPos": pos,
+ "fieldConfig": {"defaults": defaults, "overrides": []},
+ "options": {
+ "displayMode": display, "orientation": "horizontal",
+ "showUnfilled": True, "valueMode": "color",
+ "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": False},
+ },
+ "targets": targets(specs, instant=True),
+ }
+
+
+def table(title, specs, pos, keep, renames, desc="", overrides=None, mappings=None,
+ sort_by=None, sort_desc=True, order=None, exclude=None):
+ """Instant-query table.
+
+ `keep` is a regex of the columns to retain, applied before the join. Filtering
+ first is what stops duplicated label columns (Time, __name__, job, instance) from
+ colliding and being silently renamed to "field 1"/"field 2" by joinByField.
+
+ When joining several queries, give exactly one of them the descriptive labels and
+ reduce the others with `max by (<join field>) (...)` so nothing collides.
+ """
+ transformations = [
+ {"id": "filterFieldsByName", "options": {"include": {"pattern": keep}}},
+ ]
+ if len(specs) > 1:
+ join_field = renames.get("__join__", "dataset")
+ transformations.append(
+ {"id": "joinByField", "options": {"byField": join_field, "mode": "outer"}}
+ )
+ transformations.append({"id": "organize", "options": {
+ # A synthetic join key built with label_join() has to survive `keep` so the
+ # join can use it, but should not be shown; drop it here.
+ "excludeByName": {name: True for name in (exclude or [])},
+ # indexByName keys are the *original* field names, before renameByName runs.
+ "indexByName": {name: i for i, name in enumerate(order or [])},
+ "renameByName": {k: v for k, v in renames.items() if k != "__join__"},
+ }})
+ if sort_by:
+ transformations.append(
+ {"id": "sortBy", "options": {"fields": {},
+ "sort": [{"field": sort_by,
+ "desc": sort_desc}]}}
+ )
+ return {
+ "type": "table", "title": title, "description": desc,
+ "id": nid(), "datasource": DS, "gridPos": pos,
+ "fieldConfig": {"defaults": {
+ "color": {"mode": "thresholds"},
+ "mappings": mappings or [],
+ "custom": {"align": "auto", "cellOptions": {"type": "auto"},
+ "inspect": False, "filterable": True},
+ "thresholds": PLAIN,
+ }, "overrides": overrides or []},
+ "options": {"showHeader": True, "cellHeight": "sm",
+ "footer": {"show": False, "reducer": ["sum"], "countRows": False,
+ "fields": ""}},
+ "targets": targets(specs, instant=True, fmt="table"),
+ "transformations": transformations,
+ }
+
+
+def row(title, y, collapsed=False, children=None, repeat=None):
+ """A dashboard row.
+
+ Grafana keeps a *collapsed* row's children nested inside the row panel, and an
+ *expanded* row's children at the top level. Getting that backwards makes panels
+ vanish, so callers pass `children` only for collapsed rows.
+
+ `repeat` names a template variable; Grafana then duplicates the row and everything
+ under it once per value of that variable.
+ """
+ r = {
+ "type": "row", "title": title, "id": nid(),
+ "gridPos": gp(0, y, 24, 1),
+ "collapsed": collapsed,
+ "panels": children or [],
+ }
+ if repeat:
+ r["repeat"] = repeat
+ r["repeatDirection"] = "h"
+ return r
+
+
+def query_var(name, query, label=None, multi=True, include_all=True, all_value=".*",
+ current_all=True, hide=0, regex=None):
+ """A Prometheus-backed template variable.
+
+ `regex` filters and rewrites the returned strings. Named capture groups `value` and
+ `text` split one string into what queries substitute and what the UI shows -- nest
+ them (`(?<text>(?<value>\\S+) .*)`) to show a superset of what you select on.
+ """
+ v = {
+ "name": name,
+ "label": label or name,
+ "type": "query",
+ "datasource": DS,
+ "query": {"qryType": 1, "query": query,
+ "refId": "PrometheusVariableQueryEditor-VariableQuery"},
+ "refresh": 1,
+ "includeAll": include_all,
+ "multi": multi,
+ "options": [],
+ "sort": 1,
+ "hide": hide,
+ "regex": regex or "",
+ }
+ if include_all:
+ v["allValue"] = all_value
+ if current_all and include_all:
+ v["current"] = {"text": ["All"], "value": ["$__all"]}
+ return v
+
+
+def dashboard(title, uid, description, panels, variables, tags, refresh="1m",
+ time_from="now-24h"):
+ return {
+ "title": title,
+ "uid": uid,
+ "description": description,
+ "tags": tags,
+ "timezone": "browser",
+ "schemaVersion": 41,
+ "version": 1,
+ "editable": True,
+ "graphTooltip": 1,
+ "refresh": refresh,
+ "time": {"from": time_from, "to": "now"},
+ "templating": {"list": variables},
+ "annotations": {"list": [{
+ "builtIn": 1, "datasource": {"type": "datasource", "uid": "grafana"},
+ "enable": True, "hide": True, "iconColor": "rgba(0, 211, 255, 1)",
+ "name": "Annotations & Alerts", "type": "dashboard",
+ }]},
+ "panels": panels,
+ }