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
This commit is contained in:
2026-07-10 10:12:07 +00:00
parent dba06c1607
commit 632d146368
4 changed files with 320 additions and 229 deletions

View File

@@ -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"
}
]
}
]
}

View File

@@ -58,7 +58,6 @@
"panels": [ "panels": [
{ {
"id": 1, "id": 1,
"type": "tamirsuliman-weathermap-panel",
"title": "Fabric Weathermap", "title": "Fabric Weathermap",
"gridPos": { "gridPos": {
"h": 24, "h": 24,
@@ -66,6 +65,7 @@
"x": 0, "x": 0,
"y": 0 "y": 0
}, },
"type": "tamirsuliman-weathermap-panel",
"datasource": { "datasource": {
"type": "prometheus", "type": "prometheus",
"uid": "cfrlusac89se8a" "uid": "cfrlusac89se8a"

View File

@@ -1,10 +1,25 @@
# scripts/generate_weathermap.py # scripts/generate_weathermap.py
Generates a [weathermap-ng](https://github.com/allamiro/grafana-network-weathermap-ng) Generates a [weathermap-ng](https://github.com/allamiro/grafana-network-weathermap-ng)
panel from live IPFabric topology + gnmic/Prometheus metrics, and writes a full **panel only** from live IPFabric topology + gnmic/Prometheus metrics, merges
Grafana dashboard-as-code JSON to `configs/grafana/weathermap-dashboard.json` it into a manually-authored dashboard base, and writes the merged result to
(committed to Gitea as the source of truth, same pattern as the retired Flow `configs/grafana/weathermap-dashboard.json` (committed to Gitea as the build
Panel YAML). Optionally provisions it into Grafana via the HTTP API. 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 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 comment thread for the live-validation history (auth quirks, plugin id, a
@@ -16,7 +31,7 @@ anchor/regex-escaping logic).
```bash ```bash
export IPFABRIC_URL=https://<ipfabric-instance> export IPFABRIC_URL=https://<ipfabric-instance>
export IPFABRIC_TOKEN=<token> export IPFABRIC_TOKEN=<token>
python3 scripts/generate_weathermap.py # writes the JSON only python3 scripts/generate_weathermap.py # writes the merged JSON only
export GRAFANA_URL=https://<external-grafana-instance> export GRAFANA_URL=https://<external-grafana-instance>
export GRAFANA_TOKEN=<token> export GRAFANA_TOKEN=<token>
@@ -24,13 +39,25 @@ export GRAFANA_DATASOURCE_UID=<prometheus-datasource-uid-in-grafana>
python3 scripts/generate_weathermap.py --provision # also provisions via the Grafana API 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-run after any topology change (`evpn-lab.clab.yml`) to regenerate the
re-provision. 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 ## Environment variables
| Variable | Required | Default | Notes | | 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_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_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` | | | `IPFABRIC_SNAPSHOT` | no | `$last` | |
@@ -66,8 +93,13 @@ re-provision.
name has no matching series is logged, not silently dropped — the actual 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 fix for a real mismatch belongs in gnmic interface aliasing (#43), not in
this script. this script.
7. Writes the dashboard JSON, and provisions it via `POST 7. Loads `configs/grafana/dashboard-base.json`, substitutes the real
/api/dashboards/db` if `--provision` is passed. 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 ## Known gaps

View File

@@ -1,6 +1,14 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Generate a weathermap-ng panel config from IPFabric topology + gnmic/Prometheus """Generate a weathermap-ng PANEL (only) from IPFabric topology + gnmic/Prometheus
metrics, and optionally provision it into a Grafana dashboard. 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): Data sources (see gitea issues #44, #47, #48):
- IPFabric REST API: device inventory + connectivity-matrix (topology) - 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 values, used both to validate the IPFabric->gnmic interface-name mapping
and to resolve which VTEPs/VLANs actually have VXLAN data. 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: Usage:
python3 scripts/generate_weathermap.py [--output PATH] [--provision] python3 scripts/generate_weathermap.py [--base PATH] [--output PATH] [--provision]
Environment: Environment:
IPFABRIC_URL e.g. https://ipfabric.example.com 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): WEATHERMAP_SLOT_TITLE = "__WEATHERMAP_SLOT__"
ds = {"type": "prometheus", "uid": datasource_uid}
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 { 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, "type": plugin_id,
"title": "Fabric Weathermap",
"gridPos": {"h": 24, "w": 24, "x": 0, "y": 0},
"datasource": {"type": "prometheus", "uid": datasource_uid}, "datasource": {"type": "prometheus", "uid": datasource_uid},
"targets": [dict(t, datasource={"type": "prometheus", "uid": datasource_uid}) for t in targets], "targets": [dict(t, datasource={"type": "prometheus", "uid": datasource_uid}) for t in targets],
"options": {"weathermap": weathermap}, "options": {"weathermap": weathermap},
} }
return {
"dashboard": {
"uid": dashboard_uid, # --------------------------------------------------------------------------
"title": "EVPN/VXLAN Fabric Weathermap", # Manual base dashboard + merge (see #52)
"tags": ["evpn", "vxlan", "weathermap", "auto-generated"], # --------------------------------------------------------------------------
"timezone": "browser",
"schemaVersion": 39, def load_base_dashboard(path):
"version": 0, with open(path) as f:
"templating": build_templating(datasource_uid), return json.load(f)
"panels": [
weathermap_panel,
build_bgp_table_panel(2, datasource_uid, 24), def substitute_placeholders(obj, replacements):
build_ports_table_panel(3, datasource_uid, 24), """Recursively replace exact-match string placeholders (e.g. the
build_throughput_panel(4, datasource_uid, 34), 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()}
"overwrite": True, 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): 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(): def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) 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", 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", 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() args = parser.parse_args()
ipfabric_url = env("IPFABRIC_URL", required=True) ipfabric_url = env("IPFABRIC_URL", required=True)
@@ -674,13 +529,20 @@ def main():
dashboard_uid = env("GRAFANA_DASHBOARD_UID", "evpn-vxlan-fabric-weathermap") dashboard_uid = env("GRAFANA_DASHBOARD_UID", "evpn-vxlan-fabric-weathermap")
datasource_uid = env("GRAFANA_DATASOURCE_UID", "PROMETHEUS_DATASOURCE_UID_PLACEHOLDER") datasource_uid = env("GRAFANA_DATASOURCE_UID", "PROMETHEUS_DATASOURCE_UID_PLACEHOLDER")
plugin_id = env("GRAFANA_WEATHERMAP_PLUGIN_ID", "tamirsuliman-weathermap-panel") 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) os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True)
with open(args.output, "w") as f: with open(args.output, "w") as f:
json.dump(dashboard_payload, f, indent=2) json.dump(dashboard_payload, f, indent=2)
f.write("\n") 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: if args.provision:
grafana_url = env("GRAFANA_URL", required=True) grafana_url = env("GRAFANA_URL", required=True)