diff options
Diffstat (limited to 'dashboards/panels.py')
| -rw-r--r-- | dashboards/panels.py | 291 |
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, + } |
