unifi-network-maps 1.3.1__py3-none-any.whl → 1.4.1__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 -1
- unifi_network_maps/__main__.py +8 -0
- unifi_network_maps/adapters/unifi.py +90 -9
- unifi_network_maps/cli/main.py +320 -23
- unifi_network_maps/io/mock_data.py +23 -0
- unifi_network_maps/io/mock_generate.py +299 -0
- unifi_network_maps/model/lldp.py +26 -12
- unifi_network_maps/model/topology.py +111 -3
- unifi_network_maps/render/device_ports_md.py +462 -0
- unifi_network_maps/render/lldp_md.py +33 -12
- unifi_network_maps/render/mermaid.py +62 -3
- {unifi_network_maps-1.3.1.dist-info → unifi_network_maps-1.4.1.dist-info}/METADATA +89 -15
- {unifi_network_maps-1.3.1.dist-info → unifi_network_maps-1.4.1.dist-info}/RECORD +17 -13
- {unifi_network_maps-1.3.1.dist-info → unifi_network_maps-1.4.1.dist-info}/WHEEL +0 -0
- {unifi_network_maps-1.3.1.dist-info → unifi_network_maps-1.4.1.dist-info}/entry_points.txt +0 -0
- {unifi_network_maps-1.3.1.dist-info → unifi_network_maps-1.4.1.dist-info}/licenses/LICENSE +0 -0
- {unifi_network_maps-1.3.1.dist-info → unifi_network_maps-1.4.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
"""Render per-device port overview tables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from html import escape as _escape_html
|
|
7
|
+
|
|
8
|
+
from ..model.ports import extract_port_number
|
|
9
|
+
from ..model.topology import ClientPortMap, Device, PortMap, classify_device_type
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def render_device_port_overview(
|
|
13
|
+
devices: list[Device],
|
|
14
|
+
port_map: PortMap,
|
|
15
|
+
*,
|
|
16
|
+
client_ports: ClientPortMap | None = None,
|
|
17
|
+
) -> str:
|
|
18
|
+
gateways = _collect_devices_by_type(devices, "gateway")
|
|
19
|
+
switches = _collect_devices_by_type(devices, "switch")
|
|
20
|
+
lines: list[str] = []
|
|
21
|
+
if gateways:
|
|
22
|
+
lines.append("## Gateways")
|
|
23
|
+
lines.append("")
|
|
24
|
+
lines.extend(_render_device_group(gateways, port_map, client_ports))
|
|
25
|
+
if switches:
|
|
26
|
+
if lines:
|
|
27
|
+
lines.append("")
|
|
28
|
+
lines.append("## Switches")
|
|
29
|
+
lines.append("")
|
|
30
|
+
lines.extend(_render_device_group(switches, port_map, client_ports))
|
|
31
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _collect_devices_by_type(devices: list[Device], desired_type: str) -> list[Device]:
|
|
35
|
+
return sorted(
|
|
36
|
+
[device for device in devices if classify_device_type(device) == desired_type],
|
|
37
|
+
key=lambda item: item.name.lower(),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _render_device_group(
|
|
42
|
+
devices: list[Device],
|
|
43
|
+
port_map: PortMap,
|
|
44
|
+
client_ports: ClientPortMap | None,
|
|
45
|
+
) -> list[str]:
|
|
46
|
+
lines: list[str] = []
|
|
47
|
+
for device in devices:
|
|
48
|
+
lines.append(f"### {device.name}")
|
|
49
|
+
lines.append("")
|
|
50
|
+
lines.extend(_render_device_details(device))
|
|
51
|
+
lines.extend(_render_device_ports(device, port_map, client_ports))
|
|
52
|
+
lines.append("")
|
|
53
|
+
return lines
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def render_device_port_details(
|
|
57
|
+
device: Device,
|
|
58
|
+
port_map: PortMap,
|
|
59
|
+
*,
|
|
60
|
+
client_ports: ClientPortMap | None = None,
|
|
61
|
+
) -> str:
|
|
62
|
+
lines = _render_device_details(device)
|
|
63
|
+
lines.extend(_render_device_ports(device, port_map, client_ports))
|
|
64
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _render_device_ports(
|
|
68
|
+
device: Device,
|
|
69
|
+
port_map: PortMap,
|
|
70
|
+
client_ports: ClientPortMap | None,
|
|
71
|
+
) -> list[str]:
|
|
72
|
+
rows = _build_port_rows(device, port_map, client_ports)
|
|
73
|
+
lines = [
|
|
74
|
+
"#### Ports",
|
|
75
|
+
"",
|
|
76
|
+
"| Port | Connected | Speed | PoE | Power |",
|
|
77
|
+
"| --- | --- | --- | --- | --- |",
|
|
78
|
+
]
|
|
79
|
+
for port_label, connected, speed, poe_state, power in rows:
|
|
80
|
+
lines.append(
|
|
81
|
+
f"| {_escape_cell(port_label)} | {_escape_cell(connected or '-')} | "
|
|
82
|
+
f"{_escape_cell(speed)} | {_escape_cell(poe_state)} | {_escape_cell(power)} |"
|
|
83
|
+
)
|
|
84
|
+
return lines
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _build_port_rows(
|
|
88
|
+
device: Device,
|
|
89
|
+
port_map: PortMap,
|
|
90
|
+
client_ports: ClientPortMap | None,
|
|
91
|
+
) -> list[tuple[str, str, str, str, str]]:
|
|
92
|
+
connections = _device_port_connections(device.name, port_map)
|
|
93
|
+
client_connections = _device_client_connections(device.name, client_ports)
|
|
94
|
+
aggregated = _aggregate_ports(device.port_table)
|
|
95
|
+
aggregated_indices = {
|
|
96
|
+
port.port_idx
|
|
97
|
+
for ports in aggregated.values()
|
|
98
|
+
for port in ports
|
|
99
|
+
if getattr(port, "port_idx", None) is not None
|
|
100
|
+
}
|
|
101
|
+
rows: list[tuple[tuple[int, int], tuple[str, str, str, str, str]]] = []
|
|
102
|
+
seen_ports: set[int] = set()
|
|
103
|
+
for port in sorted(device.port_table, key=_port_sort_key):
|
|
104
|
+
if port.port_idx in aggregated_indices:
|
|
105
|
+
port_idx = _port_index(port.port_idx, port.name)
|
|
106
|
+
if port_idx is not None:
|
|
107
|
+
seen_ports.add(port_idx)
|
|
108
|
+
continue
|
|
109
|
+
port_idx = _port_index(port.port_idx, port.name)
|
|
110
|
+
if port_idx is not None:
|
|
111
|
+
seen_ports.add(port_idx)
|
|
112
|
+
port_label = _format_port_label(port_idx, port.name)
|
|
113
|
+
connected = _format_connections(
|
|
114
|
+
device.name,
|
|
115
|
+
port_idx,
|
|
116
|
+
connections,
|
|
117
|
+
client_connections,
|
|
118
|
+
port_map,
|
|
119
|
+
)
|
|
120
|
+
rows.append(
|
|
121
|
+
(
|
|
122
|
+
(0, port_idx or 10_000),
|
|
123
|
+
(
|
|
124
|
+
port_label,
|
|
125
|
+
connected,
|
|
126
|
+
_format_speed(port.speed),
|
|
127
|
+
_format_poe_state(port),
|
|
128
|
+
_format_poe_power(port.poe_power),
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
for _group_id, group_ports in aggregated.items():
|
|
133
|
+
group_label = _format_aggregate_label(group_ports)
|
|
134
|
+
group_sort = _aggregate_sort_key(group_ports)
|
|
135
|
+
group_connections = _format_aggregate_connections(
|
|
136
|
+
device.name,
|
|
137
|
+
group_ports,
|
|
138
|
+
connections,
|
|
139
|
+
client_connections,
|
|
140
|
+
port_map,
|
|
141
|
+
)
|
|
142
|
+
rows.append(
|
|
143
|
+
(
|
|
144
|
+
(0, group_sort),
|
|
145
|
+
(
|
|
146
|
+
group_label,
|
|
147
|
+
group_connections,
|
|
148
|
+
_format_aggregate_speed(group_ports),
|
|
149
|
+
_format_aggregate_poe_state(group_ports),
|
|
150
|
+
_format_aggregate_power(group_ports),
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
for port_idx in sorted(connections):
|
|
155
|
+
if port_idx in seen_ports:
|
|
156
|
+
continue
|
|
157
|
+
port_label = _format_port_label(port_idx, None)
|
|
158
|
+
connected = _format_connections(
|
|
159
|
+
device.name,
|
|
160
|
+
port_idx,
|
|
161
|
+
connections,
|
|
162
|
+
client_connections,
|
|
163
|
+
port_map,
|
|
164
|
+
)
|
|
165
|
+
rows.append(((2, port_idx), (port_label, connected, "-", "-", "-")))
|
|
166
|
+
return [row for _key, row in sorted(rows, key=lambda item: item[0])]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _device_port_connections(device_name: str, port_map: PortMap) -> dict[int, list[str]]:
|
|
170
|
+
connections: dict[int, list[str]] = defaultdict(list)
|
|
171
|
+
for (src, dst), label in port_map.items():
|
|
172
|
+
if src != device_name:
|
|
173
|
+
continue
|
|
174
|
+
port_idx = extract_port_number(label or "")
|
|
175
|
+
if port_idx is None:
|
|
176
|
+
continue
|
|
177
|
+
connections[port_idx].append(dst)
|
|
178
|
+
return connections
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _device_client_connections(
|
|
182
|
+
device_name: str, client_ports: ClientPortMap | None
|
|
183
|
+
) -> dict[int, list[str]]:
|
|
184
|
+
if not client_ports:
|
|
185
|
+
return {}
|
|
186
|
+
rows = client_ports.get(device_name, [])
|
|
187
|
+
connections: dict[int, list[str]] = defaultdict(list)
|
|
188
|
+
for port_idx, name in rows:
|
|
189
|
+
connections[port_idx].append(name)
|
|
190
|
+
return connections
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _format_connections(
|
|
194
|
+
device_name: str,
|
|
195
|
+
port_idx: int | None,
|
|
196
|
+
connections: dict[int, list[str]],
|
|
197
|
+
client_connections: dict[int, list[str]],
|
|
198
|
+
port_map: PortMap,
|
|
199
|
+
) -> str:
|
|
200
|
+
if port_idx is None:
|
|
201
|
+
return ""
|
|
202
|
+
peers = connections.get(port_idx, [])
|
|
203
|
+
clients = client_connections.get(port_idx, [])
|
|
204
|
+
if not peers and not clients:
|
|
205
|
+
return ""
|
|
206
|
+
peer_entries: list[str] = []
|
|
207
|
+
for peer in sorted(peers, key=str.lower):
|
|
208
|
+
peer_label = port_map.get((peer, device_name))
|
|
209
|
+
if peer_label:
|
|
210
|
+
peer_entries.append(f"{peer} ({peer_label})")
|
|
211
|
+
else:
|
|
212
|
+
peer_entries.append(peer)
|
|
213
|
+
peer_text = ", ".join(peer_entries)
|
|
214
|
+
client_text = _format_client_connections(clients)
|
|
215
|
+
if peer_text and client_text:
|
|
216
|
+
return f"{peer_text}<br/>{client_text}"
|
|
217
|
+
return peer_text or client_text
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _format_port_label(port_idx: int | None, name: str | None) -> str:
|
|
221
|
+
if name and name.strip():
|
|
222
|
+
normalized = name.strip()
|
|
223
|
+
if port_idx is None:
|
|
224
|
+
return normalized
|
|
225
|
+
if normalized.lower() != f"port {port_idx}".lower():
|
|
226
|
+
return normalized
|
|
227
|
+
if port_idx is None:
|
|
228
|
+
return "Port ?"
|
|
229
|
+
return f"Port {port_idx}"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _format_speed(speed: int | None) -> str:
|
|
233
|
+
if speed is None or speed <= 0:
|
|
234
|
+
return "-"
|
|
235
|
+
if speed >= 1000:
|
|
236
|
+
if speed % 1000 == 0:
|
|
237
|
+
return f"{speed // 1000}G"
|
|
238
|
+
return f"{speed / 1000:.1f}G"
|
|
239
|
+
return f"{speed}M"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _format_poe_state(port: object) -> str:
|
|
243
|
+
poe_power = getattr(port, "poe_power", None)
|
|
244
|
+
poe_good = getattr(port, "poe_good", False)
|
|
245
|
+
poe_enable = getattr(port, "poe_enable", False)
|
|
246
|
+
port_poe = getattr(port, "port_poe", False)
|
|
247
|
+
if (poe_power or 0.0) > 0 or poe_good:
|
|
248
|
+
return "active"
|
|
249
|
+
if port_poe or poe_enable:
|
|
250
|
+
if not poe_enable:
|
|
251
|
+
return "disabled"
|
|
252
|
+
return "capable"
|
|
253
|
+
return "-"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _format_poe_power(power: float | None) -> str:
|
|
257
|
+
if power is None or power <= 0:
|
|
258
|
+
return "-"
|
|
259
|
+
return f"{power:.2f}W"
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _port_index(port_idx: int | None, name: str | None) -> int | None:
|
|
263
|
+
if port_idx is not None:
|
|
264
|
+
return port_idx
|
|
265
|
+
if name:
|
|
266
|
+
return extract_port_number(name)
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _port_sort_key(port: object) -> tuple[int, str]:
|
|
271
|
+
port_idx = _port_index(getattr(port, "port_idx", None), getattr(port, "name", None))
|
|
272
|
+
if port_idx is not None:
|
|
273
|
+
return (0, f"{port_idx:04d}")
|
|
274
|
+
name = getattr(port, "name", "") or ""
|
|
275
|
+
return (1, name.lower())
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _escape_cell(value: str) -> str:
|
|
279
|
+
return value.replace("|", "\\|")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _render_device_details(device: Device) -> list[str]:
|
|
283
|
+
lines = [
|
|
284
|
+
"#### Details",
|
|
285
|
+
"",
|
|
286
|
+
"| Field | Value |",
|
|
287
|
+
"| --- | --- |",
|
|
288
|
+
f"| Model | {_escape_cell(_device_model_label(device))} |",
|
|
289
|
+
f"| Type | {_escape_cell(device.type or '-')} |",
|
|
290
|
+
f"| IP | {_escape_cell(device.ip or '-')} |",
|
|
291
|
+
f"| MAC | {_escape_cell(device.mac or '-')} |",
|
|
292
|
+
f"| Firmware | {_escape_cell(device.version or '-')} |",
|
|
293
|
+
f"| Uplink | {_escape_cell(_uplink_summary(device))} |",
|
|
294
|
+
f"| Ports | {_escape_cell(_port_summary(device))} |",
|
|
295
|
+
f"| PoE | {_escape_cell(_poe_summary(device))} |",
|
|
296
|
+
"",
|
|
297
|
+
]
|
|
298
|
+
return lines
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _port_summary(device: Device) -> str:
|
|
302
|
+
ports = [port for port in device.port_table if port.port_idx is not None]
|
|
303
|
+
if not ports:
|
|
304
|
+
return "-"
|
|
305
|
+
total_ports = len(ports)
|
|
306
|
+
active_ports = sum(1 for port in ports if (port.speed or 0) > 0)
|
|
307
|
+
return f"{total_ports} total, {active_ports} active"
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _poe_summary(device: Device) -> str:
|
|
311
|
+
ports = [port for port in device.port_table if port.port_idx is not None]
|
|
312
|
+
if not ports:
|
|
313
|
+
return "-"
|
|
314
|
+
poe_capable = sum(1 for port in ports if port.port_poe or port.poe_enable)
|
|
315
|
+
poe_active = sum(1 for port in ports if _format_poe_state(port) == "active")
|
|
316
|
+
total_power = sum(port.poe_power or 0.0 for port in ports)
|
|
317
|
+
summary = f"{poe_capable} capable, {poe_active} active"
|
|
318
|
+
if total_power > 0:
|
|
319
|
+
summary = f"{summary}, {total_power:.2f}W"
|
|
320
|
+
return summary
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _uplink_summary(device: Device) -> str:
|
|
324
|
+
uplink = device.uplink or device.last_uplink
|
|
325
|
+
if not uplink:
|
|
326
|
+
if classify_device_type(device) == "gateway":
|
|
327
|
+
return "Internet"
|
|
328
|
+
return "-"
|
|
329
|
+
name = uplink.name or uplink.mac or "Unknown"
|
|
330
|
+
if classify_device_type(device) == "gateway":
|
|
331
|
+
lowered = name.lower()
|
|
332
|
+
if lowered in {"unknown", "wan", "internet"}:
|
|
333
|
+
name = "Internet"
|
|
334
|
+
elif lowered.startswith(("eth", "wan")):
|
|
335
|
+
name = "Internet"
|
|
336
|
+
if uplink.port is not None:
|
|
337
|
+
return f"{name} (Port {uplink.port})"
|
|
338
|
+
return name
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _device_model_label(device: Device) -> str:
|
|
342
|
+
if device.model_name:
|
|
343
|
+
return device.model_name
|
|
344
|
+
if device.model:
|
|
345
|
+
return device.model
|
|
346
|
+
return device.type or "-"
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _format_client_connections(clients: list[str]) -> str:
|
|
350
|
+
if not clients:
|
|
351
|
+
return ""
|
|
352
|
+
if len(clients) == 1:
|
|
353
|
+
return f"{clients[0]} (client)"
|
|
354
|
+
items = "".join(f"<li>{_escape_html(name)}</li>" for name in clients)
|
|
355
|
+
return f'<ul class="unifi-port-clients">{items}</ul>'
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _aggregate_ports(port_table: list[object]) -> dict[str, list[object]]:
|
|
359
|
+
groups: dict[str, list[object]] = defaultdict(list)
|
|
360
|
+
for port in port_table:
|
|
361
|
+
group = getattr(port, "aggregation_group", None)
|
|
362
|
+
if group:
|
|
363
|
+
groups[str(group)].append(port)
|
|
364
|
+
continue
|
|
365
|
+
if _looks_like_lag(port):
|
|
366
|
+
port_idx = getattr(port, "port_idx", None)
|
|
367
|
+
if port_idx is not None:
|
|
368
|
+
groups[f"lag-{port_idx}"].append(port)
|
|
369
|
+
if not groups:
|
|
370
|
+
return groups
|
|
371
|
+
port_by_idx = {
|
|
372
|
+
getattr(port, "port_idx", None): port for port in port_table if port.port_idx is not None
|
|
373
|
+
}
|
|
374
|
+
for group_id, group_ports in list(groups.items()):
|
|
375
|
+
if len(group_ports) > 1:
|
|
376
|
+
continue
|
|
377
|
+
lone_port = group_ports[0]
|
|
378
|
+
if not _looks_like_lag(lone_port):
|
|
379
|
+
continue
|
|
380
|
+
if getattr(lone_port, "port_idx", None) is None:
|
|
381
|
+
continue
|
|
382
|
+
candidates = []
|
|
383
|
+
for neighbor in (lone_port.port_idx - 1, lone_port.port_idx + 1):
|
|
384
|
+
port = port_by_idx.get(neighbor)
|
|
385
|
+
if port and not getattr(port, "aggregation_group", None):
|
|
386
|
+
if getattr(port, "speed", None) == getattr(lone_port, "speed", None):
|
|
387
|
+
candidates.append(port)
|
|
388
|
+
if candidates:
|
|
389
|
+
groups[group_id].extend(candidates)
|
|
390
|
+
return groups
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _looks_like_lag(port: object) -> bool:
|
|
394
|
+
name = (getattr(port, "name", "") or "").lower()
|
|
395
|
+
ifname = (getattr(port, "ifname", "") or "").lower()
|
|
396
|
+
return "lag" in name or "lag" in ifname or "aggregate" in name
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _format_aggregate_label(group_ports: list[object]) -> str:
|
|
400
|
+
ports = sorted([p.port_idx for p in group_ports if getattr(p, "port_idx", None) is not None])
|
|
401
|
+
if ports:
|
|
402
|
+
if len(ports) == 1:
|
|
403
|
+
return f"Port {ports[0]} (LAG)"
|
|
404
|
+
if ports == list(range(ports[0], ports[-1] + 1)):
|
|
405
|
+
return f"Port {ports[0]}-{ports[-1]} (LAG)"
|
|
406
|
+
return "Ports " + "+".join(str(port) for port in ports) + " (LAG)"
|
|
407
|
+
return "Aggregated ports"
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _aggregate_sort_key(group_ports: list[object]) -> int:
|
|
411
|
+
ports = sorted([p.port_idx for p in group_ports if getattr(p, "port_idx", None) is not None])
|
|
412
|
+
return ports[0] if ports else 10_000
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _format_aggregate_connections(
|
|
416
|
+
device_name: str,
|
|
417
|
+
group_ports: list[object],
|
|
418
|
+
connections: dict[int, list[str]],
|
|
419
|
+
client_connections: dict[int, list[str]],
|
|
420
|
+
port_map: PortMap,
|
|
421
|
+
) -> str:
|
|
422
|
+
rendered: list[str] = []
|
|
423
|
+
for port in group_ports:
|
|
424
|
+
port_idx = _port_index(getattr(port, "port_idx", None), getattr(port, "name", None))
|
|
425
|
+
if port_idx is None:
|
|
426
|
+
continue
|
|
427
|
+
text = _format_connections(
|
|
428
|
+
device_name,
|
|
429
|
+
port_idx,
|
|
430
|
+
connections,
|
|
431
|
+
client_connections,
|
|
432
|
+
port_map,
|
|
433
|
+
)
|
|
434
|
+
if text:
|
|
435
|
+
rendered.append(text)
|
|
436
|
+
return ", ".join([item for item in rendered if item])
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _format_aggregate_speed(group_ports: list[object]) -> str:
|
|
440
|
+
speeds = {getattr(port, "speed", None) for port in group_ports}
|
|
441
|
+
speeds.discard(None)
|
|
442
|
+
if not speeds:
|
|
443
|
+
return "-"
|
|
444
|
+
if len(speeds) == 1:
|
|
445
|
+
return _format_speed(next(iter(speeds)))
|
|
446
|
+
return "mixed"
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _format_aggregate_poe_state(group_ports: list[object]) -> str:
|
|
450
|
+
states = {_format_poe_state(port) for port in group_ports}
|
|
451
|
+
if "active" in states:
|
|
452
|
+
return "active"
|
|
453
|
+
if "disabled" in states:
|
|
454
|
+
return "disabled"
|
|
455
|
+
if "capable" in states:
|
|
456
|
+
return "capable"
|
|
457
|
+
return "-"
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _format_aggregate_power(group_ports: list[object]) -> str:
|
|
461
|
+
total = sum(getattr(port, "poe_power", 0.0) or 0.0 for port in group_ports)
|
|
462
|
+
return _format_poe_power(total)
|
|
@@ -5,7 +5,8 @@ from __future__ import annotations
|
|
|
5
5
|
from collections.abc import Iterable
|
|
6
6
|
|
|
7
7
|
from ..model.lldp import LLDPEntry, local_port_label
|
|
8
|
-
from ..model.topology import Device, build_device_index
|
|
8
|
+
from ..model.topology import Device, build_client_port_map, build_device_index, build_port_map
|
|
9
|
+
from .device_ports_md import render_device_port_details
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
def _normalize_mac(value: str) -> str:
|
|
@@ -78,17 +79,7 @@ def _lldp_sort_key(entry: LLDPEntry) -> tuple[int, str, str]:
|
|
|
78
79
|
|
|
79
80
|
|
|
80
81
|
def _device_header_lines(device: Device) -> list[str]:
|
|
81
|
-
|
|
82
|
-
meta = []
|
|
83
|
-
if device.model_name:
|
|
84
|
-
meta.append(f"Model: {device.model_name}")
|
|
85
|
-
if device.ip:
|
|
86
|
-
meta.append(f"IP: {device.ip}")
|
|
87
|
-
if device.mac:
|
|
88
|
-
meta.append(f"MAC: {device.mac}")
|
|
89
|
-
if meta:
|
|
90
|
-
lines.append(f"*{' | '.join(meta)}*")
|
|
91
|
-
return lines
|
|
82
|
+
return [f"## {device.name}"]
|
|
92
83
|
|
|
93
84
|
|
|
94
85
|
def _port_summary(device: Device) -> str:
|
|
@@ -105,6 +96,19 @@ def _port_summary(device: Device) -> str:
|
|
|
105
96
|
return summary
|
|
106
97
|
|
|
107
98
|
|
|
99
|
+
def _poe_summary(device: Device) -> str:
|
|
100
|
+
ports = [port for port in device.port_table if port.port_idx is not None]
|
|
101
|
+
if not ports:
|
|
102
|
+
return "-"
|
|
103
|
+
poe_capable = sum(1 for port in ports if port.port_poe or port.poe_enable)
|
|
104
|
+
poe_active = sum(1 for port in ports if (port.poe_power or 0.0) > 0 or port.poe_good)
|
|
105
|
+
total_power = sum(port.poe_power or 0.0 for port in ports)
|
|
106
|
+
summary = f"{poe_capable} capable, {poe_active} active"
|
|
107
|
+
if total_power > 0:
|
|
108
|
+
summary = f"{summary}, {total_power:.2f}W"
|
|
109
|
+
return summary
|
|
110
|
+
|
|
111
|
+
|
|
108
112
|
def _uplink_summary(device: Device) -> str:
|
|
109
113
|
uplink = device.uplink or device.last_uplink
|
|
110
114
|
if not uplink:
|
|
@@ -141,9 +145,14 @@ def _details_table_lines(
|
|
|
141
145
|
"",
|
|
142
146
|
"| Field | Value |",
|
|
143
147
|
"| --- | --- |",
|
|
148
|
+
f"| Model | {_escape_cell(device.model_name or device.type or '-')} |",
|
|
149
|
+
f"| Type | {_escape_cell(device.type or '-')} |",
|
|
150
|
+
f"| IP | {_escape_cell(device.ip or '-')} |",
|
|
151
|
+
f"| MAC | {_escape_cell(device.mac or '-')} |",
|
|
144
152
|
f"| Firmware | {_escape_cell(device.version or '-')} |",
|
|
145
153
|
f"| Uplink | {_escape_cell(_uplink_summary(device))} |",
|
|
146
154
|
f"| Ports | {_escape_cell(_port_summary(device))} |",
|
|
155
|
+
f"| PoE | {_escape_cell(_poe_summary(device))} |",
|
|
147
156
|
f"| {client_label} | {_escape_cell(wired_count)} |",
|
|
148
157
|
f"| Client examples | {_escape_cell(client_sample)} |",
|
|
149
158
|
"",
|
|
@@ -213,16 +222,28 @@ def render_lldp_md(
|
|
|
213
222
|
client_mode: str = "wired",
|
|
214
223
|
) -> str:
|
|
215
224
|
device_index = build_device_index(devices)
|
|
225
|
+
port_map = {}
|
|
226
|
+
client_port_map = None
|
|
216
227
|
client_rows = (
|
|
217
228
|
_client_rows(clients, device_index, include_ports=include_ports, client_mode=client_mode)
|
|
218
229
|
if clients
|
|
219
230
|
else {}
|
|
220
231
|
)
|
|
232
|
+
if include_ports:
|
|
233
|
+
port_map = build_port_map(devices, only_unifi=False)
|
|
234
|
+
if clients and show_clients:
|
|
235
|
+
client_port_map = build_client_port_map(devices, clients, client_mode=client_mode)
|
|
221
236
|
lines: list[str] = ["# LLDP Neighbors", ""]
|
|
222
237
|
for device in sorted(devices, key=lambda item: item.name.lower()):
|
|
223
238
|
lines.extend(_device_header_lines(device))
|
|
224
239
|
lines.append("")
|
|
225
240
|
lines.extend(_details_table_lines(device, client_rows, client_mode))
|
|
241
|
+
if include_ports:
|
|
242
|
+
lines.append("### Ports")
|
|
243
|
+
lines.append("")
|
|
244
|
+
lines.append(
|
|
245
|
+
render_device_port_details(device, port_map, client_ports=client_port_map).strip()
|
|
246
|
+
)
|
|
226
247
|
if device.lldp_info:
|
|
227
248
|
lines.append("")
|
|
228
249
|
lines.append(
|
|
@@ -129,8 +129,19 @@ def render_mermaid(
|
|
|
129
129
|
return "\n".join(lines) + "\n"
|
|
130
130
|
|
|
131
131
|
|
|
132
|
-
def render_legend(theme: MermaidTheme = DEFAULT_THEME) -> str:
|
|
132
|
+
def render_legend(theme: MermaidTheme = DEFAULT_THEME, *, legend_scale: float = 1.0) -> str:
|
|
133
|
+
scale = legend_scale if legend_scale > 0 else 1.0
|
|
134
|
+
legend_font_size = max(7, round(10 * scale))
|
|
135
|
+
poe_link_width = max(1, round(theme.poe_link_width * scale))
|
|
136
|
+
standard_link_width = max(1, round(theme.standard_link_width * scale))
|
|
137
|
+
node_spacing = max(10, round(50 * scale))
|
|
138
|
+
rank_spacing = max(10, round(50 * scale))
|
|
139
|
+
node_padding = max(4, round(12 * scale))
|
|
133
140
|
lines = [
|
|
141
|
+
"%%{init: {"
|
|
142
|
+
f'"flowchart": {{"nodeSpacing": {node_spacing}, "rankSpacing": {rank_spacing}}}, '
|
|
143
|
+
f'"themeVariables": {{"fontSize": "{legend_font_size}px", "nodePadding": {node_padding}}}'
|
|
144
|
+
"}}%%",
|
|
134
145
|
"graph TB",
|
|
135
146
|
' subgraph legend["Legend"];',
|
|
136
147
|
' legend_gateway["Gateway"];',
|
|
@@ -158,14 +169,62 @@ def render_legend(theme: MermaidTheme = DEFAULT_THEME) -> str:
|
|
|
158
169
|
" class legend_no_poe_b node_legend;",
|
|
159
170
|
]
|
|
160
171
|
lines.extend(class_defs(theme))
|
|
172
|
+
lines.append(f" classDef node_legend font-size:{legend_font_size}px;")
|
|
161
173
|
lines.append(
|
|
162
174
|
" linkStyle 0 "
|
|
163
|
-
f"stroke:{theme.poe_link},stroke-width:{
|
|
175
|
+
f"stroke:{theme.poe_link},stroke-width:{poe_link_width}px,"
|
|
164
176
|
f"arrowhead:{theme.poe_link_arrow};"
|
|
165
177
|
)
|
|
166
178
|
lines.append(
|
|
167
179
|
" linkStyle 1 "
|
|
168
|
-
f"stroke:{theme.standard_link},stroke-width:{
|
|
180
|
+
f"stroke:{theme.standard_link},stroke-width:{standard_link_width}px,"
|
|
169
181
|
f"arrowhead:{theme.standard_link_arrow};"
|
|
170
182
|
)
|
|
171
183
|
return "\n".join(lines) + "\n"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def render_legend_compact(theme: MermaidTheme = DEFAULT_THEME) -> str:
|
|
187
|
+
def swatch(fill: str, stroke: str, label: str) -> str:
|
|
188
|
+
return (
|
|
189
|
+
f'<span style="display:inline-block;width:12px;height:12px;'
|
|
190
|
+
f"background:{fill};border:1px solid {stroke};border-radius:2px;"
|
|
191
|
+
f'margin-right:6px;"></span>{label}'
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def line_sample(
|
|
195
|
+
color: str,
|
|
196
|
+
width: int,
|
|
197
|
+
*,
|
|
198
|
+
dashed: bool = False,
|
|
199
|
+
label: str = "",
|
|
200
|
+
bolt: bool = False,
|
|
201
|
+
) -> str:
|
|
202
|
+
dash = ' stroke-dasharray="5 4"' if dashed else ""
|
|
203
|
+
bolt_suffix = " ⚡" if bolt else ""
|
|
204
|
+
return (
|
|
205
|
+
f'<span style="display:inline-flex;align-items:center;gap:6px;">'
|
|
206
|
+
f'<svg width="42" height="10" viewBox="0 0 42 10" '
|
|
207
|
+
f'xmlns="http://www.w3.org/2000/svg" aria-hidden="true">'
|
|
208
|
+
f'<line x1="2" y1="5" x2="40" y2="5" stroke="{color}" '
|
|
209
|
+
f'stroke-width="{max(1, width)}"{dash} />'
|
|
210
|
+
f"</svg>{label}{bolt_suffix}</span>"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
rows = [
|
|
214
|
+
swatch(theme.node_gateway[0], theme.node_gateway[1], "Gateway"),
|
|
215
|
+
swatch(theme.node_switch[0], theme.node_switch[1], "Switch"),
|
|
216
|
+
swatch(theme.node_ap[0], theme.node_ap[1], "AP"),
|
|
217
|
+
swatch(theme.node_client[0], theme.node_client[1], "Client"),
|
|
218
|
+
swatch(theme.node_other[0], theme.node_other[1], "Other"),
|
|
219
|
+
line_sample(theme.poe_link, theme.poe_link_width, label="PoE", bolt=True),
|
|
220
|
+
line_sample(theme.standard_link, theme.standard_link_width, label="Link"),
|
|
221
|
+
line_sample(theme.standard_link, theme.standard_link_width, dashed=True, label="Wireless"),
|
|
222
|
+
]
|
|
223
|
+
lines = [
|
|
224
|
+
'<table class="unifi-legend-table">',
|
|
225
|
+
"<tbody>",
|
|
226
|
+
]
|
|
227
|
+
lines.extend(f" <tr><td>{style}</td></tr>" for style in rows)
|
|
228
|
+
lines.append("</tbody>")
|
|
229
|
+
lines.append("</table>")
|
|
230
|
+
return "\n".join(lines) + "\n"
|