#!/usr/bin/env python3 """Generate a weathermap-ng panel config from IPFabric topology + gnmic/Prometheus metrics, and optionally provision it into a Grafana dashboard. Data sources (see gitea issues #44, #47, #48): - IPFabric REST API: device inventory + connectivity-matrix (topology) - Prometheus (gnmic exporter, configs/prometheus/prometheus.yml): live label values, used both to validate the IPFabric->gnmic interface-name mapping and to resolve which VTEPs/VLANs actually have VXLAN data. Usage: python3 scripts/generate_weathermap.py [--output PATH] [--provision] Environment: IPFABRIC_URL e.g. https://ipfabric.example.com IPFABRIC_TOKEN API token (Inventory/Snapshots read access) IPFABRIC_SNAPSHOT snapshot id, default "$last" PROMETHEUS_URL default http://172.16.0.71:9090 (in-topology instance) GRAFANA_URL required with --provision GRAFANA_TOKEN required with --provision GRAFANA_DATASOURCE_UID Prometheus datasource UID in Grafana, required with --provision GRAFANA_DASHBOARD_UID default "evpn-vxlan-fabric-weathermap" GRAFANA_WEATHERMAP_PLUGIN_ID default "tamirsuliman-weathermap-panel" -- override if a different weathermap-ng fork/plugin id is installed. """ import argparse import json import os import re import sys import urllib.request import urllib.error import urllib.parse WEATHERMAP_SCHEMA_VERSION = 14 # IPFabric abbreviated interface prefixes -> gnmic/OpenConfig full names. # Longest-prefix-first so e.g. "Ma" doesn't shadow a hypothetical multi-letter clash. INTERFACE_PREFIX_ALIASES = { "Et": "Ethernet", "Po": "Port-Channel", "Lo": "Loopback", "Vl": "Vlan", "Ma": "Management", } SITE_ORDER = ["dc", "core", "campus"] GRID_COLUMNS = 6 GRID_SPACING_X = 180 GRID_SPACING_Y = 150 SITE_BAND_Y = {"dc": 0, "core": 450, "campus": 750} # tamirsuliman-weathermap-panel's numeric anchor enum (Center=0, Top=1, # Bottom=2, Left=3, Right=4) -- reverse-engineered from module.js, since # link.sides.*.anchor and node.anchors{} both index by this numeric value, # not by the anchor name string. ANCHOR = {"Center": 0, "Top": 1, "Bottom": 2, "Left": 3, "Right": 4} NODE_COLORS = {"font": "#ffffff", "background": "#22252b", "border": "#5794F2", "statusDown": "#F2495C"} STATUS_VALUE_MAPPINGS = [{"value": 0, "color": "#F2495C"}, {"value": 1, "color": "#73BF69"}] DEFAULT_LINK_BANDWIDTH_BPS = 10_000_000_000 # 10G fallback when IPFabric speed is missing def env(name, default=None, required=False): val = os.environ.get(name, default) if required and not val: sys.exit(f"Missing required environment variable: {name}") return val # -------------------------------------------------------------------------- # IPFabric # -------------------------------------------------------------------------- def ipfabric_request(base_url, token, path, body): req = urllib.request.Request( f"{base_url.rstrip('/')}/api{path}", data=json.dumps(body).encode(), headers={"X-API-Token": token, "Content-Type": "application/json"}, method="POST", ) with urllib.request.urlopen(req, timeout=30) as resp: return json.load(resp)["data"] def fetch_devices(base_url, token, snapshot): return ipfabric_request( base_url, token, "/tables/inventory/devices", {"columns": ["hostname", "siteName", "vendor", "model", "sn"], "snapshot": snapshot, "pagination": {"limit": 1000, "start": 0}}, ) def fetch_connectivity_matrix(base_url, token, snapshot): return ipfabric_request( base_url, token, "/tables/interfaces/connectivity-matrix", {"columns": ["localHost", "localInt", "remoteHost", "remoteInt", "protocol"], "snapshot": snapshot, "pagination": {"limit": 5000, "start": 0}}, ) def fetch_interface_speeds(base_url, token, snapshot): rows = ipfabric_request( base_url, token, "/tables/inventory/interfaces", {"columns": ["hostname", "intName", "speed"], "snapshot": snapshot, "pagination": {"limit": 5000, "start": 0}}, ) speeds = {} for row in rows: speed = row.get("speed") if speed: speeds[(row["hostname"], row["intName"])] = int(speed) return speeds # -------------------------------------------------------------------------- # Prometheus (validation + VXLAN discovery) # -------------------------------------------------------------------------- def prom_query(prometheus_url, promql): url = f"{prometheus_url.rstrip('/')}/api/v1/query?query=" + urllib.parse.quote(promql) with urllib.request.urlopen(url, timeout=15) as resp: payload = json.load(resp) if payload["status"] != "success": sys.exit(f"Prometheus query failed: {promql}") return payload["data"]["result"] def known_interface_pairs(prometheus_url): """(device, interface) pairs that actually exist in the gnmic exporter, used to validate the IPFabric->gnmic interface alias before trusting it.""" results = prom_query(prometheus_url, "interfaces_interface_state_oper_status") return {(m["metric"]["device"], m["metric"]["interface"]) for m in results} def known_vtep_vlan_pairs(prometheus_url): """(device, vlan) pairs with a live VLAN-to-VNI mapping -- these are the VTEP nodes eligible for a VXLAN MAC-count tooltip metric.""" results = prom_query(prometheus_url, "interfaces_interface_arista_vxlan_vlan_to_vnis_vlan_to_vni_state_vni") return {(m["metric"]["device"], m["metric"]["vlan"]) for m in results} # -------------------------------------------------------------------------- # Interface aliasing (IPFabric abbreviated name -> gnmic full name) # -------------------------------------------------------------------------- def alias_interface(ipf_name): match = re.match(r"^([A-Za-z]+)(\d.*)$", ipf_name) if not match: return ipf_name prefix, rest = match.groups() full = INTERFACE_PREFIX_ALIASES.get(prefix) if full is None: return ipf_name return f"{full}{rest}" def promql_escape(s): """re.escape() escapes '-' as '\\-', which Python's own regex engine accepts but PromQL's RE2-based engine rejects ("unknown escape sequence U+002D '-'") -- and every hostname in this lab contains hyphens. '-' is not a regex metacharacter outside a character class, so it's safe to leave unescaped.""" return re.escape(s).replace("\\-", "-") # -------------------------------------------------------------------------- # Topology processing # -------------------------------------------------------------------------- def build_layout(devices): by_site = {} for dev in devices: by_site.setdefault(dev["siteName"], []).append(dev["hostname"]) positions = {} for site in SITE_ORDER: band_y = SITE_BAND_Y.get(site, 0) for idx, host in enumerate(sorted(by_site.get(site, []))): col, row = idx % GRID_COLUMNS, idx // GRID_COLUMNS positions[host] = [100 + col * GRID_SPACING_X, band_y + row * GRID_SPACING_Y] # Any site not in SITE_ORDER (shouldn't happen given the naming convention, # but don't silently drop nodes if it does) gets stacked below everything else. extra_band_y = max(SITE_BAND_Y.values()) + 300 for site, hosts in by_site.items(): if site in SITE_ORDER: continue for idx, host in enumerate(sorted(hosts)): col, row = idx % GRID_COLUMNS, idx // GRID_COLUMNS positions[host] = [100 + col * GRID_SPACING_X, extra_band_y + row * GRID_SPACING_Y] return positions def dedupe_links(connectivity_matrix): """connectivity-matrix reports each physical link twice (once from each side), plus management-plane (Management0) neighbor entries, plus one row per 802.1Q subinterface on trunked ports (e.g. Et13.100/Et13.200, used for the gold VRF stitching on Core -- see README). Subinterfaces ride the same physical port as their parent interface, so they'd otherwise show up as bogus duplicate parallel links with permanently-unresolvable queries (gnmic only subscribes to physical interface counters). Keep only physical Ethernet-to-Ethernet links, one entry per unordered (host,int)-(host,int) pair.""" seen = set() links = [] for row in connectivity_matrix: local_int, remote_int = row["localInt"], row["remoteInt"] if "." in local_int or "." in remote_int: continue if not (local_int.startswith("Et") and remote_int.startswith("Et")): continue side_a = (row["localHost"], local_int) side_z = (row["remoteHost"], remote_int) key = tuple(sorted([side_a, side_z])) if key in seen: continue seen.add(key) links.append({"a_host": key[0][0], "a_int": key[0][1], "z_host": key[1][0], "z_int": key[1][1]}) return links # -------------------------------------------------------------------------- # Weathermap assembly # -------------------------------------------------------------------------- def build_weathermap(devices, links, interface_speeds, positions, prometheus_url, mismatches): hostnames = [d["hostname"] for d in devices] host_regex = "|".join(promql_escape(h) for h in hostnames) known_pairs = known_interface_pairs(prometheus_url) vtep_vlan_pairs = known_vtep_vlan_pairs(prometheus_url) vtep_hosts = sorted({dev for dev, _ in vtep_vlan_pairs}) def resolve_and_check(host, ipf_int, side_label): gnmic_int = alias_interface(ipf_int) if (host, gnmic_int) not in known_pairs: mismatches.append( f"{host} {ipf_int} -> {gnmic_int} ({side_label}): no matching series in " f"interfaces_interface_state_oper_status -- link will reference a query that " f"resolves to no data until the gnmic/IPFabric interface names are aligned " f"(see #47/#48 interface aliasing fallback)" ) return gnmic_int # -- nodes -- # anchors{} tallies how many link-sides attach to each anchor position on # this node; every link below uses Right for its A side and Left for its # Z side, so that's what gets counted per node here. anchor_counts = {host: {a: 0 for a in ANCHOR.values()} for host in (d["hostname"] for d in devices)} for link in links: anchor_counts[link["a_host"]][ANCHOR["Right"]] += 1 anchor_counts[link["z_host"]][ANCHOR["Left"]] += 1 nodes = [] for dev in devices: host = dev["hostname"] node = { "id": host, "label": host, "position": positions[host], "isConnection": False, "useConstantSpacing": False, "compactVerticalLinks": False, "padding": {"horizontal": 12, "vertical": 6}, "colors": dict(NODE_COLORS), "nodeIcon": None, "statusQuery": f"STATUS {host}", "nodeStatusColorTarget": "border", "statusValueMappings": [dict(m) for m in STATUS_VALUE_MAPPINGS], "anchors": {a: {"numLinks": anchor_counts[host][a], "numFilledLinks": 0} for a in ANCHOR.values()}, } if host in vtep_hosts: node["tooltipMetrics"] = [ {"label": f"VNI {host} {vlan}", "query": f"VNI {host} {vlan}", "units": "MACs"} for _, vlan in sorted(v for v in vtep_vlan_pairs if v[0] == host) ] nodes.append(node) # -- links -- link_defs = [] interface_regex_parts = set() for link in links: a_gnmic_int = resolve_and_check(link["a_host"], link["a_int"], "side A") z_gnmic_int = resolve_and_check(link["z_host"], link["z_int"], "side Z") interface_regex_parts.add(promql_escape(a_gnmic_int)) interface_regex_parts.add(promql_escape(z_gnmic_int)) a_bw = interface_speeds.get((link["a_host"], link["a_int"]), DEFAULT_LINK_BANDWIDTH_BPS) z_bw = interface_speeds.get((link["z_host"], link["z_int"]), DEFAULT_LINK_BANDWIDTH_BPS) if (link["a_host"], link["a_int"]) not in interface_speeds: mismatches.append(f"{link['a_host']} {link['a_int']}: no IPFabric speed, defaulting bandwidth to {DEFAULT_LINK_BANDWIDTH_BPS}") if (link["z_host"], link["z_int"]) not in interface_speeds: mismatches.append(f"{link['z_host']} {link['z_int']}: no IPFabric speed, defaulting bandwidth to {DEFAULT_LINK_BANDWIDTH_BPS}") link_defs.append({ "id": f"{link['a_host']}-{a_gnmic_int}--{link['z_host']}-{z_gnmic_int}", "nodes": [{"id": link["a_host"]}, {"id": link["z_host"]}], "sides": { "A": { "bandwidth": a_bw, "query": f"{link['a_host']} {a_gnmic_int} tx", "labelOffset": 55, "anchor": ANCHOR["Right"], "dashboardLink": "", }, "Z": { "bandwidth": z_bw, "query": f"{link['z_host']} {z_gnmic_int} rx", "labelOffset": 55, "anchor": ANCHOR["Left"], "dashboardLink": "", }, }, "units": "bps", "arrows": {"width": 8, "height": 10, "offset": 2}, "stroke": 5, "showThroughputPercentage": False, }) interface_regex = "|".join(sorted(interface_regex_parts)) # -- targets (one query per metric family, per task spec) -- # # Node status (refId A): a device is "up" only if at least one interface # is up AND every BGP session is up, if it has any -- see #49. The old # "BGP {{device}}" query sourced raw per-neighbor session-state series, # so a device with multiple neighbors collided on one legend string and # only one arbitrarily won. `min by (device)` picks the *worst* session # (0 beats 1), and devices with zero BGP sessions (access switches) are # not penalized: the `or` fallback substitutes a constant 1 for any # device present in device-up but absent from the BGP series entirely. device_up = f'min by (device) (interfaces_interface_state_oper_status{{device=~"{host_regex}"}})' bgp_worst = ( f'min by (device) (network_instances_network_instance_protocols_protocol_bgp_neighbors_neighbor_state_session_state' f'{{network_instance_name="default", device=~"{host_regex}"}})' ) bgp_ok_or_not_applicable = f'({bgp_worst} or ({device_up} * 0 + 1))' targets = [ { "refId": "A", "expr": f'{device_up} * {bgp_ok_or_not_applicable}', "legendFormat": "STATUS {{device}}", }, { "refId": "B", "expr": f'rate(interfaces_interface_state_counters_out_octets{{device=~"{host_regex}", interface=~"{interface_regex}"}}[5m]) * 8', "legendFormat": "{{device}} {{interface}} tx", }, { "refId": "C", "expr": f'rate(interfaces_interface_state_counters_in_octets{{device=~"{host_regex}", interface=~"{interface_regex}"}}[5m]) * 8', "legendFormat": "{{device}} {{interface}} rx", }, ] if vtep_hosts: vtep_regex = "|".join(promql_escape(h) for h in vtep_hosts) # Verbatim join from #44 "VXLAN (MAC count per VNI)", scoped to VTEP nodes. targets.append({ "refId": "D", "expr": ( f'count by (device, vlan) (network_instances_network_instance_fdb_mac_table_entries_entry_vlan{{device=~"{vtep_regex}"}})\n' f'* on(device, vlan) group_left(vlan_to_vni_state_vni)\n' f'interfaces_interface_arista_vxlan_vlan_to_vnis_vlan_to_vni_state_vni{{device=~"{vtep_regex}"}}' ), "legendFormat": "VNI {{device}} {{vlan}}", }) weathermap = { "version": WEATHERMAP_SCHEMA_VERSION, "id": "evpn-vxlan-fabric-weathermap", "nodes": nodes, "links": link_defs, "scale": [ {"percent": 0, "color": "#5794F2"}, {"percent": 70, "color": "#FA6400"}, {"percent": 90, "color": "#C4162A"}, ], "settings": { "panel": {"backgroundColor": "#212124", "panelSize": {"width": 1600, "height": 1000}, "zoomScale": 0, "offset": {"x": 0, "y": 0}, "showTimestamp": True, "grid": {"enabled": False, "size": 10, "guidesEnabled": False}}, "link": {"spacing": {"horizontal": 10, "vertical": 5}, "stroke": {"color": "#CCCCDC"}, "label": {"background": "#FFFFFF", "border": "#000000", "font": "#000000"}, "showAllWithPercentage": False, "defaultUnits": "bps"}, "tooltip": {"fontSize": 10, "textColor": "#CCCCDC", "backgroundColor": "#1A1B1F", "inboundColor": "#73BF69", "outboundColor": "#5794F2", "scaleToBandwidth": False}, "fontSizing": {"node": 12, "link": 10}, "scale": {"position": {"x": 0, "y": 0}, "size": {"width": 150, "height": 100}, "title": "Utilization", "fontSizing": {"title": 10, "threshold": 9}}, }, } return weathermap, targets # -------------------------------------------------------------------------- # Grafana provisioning # -------------------------------------------------------------------------- def build_templating(datasource_uid): ds = {"type": "prometheus", "uid": datasource_uid} 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, "expr": 'sum by (site) (rate(interfaces_interface_state_counters_in_octets{site=~"$site"}[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"}[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, } def provision_to_grafana(grafana_url, api_token, dashboard_payload): req = urllib.request.Request( f"{grafana_url.rstrip('/')}/api/dashboards/db", data=json.dumps(dashboard_payload).encode(), headers={"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"}, method="POST", ) try: with urllib.request.urlopen(req, timeout=30) as resp: return json.load(resp) except urllib.error.HTTPError as e: sys.exit(f"Grafana provisioning failed: HTTP {e.code} {e.read().decode()}") # -------------------------------------------------------------------------- def main(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--output", default="configs/grafana/weathermap-dashboard.json", help="where to write the generated dashboard-as-code JSON") parser.add_argument("--provision", action="store_true", help="also POST the dashboard to the Grafana API (requires GRAFANA_* env vars)") args = parser.parse_args() ipfabric_url = env("IPFABRIC_URL", required=True) ipfabric_token = env("IPFABRIC_TOKEN", required=True) snapshot = env("IPFABRIC_SNAPSHOT", "$last") prometheus_url = env("PROMETHEUS_URL", "http://172.16.0.71:9090") print(f"Fetching devices from IPFabric ({ipfabric_url}, snapshot={snapshot})...") devices = fetch_devices(ipfabric_url, ipfabric_token, snapshot) print(f" {len(devices)} devices") print("Fetching connectivity-matrix...") matrix = fetch_connectivity_matrix(ipfabric_url, ipfabric_token, snapshot) links = dedupe_links(matrix) print(f" {len(links)} fabric links after Management-plane filter + dedup ({len(matrix)} raw rows)") print("Fetching interface speeds...") interface_speeds = fetch_interface_speeds(ipfabric_url, ipfabric_token, snapshot) positions = build_layout(devices) mismatches = [] print(f"Cross-checking interface names against live exporter ({prometheus_url})...") weathermap, targets = build_weathermap(devices, links, interface_speeds, positions, prometheus_url, mismatches) if mismatches: print(f"\n{len(mismatches)} interface-name / bandwidth mismatch(es) found (link kept, not dropped):", file=sys.stderr) for m in mismatches: print(f" - {m}", file=sys.stderr) print(file=sys.stderr) 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) 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)") if args.provision: grafana_url = env("GRAFANA_URL", required=True) grafana_token = env("GRAFANA_TOKEN", required=True) if datasource_uid == "PROMETHEUS_DATASOURCE_UID_PLACEHOLDER": sys.exit("Refusing to provision: set GRAFANA_DATASOURCE_UID to the real Prometheus datasource UID first") print(f"Provisioning dashboard '{dashboard_uid}' to {grafana_url}...") result = provision_to_grafana(grafana_url, grafana_token, dashboard_payload) print(f" {result.get('status')}: {result.get('url')}") if __name__ == "__main__": main()