unifi-network-maps 1.4.15__py3-none-any.whl → 1.5.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 -1
- unifi_network_maps/adapters/unifi.py +80 -96
- unifi_network_maps/assets/icons/modern/ap.svg +9 -0
- unifi_network_maps/assets/icons/modern/camera.svg +9 -0
- unifi_network_maps/assets/icons/modern/client.svg +9 -0
- unifi_network_maps/assets/icons/modern/game_console.svg +10 -0
- unifi_network_maps/assets/icons/modern/gateway.svg +17 -0
- unifi_network_maps/assets/icons/modern/iot.svg +9 -0
- unifi_network_maps/assets/icons/modern/nas.svg +9 -0
- unifi_network_maps/assets/icons/modern/other.svg +10 -0
- unifi_network_maps/assets/icons/modern/phone.svg +10 -0
- unifi_network_maps/assets/icons/modern/printer.svg +9 -0
- unifi_network_maps/assets/icons/modern/speaker.svg +10 -0
- unifi_network_maps/assets/icons/modern/switch.svg +10 -0
- unifi_network_maps/assets/icons/modern/tv.svg +10 -0
- unifi_network_maps/assets/icons/modern-flat/ap.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/camera.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/client.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/game_console.svg +6 -0
- unifi_network_maps/assets/icons/modern-flat/gateway.svg +13 -0
- unifi_network_maps/assets/icons/modern-flat/iot.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/nas.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/other.svg +6 -0
- unifi_network_maps/assets/icons/modern-flat/phone.svg +6 -0
- unifi_network_maps/assets/icons/modern-flat/printer.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/speaker.svg +6 -0
- unifi_network_maps/assets/icons/modern-flat/switch.svg +6 -0
- unifi_network_maps/assets/icons/modern-flat/tv.svg +6 -0
- unifi_network_maps/assets/themes/dark.yaml +53 -10
- unifi_network_maps/assets/themes/default.yaml +34 -0
- unifi_network_maps/assets/themes/minimal-dark.yaml +98 -0
- unifi_network_maps/assets/themes/minimal.yaml +92 -0
- unifi_network_maps/assets/themes/unifi-dark.yaml +97 -0
- unifi_network_maps/assets/themes/unifi.yaml +92 -0
- unifi_network_maps/cli/args.py +54 -0
- unifi_network_maps/cli/main.py +18 -7
- unifi_network_maps/cli/render.py +79 -27
- unifi_network_maps/cli/runtime.py +29 -15
- unifi_network_maps/io/debug.py +2 -1
- unifi_network_maps/io/export.py +19 -13
- unifi_network_maps/io/mock_data.py +5 -3
- unifi_network_maps/io/paths.py +5 -3
- unifi_network_maps/model/classify.py +199 -0
- unifi_network_maps/model/clients.py +271 -0
- unifi_network_maps/model/connection.py +37 -0
- unifi_network_maps/model/diff.py +544 -0
- unifi_network_maps/model/edges.py +558 -0
- unifi_network_maps/model/helpers.py +64 -0
- unifi_network_maps/model/lldp.py +20 -25
- unifi_network_maps/model/mock.py +110 -23
- unifi_network_maps/model/snapshot.py +294 -0
- unifi_network_maps/model/topology.py +143 -951
- unifi_network_maps/model/topology_coerce.py +339 -0
- unifi_network_maps/model/vlans.py +32 -46
- unifi_network_maps/model/wan.py +132 -0
- unifi_network_maps/render/device_ports_md.py +39 -97
- unifi_network_maps/render/device_summary.py +53 -0
- unifi_network_maps/render/lldp_md.py +29 -219
- unifi_network_maps/render/markdown_tables.py +8 -0
- unifi_network_maps/render/mermaid.py +11 -2
- unifi_network_maps/render/mkdocs.py +2 -1
- unifi_network_maps/render/svg.py +566 -908
- unifi_network_maps/render/svg_icons.py +231 -0
- unifi_network_maps/render/svg_isometric.py +1196 -0
- unifi_network_maps/render/svg_labels.py +184 -0
- unifi_network_maps/render/svg_theme.py +166 -32
- unifi_network_maps/render/theme.py +86 -1
- {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/METADATA +107 -31
- {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/RECORD +73 -31
- unifi_network_maps/assets/icons/isometric/printer.svg +0 -122
- {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/WHEEL +0 -0
- {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/entry_points.txt +0 -0
- {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/licenses/LICENSE +0 -0
- {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/top_level.txt +0 -0
|
@@ -5,9 +5,11 @@ from __future__ import annotations
|
|
|
5
5
|
from collections import defaultdict
|
|
6
6
|
from html import escape as _escape_html
|
|
7
7
|
|
|
8
|
+
from ..model.classify import classify_device_type
|
|
8
9
|
from ..model.ports import extract_port_number
|
|
9
|
-
from ..model.topology import ClientPortMap, Device, PortInfo, PortMap
|
|
10
|
-
from .
|
|
10
|
+
from ..model.topology import ClientPortMap, Device, PortInfo, PortMap
|
|
11
|
+
from .device_summary import poe_summary, port_summary, uplink_summary
|
|
12
|
+
from .markdown_tables import escape_markdown, markdown_table_lines
|
|
11
13
|
from .templating import render_template
|
|
12
14
|
|
|
13
15
|
|
|
@@ -83,11 +85,11 @@ def _render_device_ports(
|
|
|
83
85
|
rows = _build_port_rows(device, port_map, client_ports)
|
|
84
86
|
table_rows = [
|
|
85
87
|
[
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
88
|
+
escape_markdown(port_label),
|
|
89
|
+
connected or "-", # Not escaped: contains intentional HTML (br, ul)
|
|
90
|
+
escape_markdown(speed),
|
|
91
|
+
escape_markdown(poe_state),
|
|
92
|
+
escape_markdown(power),
|
|
91
93
|
]
|
|
92
94
|
for port_label, connected, speed, poe_state, power in rows
|
|
93
95
|
]
|
|
@@ -113,7 +115,7 @@ def _build_port_rows(
|
|
|
113
115
|
port.port_idx
|
|
114
116
|
for ports in aggregated.values()
|
|
115
117
|
for port in ports
|
|
116
|
-
if
|
|
118
|
+
if port.port_idx is not None
|
|
117
119
|
}
|
|
118
120
|
rows: list[tuple[tuple[int, int], tuple[str, str, str, str, str]]] = []
|
|
119
121
|
seen_ports: set[int] = set()
|
|
@@ -224,11 +226,9 @@ def _format_connections(
|
|
|
224
226
|
for peer in sorted(peers, key=str.lower):
|
|
225
227
|
peer_label = port_map.get((peer, device_name))
|
|
226
228
|
if peer_label:
|
|
227
|
-
peer_entries.append(
|
|
228
|
-
f"{_escape_markdown_text(peer)} ({_escape_markdown_text(peer_label)})"
|
|
229
|
-
)
|
|
229
|
+
peer_entries.append(f"{escape_markdown(peer)} ({escape_markdown(peer_label)})")
|
|
230
230
|
else:
|
|
231
|
-
peer_entries.append(
|
|
231
|
+
peer_entries.append(escape_markdown(peer))
|
|
232
232
|
peer_text = ", ".join(peer_entries)
|
|
233
233
|
client_text = _format_client_connections(clients)
|
|
234
234
|
if peer_text and client_text:
|
|
@@ -258,15 +258,11 @@ def _format_speed(speed: int | None) -> str:
|
|
|
258
258
|
return f"{speed}M"
|
|
259
259
|
|
|
260
260
|
|
|
261
|
-
def _format_poe_state(port:
|
|
262
|
-
|
|
263
|
-
poe_good = getattr(port, "poe_good", False)
|
|
264
|
-
poe_enable = getattr(port, "poe_enable", False)
|
|
265
|
-
port_poe = getattr(port, "port_poe", False)
|
|
266
|
-
if (poe_power or 0.0) > 0 or poe_good:
|
|
261
|
+
def _format_poe_state(port: PortInfo) -> str:
|
|
262
|
+
if (port.poe_power or 0.0) > 0 or port.poe_good:
|
|
267
263
|
return "active"
|
|
268
|
-
if port_poe or poe_enable:
|
|
269
|
-
if not poe_enable:
|
|
264
|
+
if port.port_poe or port.poe_enable:
|
|
265
|
+
if not port.poe_enable:
|
|
270
266
|
return "disabled"
|
|
271
267
|
return "capable"
|
|
272
268
|
return "-"
|
|
@@ -286,23 +282,11 @@ def _port_index(port_idx: int | None, name: str | None) -> int | None:
|
|
|
286
282
|
return None
|
|
287
283
|
|
|
288
284
|
|
|
289
|
-
def _port_sort_key(port:
|
|
290
|
-
port_idx = _port_index(
|
|
285
|
+
def _port_sort_key(port: PortInfo) -> tuple[int, str]:
|
|
286
|
+
port_idx = _port_index(port.port_idx, port.name)
|
|
291
287
|
if port_idx is not None:
|
|
292
288
|
return (0, f"{port_idx:04d}")
|
|
293
|
-
|
|
294
|
-
return (1, name.lower())
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
def _escape_markdown_text(value: str) -> str:
|
|
298
|
-
escaped = value.replace("\\", "\\\\")
|
|
299
|
-
for char in ("|", "[", "]", "*", "_", "`", "<", ">"):
|
|
300
|
-
escaped = escaped.replace(char, f"\\{char}")
|
|
301
|
-
return escaped
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
def _escape_connected_cell(value: str) -> str:
|
|
305
|
-
return value
|
|
289
|
+
return (1, (port.name or "").lower())
|
|
306
290
|
|
|
307
291
|
|
|
308
292
|
def _render_device_details(device: Device) -> list[str]:
|
|
@@ -311,59 +295,19 @@ def _render_device_details(device: Device) -> list[str]:
|
|
|
311
295
|
"",
|
|
312
296
|
"| Field | Value |",
|
|
313
297
|
"| --- | --- |",
|
|
314
|
-
f"| Model | {
|
|
315
|
-
f"| Type | {
|
|
316
|
-
f"| IP | {
|
|
317
|
-
f"| MAC | {
|
|
318
|
-
f"| Firmware | {
|
|
319
|
-
f"| Uplink | {
|
|
320
|
-
f"| Ports | {
|
|
321
|
-
f"| PoE | {
|
|
298
|
+
f"| Model | {escape_markdown(_device_model_label(device))} |",
|
|
299
|
+
f"| Type | {escape_markdown(device.type or '-')} |",
|
|
300
|
+
f"| IP | {escape_markdown(device.ip or '-')} |",
|
|
301
|
+
f"| MAC | {escape_markdown(device.mac or '-')} |",
|
|
302
|
+
f"| Firmware | {escape_markdown(device.version or '-')} |",
|
|
303
|
+
f"| Uplink | {escape_markdown(uplink_summary(device))} |",
|
|
304
|
+
f"| Ports | {escape_markdown(port_summary(device))} |",
|
|
305
|
+
f"| PoE | {escape_markdown(poe_summary(device))} |",
|
|
322
306
|
"",
|
|
323
307
|
]
|
|
324
308
|
return lines
|
|
325
309
|
|
|
326
310
|
|
|
327
|
-
def _port_summary(device: Device) -> str:
|
|
328
|
-
ports = [port for port in device.port_table if port.port_idx is not None]
|
|
329
|
-
if not ports:
|
|
330
|
-
return "-"
|
|
331
|
-
total_ports = len(ports)
|
|
332
|
-
active_ports = sum(1 for port in ports if (port.speed or 0) > 0)
|
|
333
|
-
return f"{total_ports} total, {active_ports} active"
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
def _poe_summary(device: Device) -> str:
|
|
337
|
-
ports = [port for port in device.port_table if port.port_idx is not None]
|
|
338
|
-
if not ports:
|
|
339
|
-
return "-"
|
|
340
|
-
poe_capable = sum(1 for port in ports if port.port_poe or port.poe_enable)
|
|
341
|
-
poe_active = sum(1 for port in ports if _format_poe_state(port) == "active")
|
|
342
|
-
total_power = sum(port.poe_power or 0.0 for port in ports)
|
|
343
|
-
summary = f"{poe_capable} capable, {poe_active} active"
|
|
344
|
-
if total_power > 0:
|
|
345
|
-
summary = f"{summary}, {total_power:.2f}W"
|
|
346
|
-
return summary
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
def _uplink_summary(device: Device) -> str:
|
|
350
|
-
uplink = device.uplink or device.last_uplink
|
|
351
|
-
if not uplink:
|
|
352
|
-
if classify_device_type(device) == "gateway":
|
|
353
|
-
return "Internet"
|
|
354
|
-
return "-"
|
|
355
|
-
name = uplink.name or uplink.mac or "Unknown"
|
|
356
|
-
if classify_device_type(device) == "gateway":
|
|
357
|
-
lowered = name.lower()
|
|
358
|
-
if lowered in {"unknown", "wan", "internet"}:
|
|
359
|
-
name = "Internet"
|
|
360
|
-
elif lowered.startswith(("eth", "wan")):
|
|
361
|
-
name = "Internet"
|
|
362
|
-
if uplink.port is not None:
|
|
363
|
-
return f"{name} (Port {uplink.port})"
|
|
364
|
-
return name
|
|
365
|
-
|
|
366
|
-
|
|
367
311
|
def _device_model_label(device: Device) -> str:
|
|
368
312
|
if device.model_name:
|
|
369
313
|
return device.model_name
|
|
@@ -376,7 +320,7 @@ def _format_client_connections(clients: list[str]) -> str:
|
|
|
376
320
|
if not clients:
|
|
377
321
|
return ""
|
|
378
322
|
if len(clients) == 1:
|
|
379
|
-
return f"{
|
|
323
|
+
return f"{escape_markdown(clients[0])} (client)"
|
|
380
324
|
items = "".join(f"<li>{_escape_html(name)}</li>" for name in clients)
|
|
381
325
|
return f'<ul class="unifi-port-clients">{items}</ul>'
|
|
382
326
|
|
|
@@ -384,14 +328,12 @@ def _format_client_connections(clients: list[str]) -> str:
|
|
|
384
328
|
def _aggregate_base_groups(port_table: list[PortInfo]) -> dict[str, list[PortInfo]]:
|
|
385
329
|
groups: dict[str, list[PortInfo]] = defaultdict(list)
|
|
386
330
|
for port in port_table:
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
groups[str(group)].append(port)
|
|
331
|
+
if port.aggregation_group:
|
|
332
|
+
groups[str(port.aggregation_group)].append(port)
|
|
390
333
|
continue
|
|
391
334
|
if _looks_like_lag(port):
|
|
392
|
-
port_idx
|
|
393
|
-
|
|
394
|
-
groups[f"lag-{port_idx}"].append(port)
|
|
335
|
+
if port.port_idx is not None:
|
|
336
|
+
groups[f"lag-{port.port_idx}"].append(port)
|
|
395
337
|
return groups
|
|
396
338
|
|
|
397
339
|
|
|
@@ -416,8 +358,8 @@ def _extend_singleton_groups(
|
|
|
416
358
|
candidates: list[PortInfo] = []
|
|
417
359
|
for neighbor in (port_idx - 1, port_idx + 1):
|
|
418
360
|
port = port_by_idx.get(neighbor)
|
|
419
|
-
if port and not
|
|
420
|
-
if
|
|
361
|
+
if port and not port.aggregation_group:
|
|
362
|
+
if port.speed == lone_port.speed:
|
|
421
363
|
candidates.append(port)
|
|
422
364
|
if candidates:
|
|
423
365
|
groups[group_id].extend(candidates)
|
|
@@ -430,8 +372,8 @@ def _aggregate_ports(port_table: list[PortInfo]) -> dict[str, list[PortInfo]]:
|
|
|
430
372
|
|
|
431
373
|
|
|
432
374
|
def _looks_like_lag(port: PortInfo) -> bool:
|
|
433
|
-
name = (
|
|
434
|
-
ifname = (
|
|
375
|
+
name = (port.name or "").lower()
|
|
376
|
+
ifname = (port.ifname or "").lower()
|
|
435
377
|
return "lag" in name or "lag" in ifname or "aggregate" in name
|
|
436
378
|
|
|
437
379
|
|
|
@@ -460,7 +402,7 @@ def _format_aggregate_connections(
|
|
|
460
402
|
) -> str:
|
|
461
403
|
rendered: list[str] = []
|
|
462
404
|
for port in group_ports:
|
|
463
|
-
port_idx = _port_index(
|
|
405
|
+
port_idx = _port_index(port.port_idx, port.name)
|
|
464
406
|
if port_idx is None:
|
|
465
407
|
continue
|
|
466
408
|
text = _format_connections(
|
|
@@ -476,7 +418,7 @@ def _format_aggregate_connections(
|
|
|
476
418
|
|
|
477
419
|
|
|
478
420
|
def _format_aggregate_speed(group_ports: list[PortInfo]) -> str:
|
|
479
|
-
speeds = {
|
|
421
|
+
speeds = {port.speed for port in group_ports}
|
|
480
422
|
speeds.discard(None)
|
|
481
423
|
if not speeds:
|
|
482
424
|
return "-"
|
|
@@ -497,5 +439,5 @@ def _format_aggregate_poe_state(group_ports: list[PortInfo]) -> str:
|
|
|
497
439
|
|
|
498
440
|
|
|
499
441
|
def _format_aggregate_power(group_ports: list[PortInfo]) -> str:
|
|
500
|
-
total = sum(
|
|
442
|
+
total = sum(port.poe_power or 0.0 for port in group_ports)
|
|
501
443
|
return _format_poe_power(total)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Shared device summary helpers for markdown renderers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..model.classify import classify_device_type
|
|
6
|
+
from ..model.topology import Device, PortInfo
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def port_summary(device: Device) -> str:
|
|
10
|
+
"""Summarize port count and activity for a device."""
|
|
11
|
+
ports = [port for port in device.port_table if port.port_idx is not None]
|
|
12
|
+
if not ports:
|
|
13
|
+
return "-"
|
|
14
|
+
total_ports = len(ports)
|
|
15
|
+
active_ports = sum(1 for port in ports if (port.speed or 0) > 0)
|
|
16
|
+
return f"{total_ports} total, {active_ports} active"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def poe_summary(device: Device) -> str:
|
|
20
|
+
"""Summarize PoE capability, activity, and power draw."""
|
|
21
|
+
ports = [port for port in device.port_table if port.port_idx is not None]
|
|
22
|
+
if not ports:
|
|
23
|
+
return "-"
|
|
24
|
+
poe_capable = sum(1 for port in ports if port.port_poe or port.poe_enable)
|
|
25
|
+
poe_active = sum(1 for port in ports if _is_poe_active(port))
|
|
26
|
+
total_power = sum(port.poe_power or 0.0 for port in ports)
|
|
27
|
+
summary = f"{poe_capable} capable, {poe_active} active"
|
|
28
|
+
if total_power > 0:
|
|
29
|
+
summary = f"{summary}, {total_power:.2f}W"
|
|
30
|
+
return summary
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def uplink_summary(device: Device) -> str:
|
|
34
|
+
"""Describe the device's uplink connection."""
|
|
35
|
+
uplink = device.uplink or device.last_uplink
|
|
36
|
+
if not uplink:
|
|
37
|
+
if classify_device_type(device) == "gateway":
|
|
38
|
+
return "Internet"
|
|
39
|
+
return "-"
|
|
40
|
+
name = uplink.name or uplink.mac or "Unknown"
|
|
41
|
+
if classify_device_type(device) == "gateway":
|
|
42
|
+
lowered = name.lower()
|
|
43
|
+
if lowered in {"unknown", "wan", "internet"}:
|
|
44
|
+
name = "Internet"
|
|
45
|
+
elif lowered.startswith(("eth", "wan")):
|
|
46
|
+
name = "Internet"
|
|
47
|
+
if uplink.port is not None:
|
|
48
|
+
return f"{name} (Port {uplink.port})"
|
|
49
|
+
return name
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _is_poe_active(port: PortInfo) -> bool:
|
|
53
|
+
return (port.poe_power or 0.0) > 0 or port.poe_good
|
|
@@ -4,212 +4,29 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from collections.abc import Iterable
|
|
6
6
|
|
|
7
|
+
from ..model.classify import client_display_name
|
|
8
|
+
from ..model.clients import (
|
|
9
|
+
_client_uplink_mac,
|
|
10
|
+
_client_uplink_port,
|
|
11
|
+
build_client_port_map,
|
|
12
|
+
client_matches_filters,
|
|
13
|
+
)
|
|
14
|
+
from ..model.edges import build_device_index, build_port_map
|
|
15
|
+
from ..model.helpers import normalize_mac
|
|
7
16
|
from ..model.lldp import LLDPEntry, local_port_label
|
|
8
|
-
from ..model.
|
|
9
|
-
from ..model.topology import Device, build_client_port_map, build_device_index, build_port_map
|
|
17
|
+
from ..model.topology import Device
|
|
10
18
|
from .device_ports_md import render_device_port_details
|
|
11
|
-
from .
|
|
19
|
+
from .device_summary import poe_summary, port_summary, uplink_summary
|
|
20
|
+
from .markdown_tables import escape_markdown, markdown_table_lines
|
|
12
21
|
from .templating import render_template
|
|
13
22
|
|
|
14
23
|
|
|
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
|
-
raw_name = _client_field(client, "name")
|
|
27
|
-
if isinstance(raw_name, str) and raw_name.strip():
|
|
28
|
-
return raw_name.strip()
|
|
29
|
-
preferred = _client_ucore_display_name(client)
|
|
30
|
-
if preferred:
|
|
31
|
-
return preferred
|
|
32
|
-
for key in ("hostname", "mac"):
|
|
33
|
-
value = _client_field(client, key)
|
|
34
|
-
if isinstance(value, str) and value.strip():
|
|
35
|
-
return value.strip()
|
|
36
|
-
return None
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def _client_uplink_mac(client: object) -> str | None:
|
|
40
|
-
for key in ("ap_mac", "sw_mac", "uplink_mac", "uplink_device_mac", "last_uplink_mac"):
|
|
41
|
-
value = _client_field(client, key)
|
|
42
|
-
if isinstance(value, str) and value.strip():
|
|
43
|
-
return value.strip()
|
|
44
|
-
for key in ("uplink", "last_uplink"):
|
|
45
|
-
nested = _client_field(client, key)
|
|
46
|
-
if isinstance(nested, dict):
|
|
47
|
-
value = nested.get("uplink_mac") or nested.get("uplink_device_mac")
|
|
48
|
-
if isinstance(value, str) and value.strip():
|
|
49
|
-
return value.strip()
|
|
50
|
-
return None
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def _client_uplink_port(client: object) -> int | None:
|
|
54
|
-
for value in _client_port_values(client):
|
|
55
|
-
parsed = _parse_port_value(value)
|
|
56
|
-
if parsed is not None:
|
|
57
|
-
return parsed
|
|
58
|
-
return None
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def _client_port_values(client: object) -> Iterable[object | None]:
|
|
62
|
-
for key in ("uplink_remote_port", "sw_port", "ap_port", "port_idx"):
|
|
63
|
-
yield _client_field(client, key)
|
|
64
|
-
for key in ("uplink", "last_uplink"):
|
|
65
|
-
nested = _client_field(client, key)
|
|
66
|
-
if isinstance(nested, dict):
|
|
67
|
-
for nested_key in ("uplink_remote_port", "port_idx"):
|
|
68
|
-
yield nested.get(nested_key)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def _parse_port_value(value: object | None) -> int | None:
|
|
72
|
-
if isinstance(value, int):
|
|
73
|
-
return value
|
|
74
|
-
if isinstance(value, str):
|
|
75
|
-
stripped = value.strip()
|
|
76
|
-
if stripped.isdigit():
|
|
77
|
-
return int(stripped)
|
|
78
|
-
return extract_port_number(stripped)
|
|
79
|
-
return None
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def _client_is_wired(client: object) -> bool:
|
|
83
|
-
return bool(_client_field(client, "is_wired"))
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def _client_unifi_flag(client: object) -> bool | None:
|
|
87
|
-
for key in ("is_unifi", "is_unifi_device", "is_ubnt", "is_uap", "is_managed"):
|
|
88
|
-
value = _client_field(client, key)
|
|
89
|
-
if isinstance(value, bool):
|
|
90
|
-
return value
|
|
91
|
-
if isinstance(value, int):
|
|
92
|
-
return value != 0
|
|
93
|
-
return None
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def _client_vendor(client: object) -> str | None:
|
|
97
|
-
for key in ("oui", "vendor", "vendor_name", "manufacturer", "manufacturer_name"):
|
|
98
|
-
value = _client_field(client, key)
|
|
99
|
-
if isinstance(value, str) and value.strip():
|
|
100
|
-
return value.strip()
|
|
101
|
-
return None
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def _client_ucore_info(client: object) -> dict[str, object] | None:
|
|
105
|
-
info = _client_field(client, "unifi_device_info_from_ucore")
|
|
106
|
-
if isinstance(info, dict):
|
|
107
|
-
return info
|
|
108
|
-
return None
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def _client_ucore_display_name(client: object) -> str | None:
|
|
112
|
-
ucore = _client_ucore_info(client)
|
|
113
|
-
if not ucore:
|
|
114
|
-
return None
|
|
115
|
-
for key in ("name", "computed_model", "product_model", "product_shortname"):
|
|
116
|
-
value = ucore.get(key)
|
|
117
|
-
if isinstance(value, str) and value.strip():
|
|
118
|
-
return value.strip()
|
|
119
|
-
return None
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
def _client_hostname_source(client: object) -> str | None:
|
|
123
|
-
value = _client_field(client, "hostname_source")
|
|
124
|
-
if isinstance(value, str) and value.strip():
|
|
125
|
-
return value.strip()
|
|
126
|
-
return None
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def _client_is_unifi(client: object) -> bool:
|
|
130
|
-
flag = _client_unifi_flag(client)
|
|
131
|
-
if flag is not None:
|
|
132
|
-
return flag
|
|
133
|
-
ucore = _client_ucore_info(client)
|
|
134
|
-
if ucore:
|
|
135
|
-
managed = ucore.get("managed")
|
|
136
|
-
if isinstance(managed, bool) and managed:
|
|
137
|
-
return True
|
|
138
|
-
if isinstance(ucore.get("product_line"), str) and ucore.get("product_line"):
|
|
139
|
-
return True
|
|
140
|
-
if isinstance(ucore.get("product_shortname"), str) and ucore.get("product_shortname"):
|
|
141
|
-
return True
|
|
142
|
-
for key in ("name", "computed_model", "product_model"):
|
|
143
|
-
value = ucore.get(key)
|
|
144
|
-
if isinstance(value, str) and value.strip():
|
|
145
|
-
return True
|
|
146
|
-
vendor = _client_vendor(client)
|
|
147
|
-
if not vendor:
|
|
148
|
-
return False
|
|
149
|
-
normalized = vendor.lower()
|
|
150
|
-
return "ubiquiti" in normalized or "unifi" in normalized
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
def _client_matches_mode(client: object, mode: str) -> bool:
|
|
154
|
-
wired = _client_is_wired(client)
|
|
155
|
-
if mode == "all":
|
|
156
|
-
return True
|
|
157
|
-
if mode == "wireless":
|
|
158
|
-
return not wired
|
|
159
|
-
return wired
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
def _client_matches_filters(client: object, *, client_mode: str, only_unifi: bool) -> bool:
|
|
163
|
-
if not _client_matches_mode(client, client_mode):
|
|
164
|
-
return False
|
|
165
|
-
if only_unifi and not _client_is_unifi(client):
|
|
166
|
-
return False
|
|
167
|
-
return True
|
|
168
|
-
|
|
169
|
-
|
|
170
24
|
def _lldp_sort_key(entry: LLDPEntry) -> tuple[int, str, str]:
|
|
171
25
|
port_label = local_port_label(entry) or ""
|
|
172
26
|
port_number = "".join(ch for ch in port_label if ch.isdigit())
|
|
173
27
|
return (int(port_number or 0), port_label, entry.port_id)
|
|
174
28
|
|
|
175
29
|
|
|
176
|
-
def _port_summary(device: Device) -> str:
|
|
177
|
-
ports = [port for port in device.port_table if port.port_idx is not None]
|
|
178
|
-
if not ports:
|
|
179
|
-
return "-"
|
|
180
|
-
total_ports = len(ports)
|
|
181
|
-
poe_capable = sum(1 for port in ports if port.port_poe or port.poe_enable)
|
|
182
|
-
poe_active = sum(1 for port in ports if device.poe_ports.get(port.port_idx or -1))
|
|
183
|
-
total_power = sum(port.poe_power or 0.0 for port in ports)
|
|
184
|
-
summary = f"Total {total_ports}, PoE {poe_capable} (active {poe_active})"
|
|
185
|
-
if total_power > 0:
|
|
186
|
-
summary = f"{summary}, {total_power:.2f}W"
|
|
187
|
-
return summary
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
def _poe_summary(device: Device) -> str:
|
|
191
|
-
ports = [port for port in device.port_table if port.port_idx is not None]
|
|
192
|
-
if not ports:
|
|
193
|
-
return "-"
|
|
194
|
-
poe_capable = sum(1 for port in ports if port.port_poe or port.poe_enable)
|
|
195
|
-
poe_active = sum(1 for port in ports if (port.poe_power or 0.0) > 0 or port.poe_good)
|
|
196
|
-
total_power = sum(port.poe_power or 0.0 for port in ports)
|
|
197
|
-
summary = f"{poe_capable} capable, {poe_active} active"
|
|
198
|
-
if total_power > 0:
|
|
199
|
-
summary = f"{summary}, {total_power:.2f}W"
|
|
200
|
-
return summary
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
def _uplink_summary(device: Device) -> str:
|
|
204
|
-
uplink = device.uplink or device.last_uplink
|
|
205
|
-
if not uplink:
|
|
206
|
-
return "-"
|
|
207
|
-
name = uplink.name or uplink.mac or "Unknown"
|
|
208
|
-
if uplink.port is not None:
|
|
209
|
-
return f"{name} (Port {uplink.port})"
|
|
210
|
-
return name
|
|
211
|
-
|
|
212
|
-
|
|
213
30
|
def _client_summary(
|
|
214
31
|
device: Device, client_rows: dict[str, list[tuple[str, str | None]]]
|
|
215
32
|
) -> tuple[str, str]:
|
|
@@ -232,16 +49,16 @@ def _details_table_lines(
|
|
|
232
49
|
wired_count, client_sample = _client_summary(device, client_rows)
|
|
233
50
|
client_label = f"Clients ({client_mode})"
|
|
234
51
|
rows = [
|
|
235
|
-
["Model",
|
|
236
|
-
["Type",
|
|
237
|
-
["IP",
|
|
238
|
-
["MAC",
|
|
239
|
-
["Firmware",
|
|
240
|
-
["Uplink",
|
|
241
|
-
["Ports",
|
|
242
|
-
["PoE",
|
|
243
|
-
[client_label,
|
|
244
|
-
["Client examples",
|
|
52
|
+
["Model", escape_markdown(device.model_name or device.type or "-")],
|
|
53
|
+
["Type", escape_markdown(device.type or "-")],
|
|
54
|
+
["IP", escape_markdown(device.ip or "-")],
|
|
55
|
+
["MAC", escape_markdown(device.mac or "-")],
|
|
56
|
+
["Firmware", escape_markdown(device.version or "-")],
|
|
57
|
+
["Uplink", escape_markdown(uplink_summary(device))],
|
|
58
|
+
["Ports", escape_markdown(port_summary(device))],
|
|
59
|
+
["PoE", escape_markdown(poe_summary(device))],
|
|
60
|
+
[client_label, escape_markdown(wired_count)],
|
|
61
|
+
["Client examples", escape_markdown(client_sample)],
|
|
245
62
|
]
|
|
246
63
|
lines = ["### Details", ""]
|
|
247
64
|
lines.extend(markdown_table_lines(["Field", "Value"], rows))
|
|
@@ -255,7 +72,7 @@ def _lldp_rows(
|
|
|
255
72
|
rows: list[list[str]] = []
|
|
256
73
|
for entry in sorted(entries, key=_lldp_sort_key):
|
|
257
74
|
local_label = local_port_label(entry) or "?"
|
|
258
|
-
peer_name = device_index.get(
|
|
75
|
+
peer_name = device_index.get(normalize_mac(entry.chassis_id), "")
|
|
259
76
|
peer_port = entry.port_id or "?"
|
|
260
77
|
port_desc = entry.port_desc or ""
|
|
261
78
|
rows.append(
|
|
@@ -270,13 +87,6 @@ def _lldp_rows(
|
|
|
270
87
|
return rows
|
|
271
88
|
|
|
272
89
|
|
|
273
|
-
def _escape_cell(value: str) -> str:
|
|
274
|
-
escaped = value.replace("\\", "\\\\")
|
|
275
|
-
for char in ("|", "[", "]", "*", "_", "`", "<", ">"):
|
|
276
|
-
escaped = escaped.replace(char, f"\\{char}")
|
|
277
|
-
return escaped
|
|
278
|
-
|
|
279
|
-
|
|
280
90
|
def _client_rows(
|
|
281
91
|
clients: Iterable[object],
|
|
282
92
|
device_index: dict[str, str],
|
|
@@ -287,13 +97,13 @@ def _client_rows(
|
|
|
287
97
|
) -> dict[str, list[tuple[str, str | None]]]:
|
|
288
98
|
rows_by_device: dict[str, list[tuple[str, str | None]]] = {}
|
|
289
99
|
for client in clients:
|
|
290
|
-
if not
|
|
100
|
+
if not client_matches_filters(client, client_mode=client_mode, only_unifi=only_unifi):
|
|
291
101
|
continue
|
|
292
|
-
name =
|
|
102
|
+
name = client_display_name(client)
|
|
293
103
|
uplink_mac = _client_uplink_mac(client)
|
|
294
104
|
if not name or not uplink_mac:
|
|
295
105
|
continue
|
|
296
|
-
device_name = device_index.get(
|
|
106
|
+
device_name = device_index.get(normalize_mac(uplink_mac))
|
|
297
107
|
if not device_name:
|
|
298
108
|
continue
|
|
299
109
|
port_label = None
|
|
@@ -370,7 +180,7 @@ def _render_device_lldp_section(
|
|
|
370
180
|
markdown_table_lines(
|
|
371
181
|
["Local Port", "Neighbor", "Neighbor Port", "Chassis ID", "Port Description"],
|
|
372
182
|
_lldp_rows(device.lldp_info, device_index),
|
|
373
|
-
escape=
|
|
183
|
+
escape=escape_markdown,
|
|
374
184
|
)
|
|
375
185
|
).rstrip()
|
|
376
186
|
else:
|
|
@@ -387,7 +197,7 @@ def _render_device_lldp_section(
|
|
|
387
197
|
markdown_table_lines(
|
|
388
198
|
["Client", "Port"],
|
|
389
199
|
[
|
|
390
|
-
[
|
|
200
|
+
[escape_markdown(client_name), escape_markdown(port_label or "-")]
|
|
391
201
|
for client_name, port_label in rows
|
|
392
202
|
],
|
|
393
203
|
)
|
|
@@ -396,7 +206,7 @@ def _render_device_lldp_section(
|
|
|
396
206
|
).rstrip()
|
|
397
207
|
else:
|
|
398
208
|
clients_section = "\n".join(
|
|
399
|
-
["### Clients", *[f"- {
|
|
209
|
+
["### Clients", *[f"- {escape_markdown(name)}" for name, _ in rows]]
|
|
400
210
|
).rstrip()
|
|
401
211
|
return render_template(
|
|
402
212
|
"lldp_device_section.md.j2",
|
|
@@ -5,6 +5,14 @@ from __future__ import annotations
|
|
|
5
5
|
from collections.abc import Callable, Iterable
|
|
6
6
|
|
|
7
7
|
|
|
8
|
+
def escape_markdown(value: str) -> str:
|
|
9
|
+
"""Escape characters that have special meaning in Markdown table cells."""
|
|
10
|
+
escaped = value.replace("\\", "\\\\")
|
|
11
|
+
for char in ("|", "[", "]", "*", "_", "`", "<", ">"):
|
|
12
|
+
escaped = escaped.replace(char, f"\\{char}")
|
|
13
|
+
return escaped
|
|
14
|
+
|
|
15
|
+
|
|
8
16
|
def markdown_table_lines(
|
|
9
17
|
headers: list[str],
|
|
10
18
|
rows: Iterable[Iterable[str]],
|
|
@@ -87,6 +87,14 @@ def _render_group_sections(
|
|
|
87
87
|
lines.append(" end")
|
|
88
88
|
|
|
89
89
|
|
|
90
|
+
def _format_vlan_suffix(active_vlans: tuple[int, ...]) -> str:
|
|
91
|
+
"""Format VLAN suffix for edge labels."""
|
|
92
|
+
if not active_vlans:
|
|
93
|
+
return ""
|
|
94
|
+
vlan_str = ",".join(f"V{v}" for v in sorted(active_vlans))
|
|
95
|
+
return f" [{vlan_str}]"
|
|
96
|
+
|
|
97
|
+
|
|
90
98
|
def _render_edge_lines(
|
|
91
99
|
lines: list[str],
|
|
92
100
|
edges: list[Edge],
|
|
@@ -103,8 +111,9 @@ def _render_edge_lines(
|
|
|
103
111
|
else:
|
|
104
112
|
left = id_map[edge.left]
|
|
105
113
|
right = id_map[edge.right]
|
|
106
|
-
|
|
107
|
-
|
|
114
|
+
vlan_suffix = _format_vlan_suffix(edge.active_vlans)
|
|
115
|
+
if edge.label or vlan_suffix:
|
|
116
|
+
label = _escape(f"{edge.label or ''}{vlan_suffix}".strip())
|
|
108
117
|
lines.append(f' {left} ---|"{label}"| {right};')
|
|
109
118
|
else:
|
|
110
119
|
lines.append(f" {left} --- {right};")
|
|
@@ -7,7 +7,8 @@ from dataclasses import dataclass
|
|
|
7
7
|
from datetime import datetime
|
|
8
8
|
from zoneinfo import ZoneInfo
|
|
9
9
|
|
|
10
|
-
from ..model.
|
|
10
|
+
from ..model.clients import build_node_type_map
|
|
11
|
+
from ..model.topology import ClientPortMap, Device, PortMap
|
|
11
12
|
from .device_ports_md import render_device_port_overview
|
|
12
13
|
from .mermaid import render_legend, render_legend_compact, render_mermaid
|
|
13
14
|
from .mermaid_theme import MermaidTheme
|