From 632d14636883501bbf545fac9774af71d7022735 Mon Sep 17 00:00:00 2001 From: Damien Arnodo Date: Fri, 10 Jul 2026 10:12:07 +0000 Subject: [PATCH] Split weathermap generation from dashboard composition generate_weathermap.py now emits only the weathermap panel (targets + options.weathermap) and merges it into a manually-authored dashboard base (configs/grafana/dashboard-base.json) at a reserved slot panel, keeping the base's gridPos so layout stays manual. The BGP sessions table, ports/interfaces table, throughput graphs, and template variables move out of the script entirely into that base file. Verified zero regression: merged output is identical to the prior generator-only output except for JSON key ordering, and all panel queries re-validated via /api/ds/query. Refs #52 --- configs/grafana/dashboard-base.json | 197 ++++++++++++++ configs/grafana/weathermap-dashboard.json | 2 +- scripts/README.md | 50 +++- scripts/generate_weathermap.py | 300 ++++++---------------- 4 files changed, 320 insertions(+), 229 deletions(-) create mode 100644 configs/grafana/dashboard-base.json diff --git a/configs/grafana/dashboard-base.json b/configs/grafana/dashboard-base.json new file mode 100644 index 0000000..356875c --- /dev/null +++ b/configs/grafana/dashboard-base.json @@ -0,0 +1,197 @@ +{ + "uid": "evpn-vxlan-fabric-weathermap", + "title": "EVPN/VXLAN Fabric Weathermap", + "tags": ["evpn", "vxlan", "weathermap", "auto-generated"], + "timezone": "browser", + "schemaVersion": 39, + "version": 0, + "templating": { + "list": [ + { + "name": "site", + "type": "query", + "datasource": { "type": "prometheus", "uid": "__DATASOURCE_UID__" }, + "query": { "query": "label_values(interfaces_interface_state_oper_status, site)", "refId": "site" }, + "refresh": 2, + "sort": 1, + "multi": true, + "includeAll": true, + "current": { "text": "All", "value": "$__all" } + }, + { + "name": "device", + "type": "query", + "datasource": { "type": "prometheus", "uid": "__DATASOURCE_UID__" }, + "query": { + "query": "label_values(interfaces_interface_state_oper_status{site=~\"$site\"}, device)", + "refId": "device" + }, + "refresh": 2, + "sort": 1, + "multi": true, + "includeAll": true, + "current": { "text": "All", "value": "$__all" } + } + ] + }, + "panels": [ + { + "id": 1, + "title": "__WEATHERMAP_SLOT__", + "gridPos": { "h": 24, "w": 24, "x": 0, "y": 0 } + }, + { + "id": 2, + "type": "table", + "title": "BGP Sessions", + "gridPos": { "h": 10, "w": 12, "x": 0, "y": 24 }, + "datasource": { "type": "prometheus", "uid": "__DATASOURCE_UID__" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "prometheus", "uid": "__DATASOURCE_UID__" }, + "instant": true, + "format": "table", + "expr": "network_instances_network_instance_protocols_protocol_bgp_neighbors_neighbor_state_session_state{device=~\"$device\"}" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "__name__": true, + "instance": true, + "job": true, + "protocol_identifier": true, + "protocol_name": true, + "subscription_name": true + }, + "renameByName": { + "device": "Device", + "neighbor_address": "Neighbor Address", + "network_instance_name": "VRF", + "Value": "State" + } + } + } + ], + "fieldConfig": { + "defaults": { + "mappings": [ + { "type": "value", "options": { "0": { "text": "Down", "color": "red" }, "1": { "text": "Up", "color": "green" } } } + ], + "custom": { "cellOptions": { "type": "color-background" } } + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "State" }, + "properties": [ + { "id": "custom.cellOptions", "value": { "type": "color-background" } }, + { + "id": "mappings", + "value": [ + { "type": "value", "options": { "0": { "text": "Down", "color": "red" }, "1": { "text": "Up", "color": "green" } } } + ] + } + ] + } + ] + } + }, + { + "id": 3, + "type": "table", + "title": "Ports / Interfaces", + "gridPos": { "h": 10, "w": 12, "x": 12, "y": 24 }, + "datasource": { "type": "prometheus", "uid": "__DATASOURCE_UID__" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "prometheus", "uid": "__DATASOURCE_UID__" }, + "instant": true, + "format": "table", + "expr": "interfaces_interface_state_oper_status{device=~\"$device\"}" + }, + { + "refId": "B", + "datasource": { "type": "prometheus", "uid": "__DATASOURCE_UID__" }, + "instant": true, + "format": "table", + "expr": "rate(interfaces_interface_state_counters_in_octets{device=~\"$device\"}[5m]) * 8" + }, + { + "refId": "C", + "datasource": { "type": "prometheus", "uid": "__DATASOURCE_UID__" }, + "instant": true, + "format": "table", + "expr": "rate(interfaces_interface_state_counters_out_octets{device=~\"$device\"}[5m]) * 8" + } + ], + "transformations": [ + { "id": "merge", "options": {} }, + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "__name__": true, + "instance": true, + "job": true, + "subscription_name": true + }, + "renameByName": { + "device": "Device", + "interface": "Interface", + "Value #A": "Status", + "Value #B": "In (bps)", + "Value #C": "Out (bps)" + } + } + } + ], + "fieldConfig": { + "defaults": {}, + "overrides": [ + { + "matcher": { "id": "byName", "options": "Status" }, + "properties": [ + { "id": "custom.cellOptions", "value": { "type": "color-background" } }, + { + "id": "mappings", + "value": [ + { "type": "value", "options": { "0": { "text": "Down", "color": "red" }, "1": { "text": "Up", "color": "green" } } } + ] + } + ] + }, + { "matcher": { "id": "byName", "options": "In (bps)" }, "properties": [{ "id": "unit", "value": "bps" }] }, + { "matcher": { "id": "byName", "options": "Out (bps)" }, "properties": [{ "id": "unit", "value": "bps" }] } + ] + } + }, + { + "id": 4, + "type": "timeseries", + "title": "Throughput per Site", + "gridPos": { "h": 10, "w": 24, "x": 0, "y": 34 }, + "datasource": { "type": "prometheus", "uid": "__DATASOURCE_UID__" }, + "fieldConfig": { "defaults": { "unit": "bps" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "prometheus", "uid": "__DATASOURCE_UID__" }, + "expr": "sum by (site) (rate(interfaces_interface_state_counters_in_octets{site=~\"$site\", interface!~\"Management.*\"}[5m]) * 8)", + "legendFormat": "{{site}} in" + }, + { + "refId": "B", + "datasource": { "type": "prometheus", "uid": "__DATASOURCE_UID__" }, + "expr": "-sum by (site) (rate(interfaces_interface_state_counters_out_octets{site=~\"$site\", interface!~\"Management.*\"}[5m]) * 8)", + "legendFormat": "{{site}} out" + } + ] + } + ] +} diff --git a/configs/grafana/weathermap-dashboard.json b/configs/grafana/weathermap-dashboard.json index b57fa99..80d15ad 100644 --- a/configs/grafana/weathermap-dashboard.json +++ b/configs/grafana/weathermap-dashboard.json @@ -58,7 +58,6 @@ "panels": [ { "id": 1, - "type": "tamirsuliman-weathermap-panel", "title": "Fabric Weathermap", "gridPos": { "h": 24, @@ -66,6 +65,7 @@ "x": 0, "y": 0 }, + "type": "tamirsuliman-weathermap-panel", "datasource": { "type": "prometheus", "uid": "cfrlusac89se8a" diff --git a/scripts/README.md b/scripts/README.md index 2d5e4ea..bd052e7 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,10 +1,25 @@ # scripts/generate_weathermap.py Generates a [weathermap-ng](https://github.com/allamiro/grafana-network-weathermap-ng) -panel from live IPFabric topology + gnmic/Prometheus metrics, and writes a full -Grafana dashboard-as-code JSON to `configs/grafana/weathermap-dashboard.json` -(committed to Gitea as the source of truth, same pattern as the retired Flow -Panel YAML). Optionally provisions it into Grafana via the HTTP API. +**panel only** from live IPFabric topology + gnmic/Prometheus metrics, merges +it into a manually-authored dashboard base, and writes the merged result to +`configs/grafana/weathermap-dashboard.json` (committed to Gitea as the build +artifact, same pattern as the retired Flow Panel YAML). Optionally provisions +it into Grafana via the HTTP API. + +## File structure (see #52) + +| File | What it is | +|---|---| +| `configs/grafana/dashboard-base.json` | **Manually authored**, committed source of truth for everything except the weathermap panel: BGP sessions table, ports/interfaces table, throughput-per-site graphs, `site`/`device` template variables, panel layout. Edit this directly for anything in those panels. Contains a reserved slot panel titled `__WEATHERMAP_SLOT__` (id `1`, `gridPos` fixed) that the script splices the generated weathermap panel into. | +| `configs/grafana/weathermap-dashboard.json` | **Generated build artifact** — base + freshly-generated weathermap panel, merged. This is what actually gets provisioned to Grafana. Don't hand-edit; re-run the script. | + +The script itself only ever knows about the weathermap panel (`targets` + +`options.weathermap`) — it has no knowledge of the other three panels or the +template variables. That split is deliberate: topology-driven content +(nodes/links, generated from live IPFabric+Prometheus data) is separated from +hand-tuned dashboard composition (table layout, transformations, thresholds), +which doesn't need to regenerate on every topology change. See issue #48 for the panel schema research this is built against, and its comment thread for the live-validation history (auth quirks, plugin id, a @@ -16,7 +31,7 @@ anchor/regex-escaping logic). ```bash export IPFABRIC_URL=https:// export IPFABRIC_TOKEN= -python3 scripts/generate_weathermap.py # writes the JSON only +python3 scripts/generate_weathermap.py # writes the merged JSON only export GRAFANA_URL=https:// export GRAFANA_TOKEN= @@ -24,13 +39,25 @@ export GRAFANA_DATASOURCE_UID= python3 scripts/generate_weathermap.py --provision # also provisions via the Grafana API ``` -Re-run after any topology change (`evpn-lab.clab.yml`) to regenerate and -re-provision. +Re-run after any topology change (`evpn-lab.clab.yml`) to regenerate the +weathermap panel and re-merge/re-provision. The script always regenerates the +weathermap panel fresh from live IPFabric data (no diffing/incremental +state), so a plain re-run already picks up any topology change — there's no +separate "detect changes" step. The only manual/operational step is +*triggering* that re-run after a change; there's no watcher or CI hook doing +it automatically yet. + +If you need to change the BGP/ports/throughput panels, template variables, or +layout, edit `configs/grafana/dashboard-base.json` directly and re-run the +script (or run it with `--provision` to push the edit live) — no topology +data is needed for that path, the weathermap panel just gets regenerated +alongside it. ## Environment variables | Variable | Required | Default | Notes | |---|---|---|---| +| `--base` (CLI flag, not env) | no | `configs/grafana/dashboard-base.json` | Manual dashboard base to merge the weathermap panel into. | | `IPFABRIC_URL` | yes | — | e.g. `https://ipfabric.example.com` | | `IPFABRIC_TOKEN` | yes | — | Inventory/Snapshots read access. Sent as `X-API-Token`, not `Authorization: Bearer` — IPFabric's REST API does not use bearer auth. | | `IPFABRIC_SNAPSHOT` | no | `$last` | | @@ -66,8 +93,13 @@ re-provision. name has no matching series is logged, not silently dropped — the actual fix for a real mismatch belongs in gnmic interface aliasing (#43), not in this script. -7. Writes the dashboard JSON, and provisions it via `POST - /api/dashboards/db` if `--provision` is passed. +7. Loads `configs/grafana/dashboard-base.json`, substitutes the real + Prometheus datasource UID into its `__DATASOURCE_UID__` placeholders, + finds the panel titled `__WEATHERMAP_SLOT__` and splices in the generated + weathermap panel content (keeping the slot's `gridPos`/`id`, so the manual + layout is never repositioned). +8. Writes the merged dashboard JSON to `configs/grafana/weathermap-dashboard.json`, + and provisions it via `POST /api/dashboards/db` if `--provision` is passed. ## Known gaps diff --git a/scripts/generate_weathermap.py b/scripts/generate_weathermap.py index 5fa6ed3..0d6d1a3 100644 --- a/scripts/generate_weathermap.py +++ b/scripts/generate_weathermap.py @@ -1,6 +1,14 @@ #!/usr/bin/env python3 -"""Generate a weathermap-ng panel config from IPFabric topology + gnmic/Prometheus -metrics, and optionally provision it into a Grafana dashboard. +"""Generate a weathermap-ng PANEL (only) from IPFabric topology + gnmic/Prometheus +metrics, merge it into a manually-authored dashboard base, and optionally +provision the result into Grafana. + +Scope (see #52): this script knows only about the weathermap panel -- +targets (node status / link tx / link rx / VXLAN tooltip queries) and +options.weathermap (nodes/links/scale/settings). It has no knowledge of the +BGP sessions table, ports/interfaces table, or throughput panels -- those +live in the manually-authored `configs/grafana/dashboard-base.json` and are +not touched by this script. Data sources (see gitea issues #44, #47, #48): - IPFabric REST API: device inventory + connectivity-matrix (topology) @@ -8,8 +16,13 @@ Data sources (see gitea issues #44, #47, #48): values, used both to validate the IPFabric->gnmic interface-name mapping and to resolve which VTEPs/VLANs actually have VXLAN data. +Merge: the weathermap panel is generated fresh from live data on every run +(no diffing), then spliced into the base dashboard's reserved slot (the panel +titled "__WEATHERMAP_SLOT__"), keeping that slot's gridPos so the manually +authored layout is never repositioned by the generator. See #52. + Usage: - python3 scripts/generate_weathermap.py [--output PATH] [--provision] + python3 scripts/generate_weathermap.py [--base PATH] [--output PATH] [--provision] Environment: IPFABRIC_URL e.g. https://ipfabric.example.com @@ -396,226 +409,66 @@ def build_weathermap(devices, links, interface_speeds, positions, prometheus_url # -------------------------------------------------------------------------- -# Grafana provisioning +# Weathermap panel (only) -- see #52, scope narrowed from full-dashboard # -------------------------------------------------------------------------- -def build_templating(datasource_uid): - ds = {"type": "prometheus", "uid": datasource_uid} +WEATHERMAP_SLOT_TITLE = "__WEATHERMAP_SLOT__" + + +def build_weathermap_panel(weathermap, targets, datasource_uid, plugin_id): + """The weathermap panel's own content: type/datasource/targets/options. + Deliberately has no gridPos/id/title of its own -- those come from + whatever slot it's merged into (see merge_weathermap_into_base).""" return { - "list": [ - { - "name": "site", - "type": "query", - "datasource": ds, - "query": {"query": "label_values(interfaces_interface_state_oper_status, site)", "refId": "site"}, - "refresh": 2, - "sort": 1, - "multi": True, - "includeAll": True, - "current": {"text": "All", "value": "$__all"}, - }, - { - "name": "device", - "type": "query", - "datasource": ds, - # chained: only devices belonging to the selected site(s) - "query": { - "query": 'label_values(interfaces_interface_state_oper_status{site=~"$site"}, device)', - "refId": "device", - }, - "refresh": 2, - "sort": 1, - "multi": True, - "includeAll": True, - "current": {"text": "All", "value": "$__all"}, - }, - ] - } - - -def build_bgp_table_panel(panel_id, datasource_uid, grid_y): - ds = {"type": "prometheus", "uid": datasource_uid} - return { - "id": panel_id, - "type": "table", - "title": "BGP Sessions", - "gridPos": {"h": 10, "w": 12, "x": 0, "y": grid_y}, - "datasource": ds, - "targets": [ - { - "refId": "A", - "datasource": ds, - "instant": True, - "format": "table", - "expr": ( - 'network_instances_network_instance_protocols_protocol_bgp_neighbors_neighbor_state_session_state' - '{device=~"$device"}' - ), - } - ], - "transformations": [ - { - "id": "organize", - "options": { - "excludeByName": { - "Time": True, "__name__": True, "instance": True, "job": True, - "protocol_identifier": True, "protocol_name": True, "subscription_name": True, - }, - "renameByName": { - "device": "Device", - "neighbor_address": "Neighbor Address", - "network_instance_name": "VRF", - "Value": "State", - }, - }, - } - ], - "fieldConfig": { - "defaults": { - "mappings": [ - {"type": "value", "options": {"0": {"text": "Down", "color": "red"}, "1": {"text": "Up", "color": "green"}}} - ], - "custom": {"cellOptions": {"type": "color-background"}}, - }, - "overrides": [ - {"matcher": {"id": "byName", "options": "State"}, "properties": [ - {"id": "custom.cellOptions", "value": {"type": "color-background"}}, - {"id": "mappings", "value": [ - {"type": "value", "options": {"0": {"text": "Down", "color": "red"}, "1": {"text": "Up", "color": "green"}}} - ]}, - ]} - ], - }, - } - - -def build_ports_table_panel(panel_id, datasource_uid, grid_y): - ds = {"type": "prometheus", "uid": datasource_uid} - return { - "id": panel_id, - "type": "table", - "title": "Ports / Interfaces", - "gridPos": {"h": 10, "w": 12, "x": 12, "y": grid_y}, - "datasource": ds, - "targets": [ - { - "refId": "A", - "datasource": ds, - "instant": True, - "format": "table", - "expr": 'interfaces_interface_state_oper_status{device=~"$device"}', - }, - { - "refId": "B", - "datasource": ds, - "instant": True, - "format": "table", - "expr": 'rate(interfaces_interface_state_counters_in_octets{device=~"$device"}[5m]) * 8', - }, - { - "refId": "C", - "datasource": ds, - "instant": True, - "format": "table", - "expr": 'rate(interfaces_interface_state_counters_out_octets{device=~"$device"}[5m]) * 8', - }, - ], - # Grafana-side merge (not a PromQL join) -- see #44/#48 VXLAN join - # failure; joining on (device, interface) in PromQL across these - # three metric families is fragile, merging the three table results - # in Grafana on shared field values is not. - "transformations": [ - {"id": "merge", "options": {}}, - { - "id": "organize", - "options": { - "excludeByName": { - "Time": True, "__name__": True, "instance": True, "job": True, "subscription_name": True, - }, - "renameByName": { - "device": "Device", - "interface": "Interface", - "Value #A": "Status", - "Value #B": "In (bps)", - "Value #C": "Out (bps)", - }, - }, - }, - ], - "fieldConfig": { - "defaults": {}, - "overrides": [ - {"matcher": {"id": "byName", "options": "Status"}, "properties": [ - {"id": "custom.cellOptions", "value": {"type": "color-background"}}, - {"id": "mappings", "value": [ - {"type": "value", "options": {"0": {"text": "Down", "color": "red"}, "1": {"text": "Up", "color": "green"}}} - ]}, - ]}, - {"matcher": {"id": "byName", "options": "In (bps)"}, "properties": [{"id": "unit", "value": "bps"}]}, - {"matcher": {"id": "byName", "options": "Out (bps)"}, "properties": [{"id": "unit", "value": "bps"}]}, - ], - }, - } - - -def build_throughput_panel(panel_id, datasource_uid, grid_y): - ds = {"type": "prometheus", "uid": datasource_uid} - return { - "id": panel_id, - "type": "timeseries", - "title": "Throughput per Site", - "gridPos": {"h": 10, "w": 24, "x": 0, "y": grid_y}, - "datasource": ds, - "fieldConfig": {"defaults": {"unit": "bps"}, "overrides": []}, - "targets": [ - { - "refId": "A", - "datasource": ds, - # Management0 excluded -- gNMI telemetry traffic on the OOB - # port otherwise swamps fabric traffic and skews in/out - # wildly asymmetric, see #51. - "expr": 'sum by (site) (rate(interfaces_interface_state_counters_in_octets{site=~"$site", interface!~"Management.*"}[5m]) * 8)', - "legendFormat": "{{site}} in", - }, - { - "refId": "B", - "datasource": ds, - # negated so in/out render as a signed graph (in above zero, out below) - "expr": '-sum by (site) (rate(interfaces_interface_state_counters_out_octets{site=~"$site", interface!~"Management.*"}[5m]) * 8)', - "legendFormat": "{{site}} out", - }, - ], - } - - -def build_dashboard(weathermap, targets, dashboard_uid, datasource_uid, plugin_id): - weathermap_panel = { - "id": 1, "type": plugin_id, - "title": "Fabric Weathermap", - "gridPos": {"h": 24, "w": 24, "x": 0, "y": 0}, "datasource": {"type": "prometheus", "uid": datasource_uid}, "targets": [dict(t, datasource={"type": "prometheus", "uid": datasource_uid}) for t in targets], "options": {"weathermap": weathermap}, } - return { - "dashboard": { - "uid": dashboard_uid, - "title": "EVPN/VXLAN Fabric Weathermap", - "tags": ["evpn", "vxlan", "weathermap", "auto-generated"], - "timezone": "browser", - "schemaVersion": 39, - "version": 0, - "templating": build_templating(datasource_uid), - "panels": [ - weathermap_panel, - build_bgp_table_panel(2, datasource_uid, 24), - build_ports_table_panel(3, datasource_uid, 24), - build_throughput_panel(4, datasource_uid, 34), - ], - }, - "overwrite": True, - } + + +# -------------------------------------------------------------------------- +# Manual base dashboard + merge (see #52) +# -------------------------------------------------------------------------- + +def load_base_dashboard(path): + with open(path) as f: + return json.load(f) + + +def substitute_placeholders(obj, replacements): + """Recursively replace exact-match string placeholders (e.g. the + datasource UID token) anywhere in the manually-authored base JSON.""" + if isinstance(obj, dict): + return {k: substitute_placeholders(v, replacements) for k, v in obj.items()} + if isinstance(obj, list): + return [substitute_placeholders(v, replacements) for v in obj] + if isinstance(obj, str) and obj in replacements: + return replacements[obj] + return obj + + +def merge_weathermap_into_base(base_dashboard, weathermap_panel, dashboard_uid): + """Splice the freshly generated weathermap panel into the base + dashboard's reserved slot (matched by title), keeping the slot's + gridPos/id -- layout stays manual, the generator never repositions it.""" + dashboard = dict(base_dashboard) + dashboard["uid"] = dashboard_uid + panels = [] + found = False + for panel in dashboard.get("panels", []): + if panel.get("title") == WEATHERMAP_SLOT_TITLE: + found = True + merged = dict(panel) + merged.update(weathermap_panel) + merged["title"] = "Fabric Weathermap" + panels.append(merged) + else: + panels.append(panel) + if not found: + sys.exit(f"Base dashboard has no panel titled {WEATHERMAP_SLOT_TITLE!r} -- nowhere to merge the weathermap panel") + dashboard["panels"] = panels + return dashboard def provision_to_grafana(grafana_url, api_token, dashboard_payload): @@ -636,10 +489,12 @@ def provision_to_grafana(grafana_url, api_token, dashboard_payload): def main(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--base", default="configs/grafana/dashboard-base.json", + help="manually-authored dashboard base JSON (everything but the weathermap panel)") parser.add_argument("--output", default="configs/grafana/weathermap-dashboard.json", - help="where to write the generated dashboard-as-code JSON") + help="where to write the merged dashboard-as-code JSON (build artifact)") parser.add_argument("--provision", action="store_true", - help="also POST the dashboard to the Grafana API (requires GRAFANA_* env vars)") + help="also POST the merged dashboard to the Grafana API (requires GRAFANA_* env vars)") args = parser.parse_args() ipfabric_url = env("IPFABRIC_URL", required=True) @@ -674,13 +529,20 @@ def main(): dashboard_uid = env("GRAFANA_DASHBOARD_UID", "evpn-vxlan-fabric-weathermap") datasource_uid = env("GRAFANA_DATASOURCE_UID", "PROMETHEUS_DATASOURCE_UID_PLACEHOLDER") plugin_id = env("GRAFANA_WEATHERMAP_PLUGIN_ID", "tamirsuliman-weathermap-panel") - dashboard_payload = build_dashboard(weathermap, targets, dashboard_uid, datasource_uid, plugin_id) + weathermap_panel = build_weathermap_panel(weathermap, targets, datasource_uid, plugin_id) + + print(f"Loading manual dashboard base ({args.base})...") + base_dashboard = load_base_dashboard(args.base) + base_dashboard = substitute_placeholders(base_dashboard, {"__DATASOURCE_UID__": datasource_uid}) + dashboard = merge_weathermap_into_base(base_dashboard, weathermap_panel, dashboard_uid) + dashboard_payload = {"dashboard": dashboard, "overwrite": True} os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True) with open(args.output, "w") as f: json.dump(dashboard_payload, f, indent=2) f.write("\n") - print(f"Wrote {args.output} ({len(weathermap['nodes'])} nodes, {len(weathermap['links'])} links)") + print(f"Wrote {args.output} ({len(weathermap['nodes'])} nodes, {len(weathermap['links'])} links, " + f"{len(dashboard['panels'])} panels total)") if args.provision: grafana_url = env("GRAFANA_URL", required=True)