Skip to content

Commit 919901c

Browse files
authored
Merge pull request #70 from srl-labs/annotations
support annotation json
2 parents c696d06 + 0eda7bd commit 919901c

File tree

5 files changed

+76
-25
lines changed

5 files changed

+76
-25
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,4 +182,5 @@ poetry.toml
182182
# LSP config files
183183
pyrightconfig.json
184184

185-
# End of https://www.toptal.com/developers/gitignore/api/python
185+
CLAUDE.md
186+
AGENTS.md

src/clab_io_draw/clab2drawio.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,9 @@ def main(
279279
os.makedirs(output_folder, exist_ok=True)
280280

281281
grafana_dashboard = GrafanaDashboard(
282-
diagram, grafana_config_path=grafana_config_path, grafana_interface_format=grafana_interface_format
282+
diagram,
283+
grafana_config_path=grafana_config_path,
284+
grafana_interface_format=grafana_interface_format,
283285
)
284286
panel_config = grafana_dashboard.create_panel_yaml()
285287

@@ -334,7 +336,9 @@ def cli( # noqa: B008
334336
None, "--grafana-config", help="Path to Grafana YAML config"
335337
), # noqa: B008
336338
grafana_interface_format: str | None = typer.Option(
337-
None, "--grafana-interface-format", help="Regex pattern for mapping interface names (e.g., 'e1-{x}:ethernet1/{x}')"
339+
None,
340+
"--grafana-interface-format",
341+
help="Regex pattern for mapping interface names (e.g., 'e1-{x}:ethernet1/{x}')",
338342
), # noqa: B008
339343
include_unlinked_nodes: bool = typer.Option(
340344
False, "--include-unlinked-nodes", help="Include nodes without links"

src/clab_io_draw/core/data/node_link_builder.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ def __init__(
2424
self.styles = styles
2525
self.prefix = prefix
2626
self.lab_name = lab_name
27+
self.annotations = containerlab_data.get("annotations")
2728

2829
def format_node_name(self, base_name: str) -> str:
2930
"""
@@ -62,28 +63,50 @@ def _build_nodes(self):
6263
node_height = self.styles.get("node_height", 75)
6364
base_style = self.styles.get("base_style", "")
6465

66+
# Create mapping of node annotations by id if annotations exist
67+
node_annotations_map = {}
68+
if self.annotations and "nodeAnnotations" in self.annotations:
69+
for annotation in self.annotations["nodeAnnotations"]:
70+
node_annotations_map[annotation["id"]] = annotation
71+
6572
nodes = {}
6673
for node_name, node_data in nodes_from_clab.items():
6774
formatted_node_name = self.format_node_name(node_name)
6875

69-
# Extract position from graph-posX and graph-posY labels if available
76+
# Initialize default values
7077
pos_x = node_data.get("pos_x", "")
7178
pos_y = node_data.get("pos_y", "")
79+
graph_icon = None
80+
graph_level = None
7281

73-
# Check for graph-posX and graph-posY in labels
82+
# First check labels (lower priority)
7483
labels = node_data.get("labels", {})
7584
if "graph-posX" in labels:
7685
pos_x = labels["graph-posX"]
7786
if "graph-posY" in labels:
7887
pos_y = labels["graph-posY"]
88+
if "graph-icon" in labels:
89+
graph_icon = labels["graph-icon"]
90+
if "graph-level" in labels:
91+
graph_level = labels["graph-level"]
92+
93+
# Then check annotations (higher priority - overrides labels)
94+
if node_name in node_annotations_map:
95+
annotation = node_annotations_map[node_name]
96+
if "position" in annotation:
97+
pos_x = str(annotation["position"]["x"])
98+
pos_y = str(annotation["position"]["y"])
99+
if "icon" in annotation:
100+
graph_icon = annotation["icon"]
101+
# Note: graph-level could be added to annotations if needed
79102

80103
node = Node(
81104
name=formatted_node_name,
82105
label=node_name,
83106
kind=node_data.get("kind", ""),
84107
mgmt_ipv4=node_data.get("mgmt_ipv4", ""),
85-
graph_level=node_data.get("labels", {}).get("graph-level", None),
86-
graph_icon=node_data.get("labels", {}).get("graph-icon", None),
108+
graph_level=graph_level,
109+
graph_icon=graph_icon,
87110
base_style=base_style,
88111
custom_style=self.styles.get(node_data.get("kind", ""), ""),
89112
pos_x=pos_x,

src/clab_io_draw/core/data/topology_loader.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import json
12
import logging
3+
import os
24

35
import yaml
46

@@ -21,9 +23,10 @@ class TopologyLoader:
2123
def load(self, input_file: str) -> dict:
2224
"""
2325
Load the containerlab YAML topology file, including environment-variable expansion.
26+
Also loads annotations from .annotations.json file if it exists.
2427
2528
:param input_file: Path to the containerlab YAML file.
26-
:return: Parsed containerlab topology data with env variables expanded.
29+
:return: Parsed containerlab topology data with env variables expanded and annotations.
2730
:raises TopologyLoaderError: If file not found or parse error occurs.
2831
"""
2932
logger.debug(f"Loading topology from file: {input_file}")
@@ -35,6 +38,19 @@ def load(self, input_file: str) -> dict:
3538
expanded_content = expand_env_vars(raw_content)
3639

3740
containerlab_data = yaml.safe_load(expanded_content)
41+
42+
# Load annotations if .annotations.json file exists
43+
annotations_file = f"{input_file}.annotations.json"
44+
if os.path.exists(annotations_file):
45+
logger.debug(f"Loading annotations from: {annotations_file}")
46+
with open(annotations_file) as f:
47+
annotations = json.load(f)
48+
containerlab_data["annotations"] = annotations
49+
logger.debug("Annotations successfully loaded.")
50+
else:
51+
logger.debug(f"No annotations file found at: {annotations_file}")
52+
containerlab_data["annotations"] = None
53+
3854
logger.debug("Topology successfully loaded.")
3955
return containerlab_data
4056

src/clab_io_draw/core/grafana/grafana_manager.py

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@ class GrafanaDashboard:
1212
Manages the creation of a Grafana dashboard and associated panel config from the diagram data.
1313
"""
1414

15-
def __init__(self, diagram=None, grafana_config_path: str | None = None, grafana_interface_format: str | None = None):
15+
def __init__(
16+
self,
17+
diagram=None,
18+
grafana_config_path: str | None = None,
19+
grafana_interface_format: str | None = None,
20+
):
1621
"""
1722
:param diagram: Diagram object that includes node and link data.
1823
:param grafana_config_path: Path to the YAML file containing grafana panel config (targets, thresholds, etc.).
@@ -79,42 +84,44 @@ def _map_interface_name(self, interface_name: str) -> str:
7984
"""
8085
Map interface names for Grafana export using user-provided regex pattern.
8186
Supports patterns like 'e1-{x}:ethernet1/{x}' to convert 'e1-1' to 'ethernet1/1'.
82-
87+
8388
:param interface_name: Original interface name
8489
:return: Mapped interface name
8590
"""
8691
if not self.grafana_interface_format:
8792
return interface_name
88-
93+
8994
import re
90-
95+
9196
# Parse the pattern format: "before_pattern:after_pattern"
92-
if ':' not in self.grafana_interface_format:
93-
logger.warning(f"Invalid grafana_interface_format pattern: {self.grafana_interface_format}. Expected format: 'pattern:replacement'")
97+
if ":" not in self.grafana_interface_format:
98+
logger.warning(
99+
f"Invalid grafana_interface_format pattern: {self.grafana_interface_format}. Expected format: 'pattern:replacement'"
100+
)
94101
return interface_name
95-
96-
pattern_part, replacement_part = self.grafana_interface_format.split(':', 1)
97-
102+
103+
pattern_part, replacement_part = self.grafana_interface_format.split(":", 1)
104+
98105
# Convert {x} placeholders to regex capture groups
99106
# Replace {x} with (\d+) for numeric captures
100-
regex_pattern = pattern_part.replace('{x}', r'(\d+)')
101-
107+
regex_pattern = pattern_part.replace("{x}", r"(\d+)")
108+
102109
# Convert replacement pattern from {x} to \1, \2, etc.
103110
replacement = replacement_part
104111
capture_count = 1
105-
while '{x}' in replacement:
106-
replacement = replacement.replace('{x}', f'\\{capture_count}', 1)
112+
while "{x}" in replacement:
113+
replacement = replacement.replace("{x}", f"\\{capture_count}", 1)
107114
capture_count += 1
108-
115+
109116
try:
110117
# Try to match and replace
111-
match = re.match(f'^{regex_pattern}$', interface_name)
118+
match = re.match(f"^{regex_pattern}$", interface_name)
112119
if match:
113-
return re.sub(f'^{regex_pattern}$', replacement, interface_name)
120+
return re.sub(f"^{regex_pattern}$", replacement, interface_name)
114121
except re.error as e:
115122
logger.warning(f"Invalid regex pattern in grafana_interface_format: {e}")
116123
return interface_name
117-
124+
118125
# If no pattern matches, return original name
119126
return interface_name
120127

0 commit comments

Comments
 (0)