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.
- unifi_network_maps/__init__.py +1 -0
- unifi_network_maps/adapters/__init__.py +1 -0
- {unifi_mermaid → unifi_network_maps/adapters}/config.py +7 -1
- {unifi_mermaid → unifi_network_maps/adapters}/unifi.py +1 -1
- unifi_network_maps/assets/themes/dark.yaml +47 -0
- unifi_network_maps/assets/themes/default.yaml +47 -0
- unifi_network_maps/cli/__init__.py +41 -0
- unifi_network_maps/cli/__main__.py +8 -0
- unifi_network_maps/cli/main.py +281 -0
- unifi_network_maps/io/__init__.py +1 -0
- {unifi_mermaid → unifi_network_maps/io}/debug.py +1 -1
- unifi_network_maps/model/__init__.py +1 -0
- unifi_network_maps/model/labels.py +35 -0
- {unifi_mermaid → unifi_network_maps/model}/lldp.py +19 -33
- unifi_network_maps/model/ports.py +23 -0
- {unifi_mermaid → unifi_network_maps/model}/topology.py +216 -89
- unifi_network_maps/render/__init__.py +1 -0
- {unifi_mermaid → unifi_network_maps/render}/mermaid.py +21 -16
- unifi_network_maps/render/mermaid_theme.py +46 -0
- {unifi_mermaid → unifi_network_maps/render}/svg.py +208 -175
- unifi_network_maps/render/svg_theme.py +64 -0
- unifi_network_maps/render/theme.py +90 -0
- {unifi_network_maps-1.2.1.dist-info → unifi_network_maps-1.3.0.dist-info}/METADATA +63 -8
- unifi_network_maps-1.3.0.dist-info/RECORD +75 -0
- unifi_network_maps-1.3.0.dist-info/entry_points.txt +2 -0
- unifi_network_maps-1.3.0.dist-info/licenses/LICENSES.md +10 -0
- unifi_network_maps-1.3.0.dist-info/top_level.txt +1 -0
- unifi_mermaid/__init__.py +0 -1
- unifi_mermaid/cli.py +0 -197
- unifi_mermaid/labels.py +0 -15
- unifi_network_maps-1.2.1.dist-info/RECORD +0 -63
- unifi_network_maps-1.2.1.dist-info/entry_points.txt +0 -2
- unifi_network_maps-1.2.1.dist-info/licenses/LICENSES.md +0 -10
- unifi_network_maps-1.2.1.dist-info/top_level.txt +0 -1
- {unifi_mermaid → unifi_network_maps}/assets/__init__.py +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/__init__.py +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/access-point.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/ISOPACKS_LICENSE +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/block.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/cache.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/cardterminal.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/cloud.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/cronjob.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/cube.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/desktop.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/diamond.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/dns.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/document.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/firewall.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/function-module.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/image.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/laptop.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/loadbalancer.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/lock.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/mail.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/mailmultiple.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/mobiledevice.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/office.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/package-module.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/paymentcard.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/plane.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/printer.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/pyramid.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/queue.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/router.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/server.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/speech.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/sphere.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/storage.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/switch-module.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/tower.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/truck-2.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/truck.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/user.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/vm.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/laptop.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/router-network.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/server-network.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/server.svg +0 -0
- {unifi_mermaid → unifi_network_maps/io}/export.py +0 -0
- {unifi_network_maps-1.2.1.dist-info → unifi_network_maps-1.3.0.dist-info}/WHEEL +0 -0
- {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
|
-
|
|
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
|
|
155
|
-
if
|
|
183
|
+
for port_entry in port_table:
|
|
184
|
+
if port_entry.port_idx is None:
|
|
156
185
|
continue
|
|
157
186
|
active = (
|
|
158
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
466
|
+
raw_links: list[tuple[str, str]] = []
|
|
435
467
|
seen: set[frozenset[str]] = set()
|
|
436
|
-
port_map:
|
|
437
|
-
poe_map:
|
|
438
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
if
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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,
|
|
460
|
-
if
|
|
461
|
-
poe_map[(device.name,
|
|
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,
|
|
551
|
+
key = frozenset({device.name, peer_name})
|
|
464
552
|
if key in seen:
|
|
465
553
|
continue
|
|
466
554
|
|
|
467
|
-
|
|
555
|
+
raw_links.append((device.name, peer_name))
|
|
468
556
|
seen.add(key)
|
|
469
557
|
devices_with_lldp_edges.add(device.name)
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
578
|
+
upstream_name = None
|
|
476
579
|
if uplink and uplink.mac:
|
|
477
|
-
|
|
478
|
-
if not
|
|
479
|
-
|
|
480
|
-
if not
|
|
481
|
-
|
|
482
|
-
if not
|
|
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
|
|
587
|
+
if only_unifi and upstream_name not in device_by_name:
|
|
485
588
|
continue
|
|
486
|
-
key = frozenset({device.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[(
|
|
493
|
-
uplink_device = device_by_name.get(
|
|
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
|
-
|
|
599
|
+
raw_links.append((upstream_name, device.name))
|
|
497
600
|
seen.add(key)
|
|
498
601
|
if poe:
|
|
499
|
-
poe_map[(
|
|
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
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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.
|
|
160
|
-
lines.append(
|
|
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
|
+
]
|