unifi-network-maps 1.2.1__py3-none-any.whl → 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. unifi_network_maps/__init__.py +1 -0
  2. unifi_network_maps/adapters/__init__.py +1 -0
  3. {unifi_mermaid → unifi_network_maps/adapters}/config.py +7 -1
  4. {unifi_mermaid → unifi_network_maps/adapters}/unifi.py +1 -1
  5. unifi_network_maps/assets/themes/dark.yaml +47 -0
  6. unifi_network_maps/assets/themes/default.yaml +47 -0
  7. unifi_network_maps/cli/__init__.py +41 -0
  8. unifi_network_maps/cli/__main__.py +8 -0
  9. unifi_network_maps/cli/main.py +281 -0
  10. unifi_network_maps/io/__init__.py +1 -0
  11. {unifi_mermaid → unifi_network_maps/io}/debug.py +1 -1
  12. unifi_network_maps/model/__init__.py +1 -0
  13. unifi_network_maps/model/labels.py +35 -0
  14. {unifi_mermaid → unifi_network_maps/model}/lldp.py +19 -33
  15. unifi_network_maps/model/ports.py +23 -0
  16. {unifi_mermaid → unifi_network_maps/model}/topology.py +216 -89
  17. unifi_network_maps/render/__init__.py +1 -0
  18. {unifi_mermaid → unifi_network_maps/render}/mermaid.py +21 -16
  19. unifi_network_maps/render/mermaid_theme.py +46 -0
  20. {unifi_mermaid → unifi_network_maps/render}/svg.py +208 -175
  21. unifi_network_maps/render/svg_theme.py +64 -0
  22. unifi_network_maps/render/theme.py +90 -0
  23. {unifi_network_maps-1.2.1.dist-info → unifi_network_maps-1.3.0.dist-info}/METADATA +63 -8
  24. unifi_network_maps-1.3.0.dist-info/RECORD +75 -0
  25. unifi_network_maps-1.3.0.dist-info/entry_points.txt +2 -0
  26. unifi_network_maps-1.3.0.dist-info/licenses/LICENSES.md +10 -0
  27. unifi_network_maps-1.3.0.dist-info/top_level.txt +1 -0
  28. unifi_mermaid/__init__.py +0 -1
  29. unifi_mermaid/cli.py +0 -197
  30. unifi_mermaid/labels.py +0 -15
  31. unifi_network_maps-1.2.1.dist-info/RECORD +0 -63
  32. unifi_network_maps-1.2.1.dist-info/entry_points.txt +0 -2
  33. unifi_network_maps-1.2.1.dist-info/licenses/LICENSES.md +0 -10
  34. unifi_network_maps-1.2.1.dist-info/top_level.txt +0 -1
  35. {unifi_mermaid → unifi_network_maps}/assets/__init__.py +0 -0
  36. {unifi_mermaid → unifi_network_maps}/assets/icons/__init__.py +0 -0
  37. {unifi_mermaid → unifi_network_maps}/assets/icons/access-point.svg +0 -0
  38. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/ISOPACKS_LICENSE +0 -0
  39. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/block.svg +0 -0
  40. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/cache.svg +0 -0
  41. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/cardterminal.svg +0 -0
  42. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/cloud.svg +0 -0
  43. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/cronjob.svg +0 -0
  44. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/cube.svg +0 -0
  45. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/desktop.svg +0 -0
  46. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/diamond.svg +0 -0
  47. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/dns.svg +0 -0
  48. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/document.svg +0 -0
  49. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/firewall.svg +0 -0
  50. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/function-module.svg +0 -0
  51. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/image.svg +0 -0
  52. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/laptop.svg +0 -0
  53. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/loadbalancer.svg +0 -0
  54. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/lock.svg +0 -0
  55. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/mail.svg +0 -0
  56. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/mailmultiple.svg +0 -0
  57. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/mobiledevice.svg +0 -0
  58. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/office.svg +0 -0
  59. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/package-module.svg +0 -0
  60. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/paymentcard.svg +0 -0
  61. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/plane.svg +0 -0
  62. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/printer.svg +0 -0
  63. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/pyramid.svg +0 -0
  64. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/queue.svg +0 -0
  65. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/router.svg +0 -0
  66. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/server.svg +0 -0
  67. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/speech.svg +0 -0
  68. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/sphere.svg +0 -0
  69. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/storage.svg +0 -0
  70. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/switch-module.svg +0 -0
  71. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/tower.svg +0 -0
  72. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/truck-2.svg +0 -0
  73. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/truck.svg +0 -0
  74. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/user.svg +0 -0
  75. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/vm.svg +0 -0
  76. {unifi_mermaid → unifi_network_maps}/assets/icons/laptop.svg +0 -0
  77. {unifi_mermaid → unifi_network_maps}/assets/icons/router-network.svg +0 -0
  78. {unifi_mermaid → unifi_network_maps}/assets/icons/server-network.svg +0 -0
  79. {unifi_mermaid → unifi_network_maps}/assets/icons/server.svg +0 -0
  80. {unifi_mermaid → unifi_network_maps/io}/export.py +0 -0
  81. {unifi_network_maps-1.2.1.dist-info → unifi_network_maps-1.3.0.dist-info}/WHEEL +0 -0
  82. {unifi_network_maps-1.2.1.dist-info → unifi_network_maps-1.3.0.dist-info}/licenses/LICENSE +0 -0
@@ -6,10 +6,11 @@ import logging
6
6
  from collections import deque
7
7
  from collections.abc import Iterable
8
8
  from dataclasses import dataclass, field
9
- from typing import Protocol
9
+ from typing import Protocol, TypeAlias
10
10
 
11
- from .labels import compose_port_label
11
+ from .labels import compose_port_label, order_edge_names
12
12
  from .lldp import LLDPEntry, coerce_lldp, local_port_label
13
+ from .ports import extract_port_number
13
14
 
14
15
  logger = logging.getLogger(__name__)
15
16
 
@@ -75,6 +76,10 @@ class PortInfo:
75
76
  poe_power: float | None
76
77
 
77
78
 
79
+ PortMap: TypeAlias = dict[tuple[str, str], str]
80
+ PoeMap: TypeAlias = dict[tuple[str, str], bool]
81
+
82
+
78
83
  def _get_attr(obj: object, name: str) -> object | None:
79
84
  return getattr(obj, name, None)
80
85
 
@@ -114,50 +119,77 @@ def _as_int(value: object | None) -> int | None:
114
119
  return None
115
120
 
116
121
 
122
+ def _resolve_port_idx_from_lldp(lldp_entry: LLDPEntry, port_table: list[PortInfo]) -> int | None:
123
+ if lldp_entry.local_port_idx is not None:
124
+ return lldp_entry.local_port_idx
125
+ candidates = []
126
+ if lldp_entry.local_port_name:
127
+ candidates.append(lldp_entry.local_port_name)
128
+ if lldp_entry.port_id:
129
+ candidates.append(lldp_entry.port_id)
130
+ for candidate in candidates:
131
+ normalized = candidate.strip().lower()
132
+ for port in port_table:
133
+ if port.ifname and port.ifname.strip().lower() == normalized:
134
+ return port.port_idx
135
+ if port.name and port.name.strip().lower() == normalized:
136
+ return port.port_idx
137
+ for candidate in candidates:
138
+ number = extract_port_number(candidate)
139
+ if number is None:
140
+ continue
141
+ for port in port_table:
142
+ if port.port_idx == number:
143
+ return port.port_idx
144
+ return None
145
+
146
+
147
+ def _port_info_from_entry(port_entry: object) -> PortInfo:
148
+ if isinstance(port_entry, dict):
149
+ port_idx = port_entry.get("port_idx") or port_entry.get("portIdx")
150
+ name = port_entry.get("name")
151
+ ifname = port_entry.get("ifname")
152
+ port_poe = _as_bool(port_entry.get("port_poe"))
153
+ poe_enable = _as_bool(port_entry.get("poe_enable"))
154
+ poe_good = _as_bool(port_entry.get("poe_good"))
155
+ poe_power = _as_float(port_entry.get("poe_power"))
156
+ else:
157
+ port_idx = _get_attr(port_entry, "port_idx") or _get_attr(port_entry, "portIdx")
158
+ name = _get_attr(port_entry, "name")
159
+ ifname = _get_attr(port_entry, "ifname")
160
+ port_poe = _as_bool(_get_attr(port_entry, "port_poe"))
161
+ poe_enable = _as_bool(_get_attr(port_entry, "poe_enable"))
162
+ poe_good = _as_bool(_get_attr(port_entry, "poe_good"))
163
+ poe_power = _as_float(_get_attr(port_entry, "poe_power"))
164
+ return PortInfo(
165
+ port_idx=_as_int(port_idx),
166
+ name=str(name) if isinstance(name, str) and name.strip() else None,
167
+ ifname=str(ifname) if isinstance(ifname, str) and ifname.strip() else None,
168
+ port_poe=port_poe,
169
+ poe_enable=poe_enable,
170
+ poe_good=poe_good,
171
+ poe_power=poe_power,
172
+ )
173
+
174
+
117
175
  def _coerce_port_table(device: DeviceLike) -> list[PortInfo]:
118
176
  port_table = _get_attr(device, "port_table") or []
119
- result: list[PortInfo] = []
120
- for entry in port_table:
121
- if isinstance(entry, dict):
122
- port_idx = entry.get("port_idx") or entry.get("portIdx")
123
- name = entry.get("name")
124
- ifname = entry.get("ifname")
125
- port_poe = _as_bool(entry.get("port_poe"))
126
- poe_enable = _as_bool(entry.get("poe_enable"))
127
- poe_good = _as_bool(entry.get("poe_good"))
128
- poe_power = _as_float(entry.get("poe_power"))
129
- else:
130
- port_idx = _get_attr(entry, "port_idx") or _get_attr(entry, "portIdx")
131
- name = _get_attr(entry, "name")
132
- ifname = _get_attr(entry, "ifname")
133
- port_poe = _as_bool(_get_attr(entry, "port_poe"))
134
- poe_enable = _as_bool(_get_attr(entry, "poe_enable"))
135
- poe_good = _as_bool(_get_attr(entry, "poe_good"))
136
- poe_power = _as_float(_get_attr(entry, "poe_power"))
137
- result.append(
138
- PortInfo(
139
- port_idx=_as_int(port_idx),
140
- name=str(name) if isinstance(name, str) and name.strip() else None,
141
- ifname=str(ifname) if isinstance(ifname, str) and ifname.strip() else None,
142
- port_poe=port_poe,
143
- poe_enable=poe_enable,
144
- poe_good=poe_good,
145
- poe_power=poe_power,
146
- )
147
- )
148
- return result
177
+ return [_port_info_from_entry(port_entry) for port_entry in port_table]
149
178
 
150
179
 
151
180
  def _poe_ports_from_device(device: DeviceLike) -> dict[int, bool]:
152
181
  port_table = _coerce_port_table(device)
153
182
  poe_ports: dict[int, bool] = {}
154
- for entry in port_table:
155
- if entry.port_idx is None:
183
+ for port_entry in port_table:
184
+ if port_entry.port_idx is None:
156
185
  continue
157
186
  active = (
158
- entry.poe_enable or entry.port_poe or entry.poe_good or _as_float(entry.poe_power) > 0.0
187
+ port_entry.poe_enable
188
+ or port_entry.port_poe
189
+ or port_entry.poe_good
190
+ or _as_float(port_entry.poe_power) > 0.0
159
191
  )
160
- poe_ports[int(entry.port_idx)] = active
192
+ poe_ports[int(port_entry.port_idx)] = active
161
193
  return poe_ports
162
194
 
163
195
 
@@ -224,7 +256,7 @@ def coerce_device(device: DeviceLike) -> Device:
224
256
  else:
225
257
  raise ValueError(f"Device {name} missing LLDP info")
226
258
 
227
- coerced_lldp = [coerce_lldp(entry) for entry in lldp_info]
259
+ coerced_lldp = [coerce_lldp(lldp_entry) for lldp_entry in lldp_info]
228
260
  port_table = _coerce_port_table(device)
229
261
  poe_ports = _poe_ports_from_device(device)
230
262
 
@@ -431,15 +463,59 @@ def build_edges(
431
463
  ordered_devices = sorted(devices, key=lambda item: (item.name.lower(), item.mac.lower()))
432
464
  index = build_device_index(ordered_devices)
433
465
  device_by_name = {device.name: device for device in ordered_devices}
434
- pairs: list[tuple[str, str]] = []
466
+ raw_links: list[tuple[str, str]] = []
435
467
  seen: set[frozenset[str]] = set()
436
- port_map: dict[tuple[str, str], str] = {}
437
- poe_map: dict[tuple[str, str], bool] = {}
438
- devices_with_lldp_edges: set[str] = set()
468
+ port_map: PortMap = {}
469
+ poe_map: PoeMap = {}
470
+
471
+ devices_with_lldp_edges = _collect_lldp_links(
472
+ ordered_devices,
473
+ index,
474
+ port_map,
475
+ poe_map,
476
+ raw_links,
477
+ seen,
478
+ only_unifi=only_unifi,
479
+ )
480
+ _collect_uplink_links(
481
+ ordered_devices,
482
+ devices_with_lldp_edges,
483
+ index,
484
+ device_by_name,
485
+ port_map,
486
+ poe_map,
487
+ raw_links,
488
+ seen,
489
+ include_ports=include_ports,
490
+ only_unifi=only_unifi,
491
+ )
492
+ edges = _build_ordered_edges(
493
+ raw_links,
494
+ port_map,
495
+ poe_map,
496
+ device_by_name,
497
+ include_ports=include_ports,
498
+ )
499
+
500
+ poe_edges = sum(1 for edge in edges if edge.poe)
501
+ logger.info("Built %d unique edges (%d PoE)", len(edges), poe_edges)
502
+ return edges
439
503
 
440
- for device in ordered_devices:
504
+
505
+ def _collect_lldp_links(
506
+ devices: list[Device],
507
+ index: dict[str, str],
508
+ port_map: PortMap,
509
+ poe_map: PoeMap,
510
+ raw_links: list[tuple[str, str]],
511
+ seen: set[frozenset[str]],
512
+ *,
513
+ only_unifi: bool,
514
+ ) -> set[str]:
515
+ devices_with_lldp_edges: set[str] = set()
516
+ for device in devices:
441
517
  poe_ports = device.poe_ports
442
- for entry in sorted(
518
+ for lldp_entry in sorted(
443
519
  device.lldp_info,
444
520
  key=lambda item: (
445
521
  _normalize_mac(item.chassis_id),
@@ -447,78 +523,117 @@ def build_edges(
447
523
  str(item.port_desc or ""),
448
524
  ),
449
525
  ):
450
- neighbor_mac = _normalize_mac(entry.chassis_id)
451
- neighbor_name = index.get(neighbor_mac)
452
- if neighbor_name is None:
526
+ peer_mac = _normalize_mac(lldp_entry.chassis_id)
527
+ peer_name = index.get(peer_mac)
528
+ if peer_name is None:
453
529
  if only_unifi:
454
530
  continue
455
- neighbor_name = entry.chassis_id
456
-
457
- label = local_port_label(entry)
531
+ peer_name = lldp_entry.chassis_id
532
+
533
+ resolved_port_idx = _resolve_port_idx_from_lldp(lldp_entry, device.port_table)
534
+ entry_for_label = (
535
+ LLDPEntry(
536
+ chassis_id=lldp_entry.chassis_id,
537
+ port_id=lldp_entry.port_id,
538
+ port_desc=lldp_entry.port_desc,
539
+ local_port_name=lldp_entry.local_port_name,
540
+ local_port_idx=resolved_port_idx,
541
+ )
542
+ if resolved_port_idx is not None
543
+ else lldp_entry
544
+ )
545
+ label = local_port_label(entry_for_label)
458
546
  if label:
459
- port_map[(device.name, neighbor_name)] = label
460
- if entry.local_port_idx is not None and entry.local_port_idx in poe_ports:
461
- poe_map[(device.name, neighbor_name)] = poe_ports[entry.local_port_idx]
547
+ port_map[(device.name, peer_name)] = label
548
+ if resolved_port_idx is not None and resolved_port_idx in poe_ports:
549
+ poe_map[(device.name, peer_name)] = poe_ports[resolved_port_idx]
462
550
 
463
- key = frozenset({device.name, neighbor_name})
551
+ key = frozenset({device.name, peer_name})
464
552
  if key in seen:
465
553
  continue
466
554
 
467
- pairs.append((device.name, neighbor_name))
555
+ raw_links.append((device.name, peer_name))
468
556
  seen.add(key)
469
557
  devices_with_lldp_edges.add(device.name)
470
-
471
- for device in ordered_devices:
558
+ return devices_with_lldp_edges
559
+
560
+
561
+ def _collect_uplink_links(
562
+ devices: list[Device],
563
+ devices_with_lldp_edges: set[str],
564
+ index: dict[str, str],
565
+ device_by_name: dict[str, Device],
566
+ port_map: PortMap,
567
+ poe_map: PoeMap,
568
+ raw_links: list[tuple[str, str]],
569
+ seen: set[frozenset[str]],
570
+ *,
571
+ include_ports: bool,
572
+ only_unifi: bool,
573
+ ) -> None:
574
+ for device in devices:
472
575
  if device.name in devices_with_lldp_edges:
473
576
  continue
474
577
  uplink = device.uplink or device.last_uplink
475
- uplink_name = None
578
+ upstream_name = None
476
579
  if uplink and uplink.mac:
477
- uplink_name = index.get(_normalize_mac(uplink.mac))
478
- if not uplink_name and uplink and uplink.name:
479
- uplink_name = uplink.name
480
- if not uplink_name and not only_unifi and uplink and uplink.mac:
481
- uplink_name = uplink.mac
482
- if not uplink_name:
580
+ upstream_name = index.get(_normalize_mac(uplink.mac))
581
+ if not upstream_name and uplink and uplink.name:
582
+ upstream_name = uplink.name
583
+ if not upstream_name and not only_unifi and uplink and uplink.mac:
584
+ upstream_name = uplink.mac
585
+ if not upstream_name:
483
586
  continue
484
- if only_unifi and uplink_name not in device_by_name:
587
+ if only_unifi and upstream_name not in device_by_name:
485
588
  continue
486
- key = frozenset({device.name, uplink_name})
589
+ key = frozenset({device.name, upstream_name})
487
590
  if key in seen:
488
591
  continue
489
592
  poe = False
490
593
  if uplink and uplink.port is not None:
491
594
  if include_ports:
492
- port_map[(uplink_name, device.name)] = f"Port {uplink.port}"
493
- uplink_device = device_by_name.get(uplink_name)
595
+ port_map[(upstream_name, device.name)] = f"Port {uplink.port}"
596
+ uplink_device = device_by_name.get(upstream_name)
494
597
  if uplink_device and uplink.port in uplink_device.poe_ports:
495
598
  poe = uplink_device.poe_ports[uplink.port]
496
- pairs.append((uplink_name, device.name))
599
+ raw_links.append((upstream_name, device.name))
497
600
  seen.add(key)
498
601
  if poe:
499
- poe_map[(uplink_name, device.name)] = poe
602
+ poe_map[(upstream_name, device.name)] = poe
603
+
604
+
605
+ def _build_ordered_edges(
606
+ raw_links: list[tuple[str, str]],
607
+ port_map: PortMap,
608
+ poe_map: PoeMap,
609
+ device_by_name: dict[str, Device],
610
+ *,
611
+ include_ports: bool,
612
+ ) -> list[Edge]:
613
+ type_rank = {"gateway": 0, "switch": 1, "ap": 2, "other": 3}
614
+
615
+ def _rank_for_name(name: str) -> int:
616
+ device = device_by_name.get(name)
617
+ if not device:
618
+ return 3
619
+ return type_rank.get(classify_device_type(device), 3)
500
620
 
501
621
  edges: list[Edge] = []
502
- for left, right in pairs:
622
+ for source_name, target_name in raw_links:
623
+ left_name = source_name
624
+ right_name = target_name
503
625
  if include_ports:
504
- left_label = port_map.get((left, right))
505
- right_label = port_map.get((right, left))
506
- if left_label is None and right_label is not None:
507
- left, right = right, left
508
- elif left_label and right_label:
509
- left_device = device_by_name.get(left)
510
- right_device = device_by_name.get(right)
511
- if left_device and right_device:
512
- type_rank = {"gateway": 0, "switch": 1, "ap": 2, "other": 3}
513
- left_rank = type_rank.get(classify_device_type(left_device), 3)
514
- right_rank = type_rank.get(classify_device_type(right_device), 3)
515
- if (left_rank, left.lower()) > (right_rank, right.lower()):
516
- left, right = right, left
517
- poe = poe_map.get((left, right), False) or poe_map.get((right, left), False)
518
- label = compose_port_label(left, right, port_map) if include_ports else None
519
- edges.append(Edge(left=left, right=right, label=label, poe=poe))
520
-
521
- logger.info("Built %d unique edges", len(edges))
626
+ left_name, right_name = order_edge_names(
627
+ left_name,
628
+ right_name,
629
+ port_map,
630
+ _rank_for_name,
631
+ )
632
+ poe = poe_map.get((left_name, right_name), False) or poe_map.get(
633
+ (right_name, left_name), False
634
+ )
635
+ label = compose_port_label(left_name, right_name, port_map) if include_ports else None
636
+ edges.append(Edge(left=left_name, right=right_name, label=label, poe=poe))
522
637
  return edges
523
638
 
524
639
 
@@ -535,6 +650,18 @@ def build_topology(
535
650
  only_unifi: bool,
536
651
  gateways: list[str],
537
652
  ) -> TopologyResult:
538
- raw_edges = build_edges(devices, include_ports=include_ports, only_unifi=only_unifi)
653
+ normalized_devices = list(devices)
654
+ lldp_entries = sum(len(device.lldp_info) for device in normalized_devices)
655
+ logger.info(
656
+ "Normalized %d devices (%d LLDP entries)",
657
+ len(normalized_devices),
658
+ lldp_entries,
659
+ )
660
+ raw_edges = build_edges(normalized_devices, include_ports=include_ports, only_unifi=only_unifi)
539
661
  tree_edges = build_tree_edges_by_topology(raw_edges, gateways)
662
+ logger.info(
663
+ "Built %d hierarchy edges (gateways=%d)",
664
+ len(tree_edges),
665
+ len(gateways),
666
+ )
540
667
  return TopologyResult(raw_edges=raw_edges, tree_edges=tree_edges)
@@ -0,0 +1 @@
1
+ """Package module."""
@@ -4,7 +4,8 @@ from __future__ import annotations
4
4
 
5
5
  from collections.abc import Iterable
6
6
 
7
- from .topology import Edge
7
+ from ..model.topology import Edge
8
+ from .mermaid_theme import DEFAULT_THEME, MermaidTheme, class_defs
8
9
 
9
10
 
10
11
  def _escape(label: str) -> str:
@@ -60,6 +61,7 @@ def render_mermaid(
60
61
  groups: dict[str, list[str]] | None = None,
61
62
  group_order: list[str] | None = None,
62
63
  node_types: dict[str, str] | None = None,
64
+ theme: MermaidTheme = DEFAULT_THEME,
63
65
  ) -> str:
64
66
  edge_list = list(edges)
65
67
  group_nodes: list[str] = []
@@ -112,17 +114,17 @@ def render_mermaid(
112
114
  node_id = id_map.get(name)
113
115
  if node_id:
114
116
  lines.append(f" class {node_id} {class_name};")
115
- lines.append(" classDef node_gateway fill:#ffe3b3,stroke:#d98300,stroke-width:1px;")
116
- lines.append(" classDef node_switch fill:#d6ecff,stroke:#3a7bd5,stroke-width:1px;")
117
- lines.append(" classDef node_ap fill:#d7f5e7,stroke:#27ae60,stroke-width:1px;")
118
- lines.append(" classDef node_client fill:#f2e5ff,stroke:#7f3fbf,stroke-width:1px;")
119
- lines.append(" classDef node_other fill:#eeeeee,stroke:#8f8f8f,stroke-width:1px;")
117
+ lines.extend(class_defs(theme))
120
118
  for index in poe_links:
121
- lines.append(f" linkStyle {index} stroke:#1e88e5,stroke-width:2px;")
119
+ lines.append(
120
+ " linkStyle "
121
+ f"{index} stroke:{theme.poe_link},stroke-width:{theme.poe_link_width}px,"
122
+ f"arrowhead:{theme.poe_link_arrow};"
123
+ )
122
124
  return "\n".join(lines) + "\n"
123
125
 
124
126
 
125
- def render_legend() -> str:
127
+ def render_legend(theme: MermaidTheme = DEFAULT_THEME) -> str:
126
128
  lines = [
127
129
  "graph TB",
128
130
  ' subgraph legend["Legend"];',
@@ -149,13 +151,16 @@ def render_legend() -> str:
149
151
  " class legend_poe_b node_legend;",
150
152
  " class legend_no_poe_a node_legend;",
151
153
  " class legend_no_poe_b node_legend;",
152
- " classDef node_gateway fill:#ffe3b3,stroke:#d98300,stroke-width:1px;",
153
- " classDef node_switch fill:#d6ecff,stroke:#3a7bd5,stroke-width:1px;",
154
- " classDef node_ap fill:#d7f5e7,stroke:#27ae60,stroke-width:1px;",
155
- " classDef node_client fill:#f2e5ff,stroke:#7f3fbf,stroke-width:1px;",
156
- " classDef node_other fill:#eeeeee,stroke:#8f8f8f,stroke-width:1px;",
157
- " classDef node_legend font-size:10px;",
158
154
  ]
159
- lines.append(" linkStyle 0 stroke:#1e88e5,stroke-width:2px,arrowhead:none;")
160
- lines.append(" linkStyle 1 stroke:#2ecc71,stroke-width:2px,arrowhead:none;")
155
+ lines.extend(class_defs(theme))
156
+ lines.append(
157
+ " linkStyle 0 "
158
+ f"stroke:{theme.poe_link},stroke-width:{theme.poe_link_width}px,"
159
+ f"arrowhead:{theme.poe_link_arrow};"
160
+ )
161
+ lines.append(
162
+ " linkStyle 1 "
163
+ f"stroke:{theme.standard_link},stroke-width:{theme.standard_link_width}px,"
164
+ f"arrowhead:{theme.standard_link_arrow};"
165
+ )
161
166
  return "\n".join(lines) + "\n"
@@ -0,0 +1,46 @@
1
+ """Mermaid theming helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class MermaidTheme:
10
+ node_gateway: tuple[str, str]
11
+ node_switch: tuple[str, str]
12
+ node_ap: tuple[str, str]
13
+ node_client: tuple[str, str]
14
+ node_other: tuple[str, str]
15
+ poe_link: str
16
+ poe_link_width: int
17
+ poe_link_arrow: str
18
+ standard_link: str
19
+ standard_link_width: int
20
+ standard_link_arrow: str
21
+
22
+
23
+ DEFAULT_THEME = MermaidTheme(
24
+ node_gateway=("#ffe3b3", "#d98300"),
25
+ node_switch=("#d6ecff", "#3a7bd5"),
26
+ node_ap=("#d7f5e7", "#27ae60"),
27
+ node_client=("#f2e5ff", "#7f3fbf"),
28
+ node_other=("#eeeeee", "#8f8f8f"),
29
+ poe_link="#1e88e5",
30
+ poe_link_width=2,
31
+ poe_link_arrow="none",
32
+ standard_link="#2ecc71",
33
+ standard_link_width=2,
34
+ standard_link_arrow="none",
35
+ )
36
+
37
+
38
+ def class_defs(theme: MermaidTheme = DEFAULT_THEME) -> list[str]:
39
+ return [
40
+ f" classDef node_gateway fill:{theme.node_gateway[0]},stroke:{theme.node_gateway[1]},stroke-width:1px;",
41
+ f" classDef node_switch fill:{theme.node_switch[0]},stroke:{theme.node_switch[1]},stroke-width:1px;",
42
+ f" classDef node_ap fill:{theme.node_ap[0]},stroke:{theme.node_ap[1]},stroke-width:1px;",
43
+ f" classDef node_client fill:{theme.node_client[0]},stroke:{theme.node_client[1]},stroke-width:1px;",
44
+ f" classDef node_other fill:{theme.node_other[0]},stroke:{theme.node_other[1]},stroke-width:1px;",
45
+ " classDef node_legend font-size:10px;",
46
+ ]