unifi-network-maps 1.4.11__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/__main__.py +8 -0
- unifi_network_maps/adapters/__init__.py +1 -0
- unifi_network_maps/adapters/config.py +49 -0
- unifi_network_maps/adapters/unifi.py +457 -0
- unifi_network_maps/assets/__init__.py +0 -0
- unifi_network_maps/assets/icons/__init__.py +0 -0
- unifi_network_maps/assets/icons/access-point.svg +1 -0
- unifi_network_maps/assets/icons/isometric/ISOPACKS_LICENSE +7 -0
- unifi_network_maps/assets/icons/isometric/block.svg +23 -0
- unifi_network_maps/assets/icons/isometric/cache.svg +48 -0
- unifi_network_maps/assets/icons/isometric/cardterminal.svg +316 -0
- unifi_network_maps/assets/icons/isometric/cloud.svg +89 -0
- unifi_network_maps/assets/icons/isometric/cronjob.svg +409 -0
- unifi_network_maps/assets/icons/isometric/cube.svg +24 -0
- unifi_network_maps/assets/icons/isometric/desktop.svg +107 -0
- unifi_network_maps/assets/icons/isometric/diamond.svg +23 -0
- unifi_network_maps/assets/icons/isometric/dns.svg +46 -0
- unifi_network_maps/assets/icons/isometric/document.svg +62 -0
- unifi_network_maps/assets/icons/isometric/firewall.svg +200 -0
- unifi_network_maps/assets/icons/isometric/function-module.svg +215 -0
- unifi_network_maps/assets/icons/isometric/image.svg +65 -0
- unifi_network_maps/assets/icons/isometric/laptop.svg +37 -0
- unifi_network_maps/assets/icons/isometric/loadbalancer.svg +65 -0
- unifi_network_maps/assets/icons/isometric/lock.svg +155 -0
- unifi_network_maps/assets/icons/isometric/mail.svg +35 -0
- unifi_network_maps/assets/icons/isometric/mailmultiple.svg +91 -0
- unifi_network_maps/assets/icons/isometric/mobiledevice.svg +66 -0
- unifi_network_maps/assets/icons/isometric/office.svg +136 -0
- unifi_network_maps/assets/icons/isometric/package-module.svg +39 -0
- unifi_network_maps/assets/icons/isometric/paymentcard.svg +92 -0
- unifi_network_maps/assets/icons/isometric/plane.svg +1 -0
- unifi_network_maps/assets/icons/isometric/printer.svg +122 -0
- unifi_network_maps/assets/icons/isometric/pyramid.svg +28 -0
- unifi_network_maps/assets/icons/isometric/queue.svg +38 -0
- unifi_network_maps/assets/icons/isometric/router.svg +39 -0
- unifi_network_maps/assets/icons/isometric/server.svg +112 -0
- unifi_network_maps/assets/icons/isometric/speech.svg +70 -0
- unifi_network_maps/assets/icons/isometric/sphere.svg +15 -0
- unifi_network_maps/assets/icons/isometric/storage.svg +92 -0
- unifi_network_maps/assets/icons/isometric/switch-module.svg +45 -0
- unifi_network_maps/assets/icons/isometric/tower.svg +50 -0
- unifi_network_maps/assets/icons/isometric/truck-2.svg +1 -0
- unifi_network_maps/assets/icons/isometric/truck.svg +1 -0
- unifi_network_maps/assets/icons/isometric/user.svg +231 -0
- unifi_network_maps/assets/icons/isometric/vm.svg +50 -0
- unifi_network_maps/assets/icons/laptop.svg +1 -0
- unifi_network_maps/assets/icons/router-network.svg +1 -0
- unifi_network_maps/assets/icons/server-network.svg +1 -0
- unifi_network_maps/assets/icons/server.svg +1 -0
- unifi_network_maps/assets/themes/dark.yaml +50 -0
- unifi_network_maps/assets/themes/default.yaml +47 -0
- unifi_network_maps/cli/__init__.py +5 -0
- unifi_network_maps/cli/__main__.py +8 -0
- unifi_network_maps/cli/args.py +166 -0
- unifi_network_maps/cli/main.py +134 -0
- unifi_network_maps/cli/render.py +255 -0
- unifi_network_maps/cli/runtime.py +157 -0
- unifi_network_maps/io/__init__.py +1 -0
- unifi_network_maps/io/debug.py +60 -0
- unifi_network_maps/io/export.py +32 -0
- unifi_network_maps/io/mkdocs_assets.py +21 -0
- unifi_network_maps/io/mock_data.py +23 -0
- unifi_network_maps/io/mock_generate.py +7 -0
- unifi_network_maps/model/__init__.py +1 -0
- unifi_network_maps/model/labels.py +35 -0
- unifi_network_maps/model/lldp.py +99 -0
- unifi_network_maps/model/mock.py +307 -0
- unifi_network_maps/model/ports.py +23 -0
- unifi_network_maps/model/topology.py +909 -0
- unifi_network_maps/render/__init__.py +1 -0
- unifi_network_maps/render/device_ports_md.py +492 -0
- unifi_network_maps/render/legend.py +30 -0
- unifi_network_maps/render/lldp_md.py +352 -0
- unifi_network_maps/render/markdown_tables.py +21 -0
- unifi_network_maps/render/mermaid.py +273 -0
- unifi_network_maps/render/mermaid_theme.py +56 -0
- unifi_network_maps/render/mkdocs.py +167 -0
- unifi_network_maps/render/svg.py +1235 -0
- unifi_network_maps/render/svg_theme.py +64 -0
- unifi_network_maps/render/templates/device_port_block.md.j2 +5 -0
- unifi_network_maps/render/templates/legend_compact.html.j2 +14 -0
- unifi_network_maps/render/templates/lldp_device_section.md.j2 +15 -0
- unifi_network_maps/render/templates/markdown_section.md.j2 +3 -0
- unifi_network_maps/render/templates/mermaid_legend.mmd.j2 +30 -0
- unifi_network_maps/render/templates/mkdocs_document.md.j2 +23 -0
- unifi_network_maps/render/templates/mkdocs_dual_theme_style.html.j2 +8 -0
- unifi_network_maps/render/templates/mkdocs_html_block.html.j2 +2 -0
- unifi_network_maps/render/templates/mkdocs_legend.css.j2 +29 -0
- unifi_network_maps/render/templates/mkdocs_legend.js.j2 +18 -0
- unifi_network_maps/render/templates/mkdocs_mermaid_block.md.j2 +4 -0
- unifi_network_maps/render/templating.py +19 -0
- unifi_network_maps/render/theme.py +109 -0
- unifi_network_maps-1.4.11.dist-info/METADATA +290 -0
- unifi_network_maps-1.4.11.dist-info/RECORD +99 -0
- unifi_network_maps-1.4.11.dist-info/WHEEL +5 -0
- unifi_network_maps-1.4.11.dist-info/entry_points.txt +2 -0
- unifi_network_maps-1.4.11.dist-info/licenses/LICENSE +21 -0
- unifi_network_maps-1.4.11.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""Render LLDP data as Markdown tables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
|
|
7
|
+
from ..model.lldp import LLDPEntry, local_port_label
|
|
8
|
+
from ..model.ports import extract_port_number
|
|
9
|
+
from ..model.topology import Device, build_client_port_map, build_device_index, build_port_map
|
|
10
|
+
from .device_ports_md import render_device_port_details
|
|
11
|
+
from .markdown_tables import markdown_table_lines
|
|
12
|
+
from .templating import render_template
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _normalize_mac(value: str) -> str:
|
|
16
|
+
return value.strip().lower()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _client_field(client: object, name: str) -> object | None:
|
|
20
|
+
if isinstance(client, dict):
|
|
21
|
+
return client.get(name)
|
|
22
|
+
return getattr(client, name, None)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _client_display_name(client: object) -> str | None:
|
|
26
|
+
for key in ("name", "hostname", "mac"):
|
|
27
|
+
value = _client_field(client, key)
|
|
28
|
+
if isinstance(value, str) and value.strip():
|
|
29
|
+
return value.strip()
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _client_uplink_mac(client: object) -> str | None:
|
|
34
|
+
for key in ("ap_mac", "sw_mac", "uplink_mac", "uplink_device_mac", "last_uplink_mac"):
|
|
35
|
+
value = _client_field(client, key)
|
|
36
|
+
if isinstance(value, str) and value.strip():
|
|
37
|
+
return value.strip()
|
|
38
|
+
for key in ("uplink", "last_uplink"):
|
|
39
|
+
nested = _client_field(client, key)
|
|
40
|
+
if isinstance(nested, dict):
|
|
41
|
+
value = nested.get("uplink_mac") or nested.get("uplink_device_mac")
|
|
42
|
+
if isinstance(value, str) and value.strip():
|
|
43
|
+
return value.strip()
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _client_uplink_port(client: object) -> int | None:
|
|
48
|
+
for value in _client_port_values(client):
|
|
49
|
+
parsed = _parse_port_value(value)
|
|
50
|
+
if parsed is not None:
|
|
51
|
+
return parsed
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _client_port_values(client: object) -> Iterable[object | None]:
|
|
56
|
+
for key in ("uplink_remote_port", "sw_port", "ap_port", "port_idx"):
|
|
57
|
+
yield _client_field(client, key)
|
|
58
|
+
for key in ("uplink", "last_uplink"):
|
|
59
|
+
nested = _client_field(client, key)
|
|
60
|
+
if isinstance(nested, dict):
|
|
61
|
+
for nested_key in ("uplink_remote_port", "port_idx"):
|
|
62
|
+
yield nested.get(nested_key)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _parse_port_value(value: object | None) -> int | None:
|
|
66
|
+
if isinstance(value, int):
|
|
67
|
+
return value
|
|
68
|
+
if isinstance(value, str):
|
|
69
|
+
stripped = value.strip()
|
|
70
|
+
if stripped.isdigit():
|
|
71
|
+
return int(stripped)
|
|
72
|
+
return extract_port_number(stripped)
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _client_is_wired(client: object) -> bool:
|
|
77
|
+
return bool(_client_field(client, "is_wired"))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _client_matches_mode(client: object, mode: str) -> bool:
|
|
81
|
+
wired = _client_is_wired(client)
|
|
82
|
+
if mode == "all":
|
|
83
|
+
return True
|
|
84
|
+
if mode == "wireless":
|
|
85
|
+
return not wired
|
|
86
|
+
return wired
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _lldp_sort_key(entry: LLDPEntry) -> tuple[int, str, str]:
|
|
90
|
+
port_label = local_port_label(entry) or ""
|
|
91
|
+
port_number = "".join(ch for ch in port_label if ch.isdigit())
|
|
92
|
+
return (int(port_number or 0), port_label, entry.port_id)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _port_summary(device: Device) -> str:
|
|
96
|
+
ports = [port for port in device.port_table if port.port_idx is not None]
|
|
97
|
+
if not ports:
|
|
98
|
+
return "-"
|
|
99
|
+
total_ports = len(ports)
|
|
100
|
+
poe_capable = sum(1 for port in ports if port.port_poe or port.poe_enable)
|
|
101
|
+
poe_active = sum(1 for port in ports if device.poe_ports.get(port.port_idx or -1))
|
|
102
|
+
total_power = sum(port.poe_power or 0.0 for port in ports)
|
|
103
|
+
summary = f"Total {total_ports}, PoE {poe_capable} (active {poe_active})"
|
|
104
|
+
if total_power > 0:
|
|
105
|
+
summary = f"{summary}, {total_power:.2f}W"
|
|
106
|
+
return summary
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _poe_summary(device: Device) -> str:
|
|
110
|
+
ports = [port for port in device.port_table if port.port_idx is not None]
|
|
111
|
+
if not ports:
|
|
112
|
+
return "-"
|
|
113
|
+
poe_capable = sum(1 for port in ports if port.port_poe or port.poe_enable)
|
|
114
|
+
poe_active = sum(1 for port in ports if (port.poe_power or 0.0) > 0 or port.poe_good)
|
|
115
|
+
total_power = sum(port.poe_power or 0.0 for port in ports)
|
|
116
|
+
summary = f"{poe_capable} capable, {poe_active} active"
|
|
117
|
+
if total_power > 0:
|
|
118
|
+
summary = f"{summary}, {total_power:.2f}W"
|
|
119
|
+
return summary
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _uplink_summary(device: Device) -> str:
|
|
123
|
+
uplink = device.uplink or device.last_uplink
|
|
124
|
+
if not uplink:
|
|
125
|
+
return "-"
|
|
126
|
+
name = uplink.name or uplink.mac or "Unknown"
|
|
127
|
+
if uplink.port is not None:
|
|
128
|
+
return f"{name} (Port {uplink.port})"
|
|
129
|
+
return name
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _client_summary(
|
|
133
|
+
device: Device, client_rows: dict[str, list[tuple[str, str | None]]]
|
|
134
|
+
) -> tuple[str, str]:
|
|
135
|
+
rows = client_rows.get(device.name)
|
|
136
|
+
if rows is None:
|
|
137
|
+
return "-", "-"
|
|
138
|
+
count = len(rows)
|
|
139
|
+
names = [name for name, _port in rows]
|
|
140
|
+
sample = ", ".join(names[:3])
|
|
141
|
+
if len(names) > 3:
|
|
142
|
+
sample = f"{sample}, ..."
|
|
143
|
+
return str(count), sample or "-"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _details_table_lines(
|
|
147
|
+
device: Device,
|
|
148
|
+
client_rows: dict[str, list[tuple[str, str | None]]],
|
|
149
|
+
client_mode: str,
|
|
150
|
+
) -> list[str]:
|
|
151
|
+
wired_count, client_sample = _client_summary(device, client_rows)
|
|
152
|
+
client_label = f"Clients ({client_mode})"
|
|
153
|
+
rows = [
|
|
154
|
+
["Model", _escape_cell(device.model_name or device.type or "-")],
|
|
155
|
+
["Type", _escape_cell(device.type or "-")],
|
|
156
|
+
["IP", _escape_cell(device.ip or "-")],
|
|
157
|
+
["MAC", _escape_cell(device.mac or "-")],
|
|
158
|
+
["Firmware", _escape_cell(device.version or "-")],
|
|
159
|
+
["Uplink", _escape_cell(_uplink_summary(device))],
|
|
160
|
+
["Ports", _escape_cell(_port_summary(device))],
|
|
161
|
+
["PoE", _escape_cell(_poe_summary(device))],
|
|
162
|
+
[client_label, _escape_cell(wired_count)],
|
|
163
|
+
["Client examples", _escape_cell(client_sample)],
|
|
164
|
+
]
|
|
165
|
+
lines = ["### Details", ""]
|
|
166
|
+
lines.extend(markdown_table_lines(["Field", "Value"], rows))
|
|
167
|
+
return lines
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _lldp_rows(
|
|
171
|
+
entries: Iterable[LLDPEntry],
|
|
172
|
+
device_index: dict[str, str],
|
|
173
|
+
) -> list[list[str]]:
|
|
174
|
+
rows: list[list[str]] = []
|
|
175
|
+
for entry in sorted(entries, key=_lldp_sort_key):
|
|
176
|
+
local_label = local_port_label(entry) or "?"
|
|
177
|
+
peer_name = device_index.get(_normalize_mac(entry.chassis_id), "")
|
|
178
|
+
peer_port = entry.port_id or "?"
|
|
179
|
+
port_desc = entry.port_desc or ""
|
|
180
|
+
rows.append(
|
|
181
|
+
[
|
|
182
|
+
local_label,
|
|
183
|
+
peer_name or "-",
|
|
184
|
+
peer_port,
|
|
185
|
+
entry.chassis_id,
|
|
186
|
+
port_desc or "-",
|
|
187
|
+
]
|
|
188
|
+
)
|
|
189
|
+
return rows
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _escape_cell(value: str) -> str:
|
|
193
|
+
return value.replace("|", "\\|")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _client_rows(
|
|
197
|
+
clients: Iterable[object],
|
|
198
|
+
device_index: dict[str, str],
|
|
199
|
+
*,
|
|
200
|
+
include_ports: bool,
|
|
201
|
+
client_mode: str,
|
|
202
|
+
) -> dict[str, list[tuple[str, str | None]]]:
|
|
203
|
+
rows_by_device: dict[str, list[tuple[str, str | None]]] = {}
|
|
204
|
+
for client in clients:
|
|
205
|
+
if not _client_matches_mode(client, client_mode):
|
|
206
|
+
continue
|
|
207
|
+
name = _client_display_name(client)
|
|
208
|
+
uplink_mac = _client_uplink_mac(client)
|
|
209
|
+
if not name or not uplink_mac:
|
|
210
|
+
continue
|
|
211
|
+
device_name = device_index.get(_normalize_mac(uplink_mac))
|
|
212
|
+
if not device_name:
|
|
213
|
+
continue
|
|
214
|
+
port_label = None
|
|
215
|
+
if include_ports:
|
|
216
|
+
uplink_port = _client_uplink_port(client)
|
|
217
|
+
if uplink_port is not None:
|
|
218
|
+
port_label = f"Port {uplink_port}"
|
|
219
|
+
rows_by_device.setdefault(device_name, []).append((name, port_label))
|
|
220
|
+
return rows_by_device
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _prepare_lldp_maps(
|
|
224
|
+
devices: list[Device],
|
|
225
|
+
*,
|
|
226
|
+
clients: Iterable[object] | None,
|
|
227
|
+
include_ports: bool,
|
|
228
|
+
show_clients: bool,
|
|
229
|
+
client_mode: str,
|
|
230
|
+
) -> tuple[
|
|
231
|
+
dict[tuple[str, str], str],
|
|
232
|
+
dict[str, list[tuple[int, str]]] | None,
|
|
233
|
+
dict[str, list[tuple[str, str | None]]],
|
|
234
|
+
]:
|
|
235
|
+
device_index = build_device_index(devices)
|
|
236
|
+
client_rows = (
|
|
237
|
+
_client_rows(clients, device_index, include_ports=include_ports, client_mode=client_mode)
|
|
238
|
+
if clients
|
|
239
|
+
else {}
|
|
240
|
+
)
|
|
241
|
+
port_map: dict[tuple[str, str], str] = {}
|
|
242
|
+
client_port_map = None
|
|
243
|
+
if include_ports:
|
|
244
|
+
port_map = build_port_map(devices, only_unifi=False)
|
|
245
|
+
if clients and show_clients:
|
|
246
|
+
client_port_map = build_client_port_map(devices, clients, client_mode=client_mode)
|
|
247
|
+
return port_map, client_port_map, client_rows
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _render_device_lldp_section(
|
|
251
|
+
device: Device,
|
|
252
|
+
*,
|
|
253
|
+
device_index: dict[str, str],
|
|
254
|
+
port_map: dict[tuple[str, str], str],
|
|
255
|
+
client_port_map: dict[str, list[tuple[int, str]]] | None,
|
|
256
|
+
client_rows: dict[str, list[tuple[str, str | None]]],
|
|
257
|
+
include_ports: bool,
|
|
258
|
+
show_clients: bool,
|
|
259
|
+
client_mode: str,
|
|
260
|
+
) -> str:
|
|
261
|
+
details = "\n".join(_details_table_lines(device, client_rows, client_mode)).rstrip()
|
|
262
|
+
ports_section = ""
|
|
263
|
+
if include_ports:
|
|
264
|
+
ports_section = "\n".join(
|
|
265
|
+
[
|
|
266
|
+
"### Ports",
|
|
267
|
+
"",
|
|
268
|
+
render_device_port_details(device, port_map, client_ports=client_port_map).strip(),
|
|
269
|
+
]
|
|
270
|
+
).rstrip()
|
|
271
|
+
if device.lldp_info:
|
|
272
|
+
lldp_section = "\n".join(
|
|
273
|
+
markdown_table_lines(
|
|
274
|
+
["Local Port", "Neighbor", "Neighbor Port", "Chassis ID", "Port Description"],
|
|
275
|
+
_lldp_rows(device.lldp_info, device_index),
|
|
276
|
+
escape=_escape_cell,
|
|
277
|
+
)
|
|
278
|
+
).rstrip()
|
|
279
|
+
else:
|
|
280
|
+
lldp_section = "_No LLDP neighbors._"
|
|
281
|
+
clients_section = ""
|
|
282
|
+
rows = client_rows.get(device.name)
|
|
283
|
+
if rows and show_clients:
|
|
284
|
+
if include_ports:
|
|
285
|
+
clients_section = "\n".join(
|
|
286
|
+
[
|
|
287
|
+
"### Clients",
|
|
288
|
+
"",
|
|
289
|
+
"\n".join(
|
|
290
|
+
markdown_table_lines(
|
|
291
|
+
["Client", "Port"],
|
|
292
|
+
[
|
|
293
|
+
[_escape_cell(client_name), _escape_cell(port_label or "-")]
|
|
294
|
+
for client_name, port_label in rows
|
|
295
|
+
],
|
|
296
|
+
)
|
|
297
|
+
),
|
|
298
|
+
]
|
|
299
|
+
).rstrip()
|
|
300
|
+
else:
|
|
301
|
+
clients_section = "\n".join(
|
|
302
|
+
["### Clients", *[f"- {_escape_cell(name)}" for name, _ in rows]]
|
|
303
|
+
).rstrip()
|
|
304
|
+
return render_template(
|
|
305
|
+
"lldp_device_section.md.j2",
|
|
306
|
+
device_name=device.name,
|
|
307
|
+
details=details,
|
|
308
|
+
ports_section=ports_section,
|
|
309
|
+
lldp_section=lldp_section,
|
|
310
|
+
clients_section=clients_section,
|
|
311
|
+
).rstrip()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def render_lldp_md(
|
|
315
|
+
devices: list[Device],
|
|
316
|
+
*,
|
|
317
|
+
clients: Iterable[object] | None = None,
|
|
318
|
+
include_ports: bool = False,
|
|
319
|
+
show_clients: bool = False,
|
|
320
|
+
client_mode: str = "wired",
|
|
321
|
+
) -> str:
|
|
322
|
+
device_index = build_device_index(devices)
|
|
323
|
+
port_map, client_port_map, client_rows = _prepare_lldp_maps(
|
|
324
|
+
devices,
|
|
325
|
+
clients=clients,
|
|
326
|
+
include_ports=include_ports,
|
|
327
|
+
show_clients=show_clients,
|
|
328
|
+
client_mode=client_mode,
|
|
329
|
+
)
|
|
330
|
+
sections: list[str] = []
|
|
331
|
+
for device in sorted(devices, key=lambda item: item.name.lower()):
|
|
332
|
+
sections.append(
|
|
333
|
+
_render_device_lldp_section(
|
|
334
|
+
device,
|
|
335
|
+
device_index=device_index,
|
|
336
|
+
port_map=port_map,
|
|
337
|
+
client_port_map=client_port_map,
|
|
338
|
+
client_rows=client_rows,
|
|
339
|
+
include_ports=include_ports,
|
|
340
|
+
show_clients=show_clients,
|
|
341
|
+
client_mode=client_mode,
|
|
342
|
+
)
|
|
343
|
+
)
|
|
344
|
+
body = "\n\n".join(section for section in sections if section).rstrip()
|
|
345
|
+
return (
|
|
346
|
+
render_template(
|
|
347
|
+
"markdown_section.md.j2",
|
|
348
|
+
title="LLDP Neighbors",
|
|
349
|
+
body=body,
|
|
350
|
+
).rstrip()
|
|
351
|
+
+ "\n"
|
|
352
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Markdown table helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Iterable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def markdown_table_lines(
|
|
9
|
+
headers: list[str],
|
|
10
|
+
rows: Iterable[Iterable[str]],
|
|
11
|
+
*,
|
|
12
|
+
escape: Callable[[str], str] | None = None,
|
|
13
|
+
) -> list[str]:
|
|
14
|
+
esc = escape or (lambda value: value)
|
|
15
|
+
lines = [
|
|
16
|
+
"| " + " | ".join(headers) + " |",
|
|
17
|
+
"| " + " | ".join(["---"] * len(headers)) + " |",
|
|
18
|
+
]
|
|
19
|
+
for row in rows:
|
|
20
|
+
lines.append("| " + " | ".join(esc(cell) for cell in row) + " |")
|
|
21
|
+
return lines
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Mermaid diagram rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
|
|
8
|
+
from ..model.topology import Edge
|
|
9
|
+
from .mermaid_theme import DEFAULT_THEME, MermaidTheme, class_defs
|
|
10
|
+
from .templating import render_template
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _escape(label: str) -> str:
|
|
14
|
+
normalized = label.replace("\r\n", "\n").replace("\r", "\n")
|
|
15
|
+
escaped = normalized.replace("\\", "\\\\").replace("\n", "\\n")
|
|
16
|
+
return escaped.replace('"', '\\"')
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _slugify(value: str) -> str:
|
|
20
|
+
normalized = []
|
|
21
|
+
for ch in value.strip():
|
|
22
|
+
if ch.isalnum():
|
|
23
|
+
normalized.append(ch.lower())
|
|
24
|
+
else:
|
|
25
|
+
normalized.append("_")
|
|
26
|
+
slug = "".join(normalized).strip("_")
|
|
27
|
+
if not slug or slug[0].isdigit():
|
|
28
|
+
slug = f"n_{slug}" if slug else "n"
|
|
29
|
+
return slug
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _build_id_map(edges: Iterable[Edge], nodes: Iterable[str]) -> dict[str, str]:
|
|
33
|
+
id_map: dict[str, str] = {}
|
|
34
|
+
used: set[str] = set()
|
|
35
|
+
|
|
36
|
+
def assign(name: str) -> None:
|
|
37
|
+
if name in id_map:
|
|
38
|
+
return
|
|
39
|
+
base = _slugify(name)
|
|
40
|
+
candidate = base
|
|
41
|
+
counter = 2
|
|
42
|
+
while candidate in used:
|
|
43
|
+
candidate = f"{base}_{counter}"
|
|
44
|
+
counter += 1
|
|
45
|
+
id_map[name] = candidate
|
|
46
|
+
used.add(candidate)
|
|
47
|
+
|
|
48
|
+
for node in nodes:
|
|
49
|
+
assign(node)
|
|
50
|
+
for edge in edges:
|
|
51
|
+
assign(edge.left)
|
|
52
|
+
assign(edge.right)
|
|
53
|
+
|
|
54
|
+
return id_map
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _node_ref(name: str, node_id: str) -> str:
|
|
58
|
+
return f'{node_id}["{_escape(name)}"]'
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _group_nodes(groups: dict[str, list[str]] | None) -> list[str]:
|
|
62
|
+
if not groups:
|
|
63
|
+
return []
|
|
64
|
+
nodes: list[str] = []
|
|
65
|
+
for members in groups.values():
|
|
66
|
+
nodes.extend(members)
|
|
67
|
+
return nodes
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _render_group_sections(
|
|
71
|
+
lines: list[str],
|
|
72
|
+
groups: dict[str, list[str]],
|
|
73
|
+
*,
|
|
74
|
+
group_order: list[str] | None,
|
|
75
|
+
id_map: dict[str, str],
|
|
76
|
+
) -> None:
|
|
77
|
+
ordered = group_order or list(groups.keys())
|
|
78
|
+
for group_name in ordered:
|
|
79
|
+
members = groups.get(group_name, [])
|
|
80
|
+
if not members:
|
|
81
|
+
continue
|
|
82
|
+
group_id = _slugify(f"group_{group_name}")
|
|
83
|
+
label = group_name.replace("_", " ").title()
|
|
84
|
+
lines.append(f' subgraph {group_id}["{_escape(label)}"];')
|
|
85
|
+
for member in members:
|
|
86
|
+
lines.append(f" {_node_ref(member, id_map[member])};")
|
|
87
|
+
lines.append(" end")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _render_edge_lines(
|
|
91
|
+
lines: list[str],
|
|
92
|
+
edges: list[Edge],
|
|
93
|
+
*,
|
|
94
|
+
id_map: dict[str, str],
|
|
95
|
+
use_node_labels: bool,
|
|
96
|
+
) -> tuple[list[int], list[int]]:
|
|
97
|
+
poe_links: list[int] = []
|
|
98
|
+
wireless_links: list[int] = []
|
|
99
|
+
for index, edge in enumerate(edges):
|
|
100
|
+
if use_node_labels:
|
|
101
|
+
left = _node_ref(edge.left, id_map[edge.left])
|
|
102
|
+
right = _node_ref(edge.right, id_map[edge.right])
|
|
103
|
+
else:
|
|
104
|
+
left = id_map[edge.left]
|
|
105
|
+
right = id_map[edge.right]
|
|
106
|
+
if edge.label:
|
|
107
|
+
label = _escape(edge.label)
|
|
108
|
+
lines.append(f' {left} ---|"{label}"| {right};')
|
|
109
|
+
else:
|
|
110
|
+
lines.append(f" {left} --- {right};")
|
|
111
|
+
if edge.poe:
|
|
112
|
+
poe_links.append(index)
|
|
113
|
+
if edge.wireless:
|
|
114
|
+
wireless_links.append(index)
|
|
115
|
+
return poe_links, wireless_links
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _render_node_classes(
|
|
119
|
+
lines: list[str],
|
|
120
|
+
*,
|
|
121
|
+
node_types: dict[str, str],
|
|
122
|
+
id_map: dict[str, str],
|
|
123
|
+
theme: MermaidTheme,
|
|
124
|
+
) -> None:
|
|
125
|
+
class_map = {
|
|
126
|
+
"gateway": "node_gateway",
|
|
127
|
+
"switch": "node_switch",
|
|
128
|
+
"ap": "node_ap",
|
|
129
|
+
"client": "node_client",
|
|
130
|
+
"other": "node_other",
|
|
131
|
+
}
|
|
132
|
+
for name, node_type in node_types.items():
|
|
133
|
+
class_name = class_map.get(node_type, "node_other")
|
|
134
|
+
node_id = id_map.get(name)
|
|
135
|
+
if node_id:
|
|
136
|
+
lines.append(f" class {node_id} {class_name};")
|
|
137
|
+
lines.extend(class_defs(theme))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _render_link_styles(
|
|
141
|
+
lines: list[str],
|
|
142
|
+
*,
|
|
143
|
+
poe_links: list[int],
|
|
144
|
+
wireless_links: list[int],
|
|
145
|
+
theme: MermaidTheme,
|
|
146
|
+
) -> None:
|
|
147
|
+
for index in poe_links:
|
|
148
|
+
lines.append(
|
|
149
|
+
" linkStyle "
|
|
150
|
+
f"{index} stroke:{theme.poe_link},stroke-width:{theme.poe_link_width}px,"
|
|
151
|
+
f"arrowhead:{theme.poe_link_arrow};"
|
|
152
|
+
)
|
|
153
|
+
for index in wireless_links:
|
|
154
|
+
lines.append(f" linkStyle {index} stroke-dasharray: 5 4;")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def render_mermaid(
|
|
158
|
+
edges: Iterable[Edge],
|
|
159
|
+
direction: str = "LR",
|
|
160
|
+
*,
|
|
161
|
+
groups: dict[str, list[str]] | None = None,
|
|
162
|
+
group_order: list[str] | None = None,
|
|
163
|
+
node_types: dict[str, str] | None = None,
|
|
164
|
+
theme: MermaidTheme = DEFAULT_THEME,
|
|
165
|
+
) -> str:
|
|
166
|
+
edge_list = list(edges)
|
|
167
|
+
id_map = _build_id_map(edge_list, _group_nodes(groups))
|
|
168
|
+
theme_vars: dict[str, object] = {}
|
|
169
|
+
if theme.edge_label_border:
|
|
170
|
+
theme_vars["edgeLabelBorderColor"] = theme.edge_label_border
|
|
171
|
+
if theme.edge_label_border_width:
|
|
172
|
+
theme_vars["edgeLabelBorderWidth"] = theme.edge_label_border_width
|
|
173
|
+
lines = []
|
|
174
|
+
if theme_vars:
|
|
175
|
+
lines.append(f'%%{{init: {{"themeVariables": {json.dumps(theme_vars)}}}}}%%')
|
|
176
|
+
lines.append(f"graph {direction}")
|
|
177
|
+
if groups:
|
|
178
|
+
_render_group_sections(lines, groups, group_order=group_order, id_map=id_map)
|
|
179
|
+
use_node_labels = not groups
|
|
180
|
+
poe_links, wireless_links = _render_edge_lines(
|
|
181
|
+
lines, edge_list, id_map=id_map, use_node_labels=use_node_labels
|
|
182
|
+
)
|
|
183
|
+
if node_types:
|
|
184
|
+
_render_node_classes(lines, node_types=node_types, id_map=id_map, theme=theme)
|
|
185
|
+
_render_link_styles(lines, poe_links=poe_links, wireless_links=wireless_links, theme=theme)
|
|
186
|
+
return "\n".join(lines) + "\n"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def render_legend(theme: MermaidTheme = DEFAULT_THEME, *, legend_scale: float = 1.0) -> str:
|
|
190
|
+
scale = legend_scale if legend_scale > 0 else 1.0
|
|
191
|
+
legend_font_size = max(7, round(10 * scale))
|
|
192
|
+
poe_link_width = max(1, round(theme.poe_link_width * scale))
|
|
193
|
+
standard_link_width = max(1, round(theme.standard_link_width * scale))
|
|
194
|
+
node_spacing = max(10, round(50 * scale))
|
|
195
|
+
rank_spacing = max(10, round(50 * scale))
|
|
196
|
+
node_padding = max(4, round(12 * scale))
|
|
197
|
+
return (
|
|
198
|
+
render_template(
|
|
199
|
+
"mermaid_legend.mmd.j2",
|
|
200
|
+
node_spacing=node_spacing,
|
|
201
|
+
rank_spacing=rank_spacing,
|
|
202
|
+
legend_font_size=legend_font_size,
|
|
203
|
+
node_padding=node_padding,
|
|
204
|
+
class_defs="\n".join(class_defs(theme)),
|
|
205
|
+
poe_link=theme.poe_link,
|
|
206
|
+
poe_link_width=poe_link_width,
|
|
207
|
+
poe_link_arrow=theme.poe_link_arrow,
|
|
208
|
+
standard_link=theme.standard_link,
|
|
209
|
+
standard_link_width=standard_link_width,
|
|
210
|
+
standard_link_arrow=theme.standard_link_arrow,
|
|
211
|
+
).rstrip()
|
|
212
|
+
+ "\n"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def render_legend_compact(theme: MermaidTheme = DEFAULT_THEME) -> str:
|
|
217
|
+
rows = [
|
|
218
|
+
{
|
|
219
|
+
"kind": "swatch",
|
|
220
|
+
"fill": theme.node_gateway[0],
|
|
221
|
+
"stroke": theme.node_gateway[1],
|
|
222
|
+
"label": "Gateway",
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
"kind": "swatch",
|
|
226
|
+
"fill": theme.node_switch[0],
|
|
227
|
+
"stroke": theme.node_switch[1],
|
|
228
|
+
"label": "Switch",
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
"kind": "swatch",
|
|
232
|
+
"fill": theme.node_ap[0],
|
|
233
|
+
"stroke": theme.node_ap[1],
|
|
234
|
+
"label": "AP",
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
"kind": "swatch",
|
|
238
|
+
"fill": theme.node_client[0],
|
|
239
|
+
"stroke": theme.node_client[1],
|
|
240
|
+
"label": "Client",
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
"kind": "swatch",
|
|
244
|
+
"fill": theme.node_other[0],
|
|
245
|
+
"stroke": theme.node_other[1],
|
|
246
|
+
"label": "Other",
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
"kind": "line",
|
|
250
|
+
"color": theme.poe_link,
|
|
251
|
+
"width": max(1, theme.poe_link_width),
|
|
252
|
+
"dashed": False,
|
|
253
|
+
"label": "PoE",
|
|
254
|
+
"bolt": True,
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
"kind": "line",
|
|
258
|
+
"color": theme.standard_link,
|
|
259
|
+
"width": max(1, theme.standard_link_width),
|
|
260
|
+
"dashed": False,
|
|
261
|
+
"label": "Link",
|
|
262
|
+
"bolt": False,
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
"kind": "line",
|
|
266
|
+
"color": theme.standard_link,
|
|
267
|
+
"width": max(1, theme.standard_link_width),
|
|
268
|
+
"dashed": True,
|
|
269
|
+
"label": "Wireless",
|
|
270
|
+
"bolt": False,
|
|
271
|
+
},
|
|
272
|
+
]
|
|
273
|
+
return render_template("legend_compact.html.j2", rows=rows)
|
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
node_text: str | None = None
|
|
22
|
+
edge_label_border: str | None = None
|
|
23
|
+
edge_label_border_width: int | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
DEFAULT_THEME = MermaidTheme(
|
|
27
|
+
node_gateway=("#ffe3b3", "#d98300"),
|
|
28
|
+
node_switch=("#d6ecff", "#3a7bd5"),
|
|
29
|
+
node_ap=("#d7f5e7", "#27ae60"),
|
|
30
|
+
node_client=("#f2e5ff", "#7f3fbf"),
|
|
31
|
+
node_other=("#eeeeee", "#8f8f8f"),
|
|
32
|
+
poe_link="#1e88e5",
|
|
33
|
+
poe_link_width=2,
|
|
34
|
+
poe_link_arrow="none",
|
|
35
|
+
standard_link="#2ecc71",
|
|
36
|
+
standard_link_width=2,
|
|
37
|
+
standard_link_arrow="none",
|
|
38
|
+
node_text=None,
|
|
39
|
+
edge_label_border=None,
|
|
40
|
+
edge_label_border_width=None,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def class_defs(theme: MermaidTheme = DEFAULT_THEME) -> list[str]:
|
|
45
|
+
def node_def(name: str, fill: str, stroke: str) -> str:
|
|
46
|
+
color = f",color:{theme.node_text}" if theme.node_text else ""
|
|
47
|
+
return f" classDef {name} fill:{fill},stroke:{stroke},stroke-width:1px{color};"
|
|
48
|
+
|
|
49
|
+
return [
|
|
50
|
+
node_def("node_gateway", theme.node_gateway[0], theme.node_gateway[1]),
|
|
51
|
+
node_def("node_switch", theme.node_switch[0], theme.node_switch[1]),
|
|
52
|
+
node_def("node_ap", theme.node_ap[0], theme.node_ap[1]),
|
|
53
|
+
node_def("node_client", theme.node_client[0], theme.node_client[1]),
|
|
54
|
+
node_def("node_other", theme.node_other[0], theme.node_other[1]),
|
|
55
|
+
" classDef node_legend font-size:10px;",
|
|
56
|
+
]
|