"""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 () (...)` 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 # Which editor tab Grafana opens the variable on. Only the UI reads this: at query time # PrometheusVariableSupport.query() takes the `query` string and hands it straight to # PrometheusMetricFindQuery, which re-parses it. Derive it so the two cannot disagree. QRY_TYPES = {"label_names(": 0, "label_values(": 1, "metrics(": 2, "query_result(": 3} 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 (`(?(?\\S+) .*)`) to show a superset of what you select on. `label_values(...)` resolves against /api/v1/label//values over the *dashboard's time range*, so it lists every value seen in that window, not the ones that exist now. `query_result(...)` is an instant query at the range's end instead. Prefer it whenever a stale option would be actively wrong rather than merely extra. """ v = { "name": name, "label": label or name, "type": "query", "datasource": DS, "query": {"qryType": QRY_TYPES.get(query.split("(")[0] + "(", 4), "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, }