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 @@
|
|
|
1
|
+
"""Package module."""
|
|
@@ -0,0 +1,492 @@
|
|
|
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, PortInfo, PortMap, classify_device_type
|
|
10
|
+
from .markdown_tables import markdown_table_lines
|
|
11
|
+
from .templating import render_template
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def render_device_port_overview(
|
|
15
|
+
devices: list[Device],
|
|
16
|
+
port_map: PortMap,
|
|
17
|
+
*,
|
|
18
|
+
client_ports: ClientPortMap | None = None,
|
|
19
|
+
) -> str:
|
|
20
|
+
gateways = _collect_devices_by_type(devices, "gateway")
|
|
21
|
+
switches = _collect_devices_by_type(devices, "switch")
|
|
22
|
+
sections: list[str] = []
|
|
23
|
+
if gateways:
|
|
24
|
+
sections.append(
|
|
25
|
+
render_template(
|
|
26
|
+
"markdown_section.md.j2",
|
|
27
|
+
title="Gateways",
|
|
28
|
+
body=_render_device_group(gateways, port_map, client_ports),
|
|
29
|
+
).rstrip()
|
|
30
|
+
)
|
|
31
|
+
if switches:
|
|
32
|
+
sections.append(
|
|
33
|
+
render_template(
|
|
34
|
+
"markdown_section.md.j2",
|
|
35
|
+
title="Switches",
|
|
36
|
+
body=_render_device_group(switches, port_map, client_ports),
|
|
37
|
+
).rstrip()
|
|
38
|
+
)
|
|
39
|
+
return "\n\n".join(section for section in sections if section).rstrip() + "\n"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _collect_devices_by_type(devices: list[Device], desired_type: str) -> list[Device]:
|
|
43
|
+
return sorted(
|
|
44
|
+
[device for device in devices if classify_device_type(device) == desired_type],
|
|
45
|
+
key=lambda item: item.name.lower(),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _render_device_group(
|
|
50
|
+
devices: list[Device],
|
|
51
|
+
port_map: PortMap,
|
|
52
|
+
client_ports: ClientPortMap | None,
|
|
53
|
+
) -> str:
|
|
54
|
+
blocks: list[str] = []
|
|
55
|
+
for device in devices:
|
|
56
|
+
blocks.append(
|
|
57
|
+
render_template(
|
|
58
|
+
"device_port_block.md.j2",
|
|
59
|
+
device_name=device.name,
|
|
60
|
+
details="\n".join(_render_device_details(device)).rstrip(),
|
|
61
|
+
ports="\n".join(_render_device_ports(device, port_map, client_ports)).rstrip(),
|
|
62
|
+
).rstrip()
|
|
63
|
+
)
|
|
64
|
+
return "\n\n".join(block for block in blocks if block)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def render_device_port_details(
|
|
68
|
+
device: Device,
|
|
69
|
+
port_map: PortMap,
|
|
70
|
+
*,
|
|
71
|
+
client_ports: ClientPortMap | None = None,
|
|
72
|
+
) -> str:
|
|
73
|
+
lines = _render_device_details(device)
|
|
74
|
+
lines.extend(_render_device_ports(device, port_map, client_ports))
|
|
75
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _render_device_ports(
|
|
79
|
+
device: Device,
|
|
80
|
+
port_map: PortMap,
|
|
81
|
+
client_ports: ClientPortMap | None,
|
|
82
|
+
) -> list[str]:
|
|
83
|
+
rows = _build_port_rows(device, port_map, client_ports)
|
|
84
|
+
table_rows = [
|
|
85
|
+
[
|
|
86
|
+
_escape_cell(port_label),
|
|
87
|
+
_escape_cell(connected or "-"),
|
|
88
|
+
_escape_cell(speed),
|
|
89
|
+
_escape_cell(poe_state),
|
|
90
|
+
_escape_cell(power),
|
|
91
|
+
]
|
|
92
|
+
for port_label, connected, speed, poe_state, power in rows
|
|
93
|
+
]
|
|
94
|
+
lines = ["#### Ports", ""]
|
|
95
|
+
lines.extend(
|
|
96
|
+
markdown_table_lines(
|
|
97
|
+
["Port", "Connected", "Speed", "PoE", "Power"],
|
|
98
|
+
table_rows,
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
return lines
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _build_port_rows(
|
|
105
|
+
device: Device,
|
|
106
|
+
port_map: PortMap,
|
|
107
|
+
client_ports: ClientPortMap | None,
|
|
108
|
+
) -> list[tuple[str, str, str, str, str]]:
|
|
109
|
+
connections = _device_port_connections(device.name, port_map)
|
|
110
|
+
client_connections = _device_client_connections(device.name, client_ports)
|
|
111
|
+
aggregated = _aggregate_ports(device.port_table)
|
|
112
|
+
aggregated_indices = {
|
|
113
|
+
port.port_idx
|
|
114
|
+
for ports in aggregated.values()
|
|
115
|
+
for port in ports
|
|
116
|
+
if getattr(port, "port_idx", None) is not None
|
|
117
|
+
}
|
|
118
|
+
rows: list[tuple[tuple[int, int], tuple[str, str, str, str, str]]] = []
|
|
119
|
+
seen_ports: set[int] = set()
|
|
120
|
+
for port in sorted(device.port_table, key=_port_sort_key):
|
|
121
|
+
if port.port_idx in aggregated_indices:
|
|
122
|
+
port_idx = _port_index(port.port_idx, port.name)
|
|
123
|
+
if port_idx is not None:
|
|
124
|
+
seen_ports.add(port_idx)
|
|
125
|
+
continue
|
|
126
|
+
port_idx = _port_index(port.port_idx, port.name)
|
|
127
|
+
if port_idx is not None:
|
|
128
|
+
seen_ports.add(port_idx)
|
|
129
|
+
port_label = _format_port_label(port_idx, port.name)
|
|
130
|
+
connected = _format_connections(
|
|
131
|
+
device.name,
|
|
132
|
+
port_idx,
|
|
133
|
+
connections,
|
|
134
|
+
client_connections,
|
|
135
|
+
port_map,
|
|
136
|
+
)
|
|
137
|
+
rows.append(
|
|
138
|
+
(
|
|
139
|
+
(0, port_idx or 10_000),
|
|
140
|
+
(
|
|
141
|
+
port_label,
|
|
142
|
+
connected,
|
|
143
|
+
_format_speed(port.speed),
|
|
144
|
+
_format_poe_state(port),
|
|
145
|
+
_format_poe_power(port.poe_power),
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
for _group_id, group_ports in aggregated.items():
|
|
150
|
+
group_label = _format_aggregate_label(group_ports)
|
|
151
|
+
group_sort = _aggregate_sort_key(group_ports)
|
|
152
|
+
group_connections = _format_aggregate_connections(
|
|
153
|
+
device.name,
|
|
154
|
+
group_ports,
|
|
155
|
+
connections,
|
|
156
|
+
client_connections,
|
|
157
|
+
port_map,
|
|
158
|
+
)
|
|
159
|
+
rows.append(
|
|
160
|
+
(
|
|
161
|
+
(0, group_sort),
|
|
162
|
+
(
|
|
163
|
+
group_label,
|
|
164
|
+
group_connections,
|
|
165
|
+
_format_aggregate_speed(group_ports),
|
|
166
|
+
_format_aggregate_poe_state(group_ports),
|
|
167
|
+
_format_aggregate_power(group_ports),
|
|
168
|
+
),
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
for port_idx in sorted(connections):
|
|
172
|
+
if port_idx in seen_ports:
|
|
173
|
+
continue
|
|
174
|
+
port_label = _format_port_label(port_idx, None)
|
|
175
|
+
connected = _format_connections(
|
|
176
|
+
device.name,
|
|
177
|
+
port_idx,
|
|
178
|
+
connections,
|
|
179
|
+
client_connections,
|
|
180
|
+
port_map,
|
|
181
|
+
)
|
|
182
|
+
rows.append(((2, port_idx), (port_label, connected, "-", "-", "-")))
|
|
183
|
+
return [row for _key, row in sorted(rows, key=lambda item: item[0])]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _device_port_connections(device_name: str, port_map: PortMap) -> dict[int, list[str]]:
|
|
187
|
+
connections: dict[int, list[str]] = defaultdict(list)
|
|
188
|
+
for (src, dst), label in port_map.items():
|
|
189
|
+
if src != device_name:
|
|
190
|
+
continue
|
|
191
|
+
port_idx = extract_port_number(label or "")
|
|
192
|
+
if port_idx is None:
|
|
193
|
+
continue
|
|
194
|
+
connections[port_idx].append(dst)
|
|
195
|
+
return connections
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _device_client_connections(
|
|
199
|
+
device_name: str, client_ports: ClientPortMap | None
|
|
200
|
+
) -> dict[int, list[str]]:
|
|
201
|
+
if not client_ports:
|
|
202
|
+
return {}
|
|
203
|
+
rows = client_ports.get(device_name, [])
|
|
204
|
+
connections: dict[int, list[str]] = defaultdict(list)
|
|
205
|
+
for port_idx, name in rows:
|
|
206
|
+
connections[port_idx].append(name)
|
|
207
|
+
return connections
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _format_connections(
|
|
211
|
+
device_name: str,
|
|
212
|
+
port_idx: int | None,
|
|
213
|
+
connections: dict[int, list[str]],
|
|
214
|
+
client_connections: dict[int, list[str]],
|
|
215
|
+
port_map: PortMap,
|
|
216
|
+
) -> str:
|
|
217
|
+
if port_idx is None:
|
|
218
|
+
return ""
|
|
219
|
+
peers = connections.get(port_idx, [])
|
|
220
|
+
clients = client_connections.get(port_idx, [])
|
|
221
|
+
if not peers and not clients:
|
|
222
|
+
return ""
|
|
223
|
+
peer_entries: list[str] = []
|
|
224
|
+
for peer in sorted(peers, key=str.lower):
|
|
225
|
+
peer_label = port_map.get((peer, device_name))
|
|
226
|
+
if peer_label:
|
|
227
|
+
peer_entries.append(f"{peer} ({peer_label})")
|
|
228
|
+
else:
|
|
229
|
+
peer_entries.append(peer)
|
|
230
|
+
peer_text = ", ".join(peer_entries)
|
|
231
|
+
client_text = _format_client_connections(clients)
|
|
232
|
+
if peer_text and client_text:
|
|
233
|
+
return f"{peer_text}<br/>{client_text}"
|
|
234
|
+
return peer_text or client_text
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _format_port_label(port_idx: int | None, name: str | None) -> str:
|
|
238
|
+
if name and name.strip():
|
|
239
|
+
normalized = name.strip()
|
|
240
|
+
if port_idx is None:
|
|
241
|
+
return normalized
|
|
242
|
+
if normalized.lower() != f"port {port_idx}".lower():
|
|
243
|
+
return normalized
|
|
244
|
+
if port_idx is None:
|
|
245
|
+
return "Port ?"
|
|
246
|
+
return f"Port {port_idx}"
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _format_speed(speed: int | None) -> str:
|
|
250
|
+
if speed is None or speed <= 0:
|
|
251
|
+
return "-"
|
|
252
|
+
if speed >= 1000:
|
|
253
|
+
if speed % 1000 == 0:
|
|
254
|
+
return f"{speed // 1000}G"
|
|
255
|
+
return f"{speed / 1000:.1f}G"
|
|
256
|
+
return f"{speed}M"
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _format_poe_state(port: object) -> str:
|
|
260
|
+
poe_power = getattr(port, "poe_power", None)
|
|
261
|
+
poe_good = getattr(port, "poe_good", False)
|
|
262
|
+
poe_enable = getattr(port, "poe_enable", False)
|
|
263
|
+
port_poe = getattr(port, "port_poe", False)
|
|
264
|
+
if (poe_power or 0.0) > 0 or poe_good:
|
|
265
|
+
return "active"
|
|
266
|
+
if port_poe or poe_enable:
|
|
267
|
+
if not poe_enable:
|
|
268
|
+
return "disabled"
|
|
269
|
+
return "capable"
|
|
270
|
+
return "-"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _format_poe_power(power: float | None) -> str:
|
|
274
|
+
if power is None or power <= 0:
|
|
275
|
+
return "-"
|
|
276
|
+
return f"{power:.2f}W"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _port_index(port_idx: int | None, name: str | None) -> int | None:
|
|
280
|
+
if port_idx is not None:
|
|
281
|
+
return port_idx
|
|
282
|
+
if name:
|
|
283
|
+
return extract_port_number(name)
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _port_sort_key(port: object) -> tuple[int, str]:
|
|
288
|
+
port_idx = _port_index(getattr(port, "port_idx", None), getattr(port, "name", None))
|
|
289
|
+
if port_idx is not None:
|
|
290
|
+
return (0, f"{port_idx:04d}")
|
|
291
|
+
name = getattr(port, "name", "") or ""
|
|
292
|
+
return (1, name.lower())
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _escape_cell(value: str) -> str:
|
|
296
|
+
return value.replace("|", "\\|")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _render_device_details(device: Device) -> list[str]:
|
|
300
|
+
lines = [
|
|
301
|
+
"#### Details",
|
|
302
|
+
"",
|
|
303
|
+
"| Field | Value |",
|
|
304
|
+
"| --- | --- |",
|
|
305
|
+
f"| Model | {_escape_cell(_device_model_label(device))} |",
|
|
306
|
+
f"| Type | {_escape_cell(device.type or '-')} |",
|
|
307
|
+
f"| IP | {_escape_cell(device.ip or '-')} |",
|
|
308
|
+
f"| MAC | {_escape_cell(device.mac or '-')} |",
|
|
309
|
+
f"| Firmware | {_escape_cell(device.version or '-')} |",
|
|
310
|
+
f"| Uplink | {_escape_cell(_uplink_summary(device))} |",
|
|
311
|
+
f"| Ports | {_escape_cell(_port_summary(device))} |",
|
|
312
|
+
f"| PoE | {_escape_cell(_poe_summary(device))} |",
|
|
313
|
+
"",
|
|
314
|
+
]
|
|
315
|
+
return lines
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _port_summary(device: Device) -> str:
|
|
319
|
+
ports = [port for port in device.port_table if port.port_idx is not None]
|
|
320
|
+
if not ports:
|
|
321
|
+
return "-"
|
|
322
|
+
total_ports = len(ports)
|
|
323
|
+
active_ports = sum(1 for port in ports if (port.speed or 0) > 0)
|
|
324
|
+
return f"{total_ports} total, {active_ports} active"
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _poe_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
|
+
poe_capable = sum(1 for port in ports if port.port_poe or port.poe_enable)
|
|
332
|
+
poe_active = sum(1 for port in ports if _format_poe_state(port) == "active")
|
|
333
|
+
total_power = sum(port.poe_power or 0.0 for port in ports)
|
|
334
|
+
summary = f"{poe_capable} capable, {poe_active} active"
|
|
335
|
+
if total_power > 0:
|
|
336
|
+
summary = f"{summary}, {total_power:.2f}W"
|
|
337
|
+
return summary
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _uplink_summary(device: Device) -> str:
|
|
341
|
+
uplink = device.uplink or device.last_uplink
|
|
342
|
+
if not uplink:
|
|
343
|
+
if classify_device_type(device) == "gateway":
|
|
344
|
+
return "Internet"
|
|
345
|
+
return "-"
|
|
346
|
+
name = uplink.name or uplink.mac or "Unknown"
|
|
347
|
+
if classify_device_type(device) == "gateway":
|
|
348
|
+
lowered = name.lower()
|
|
349
|
+
if lowered in {"unknown", "wan", "internet"}:
|
|
350
|
+
name = "Internet"
|
|
351
|
+
elif lowered.startswith(("eth", "wan")):
|
|
352
|
+
name = "Internet"
|
|
353
|
+
if uplink.port is not None:
|
|
354
|
+
return f"{name} (Port {uplink.port})"
|
|
355
|
+
return name
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _device_model_label(device: Device) -> str:
|
|
359
|
+
if device.model_name:
|
|
360
|
+
return device.model_name
|
|
361
|
+
if device.model:
|
|
362
|
+
return device.model
|
|
363
|
+
return device.type or "-"
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _format_client_connections(clients: list[str]) -> str:
|
|
367
|
+
if not clients:
|
|
368
|
+
return ""
|
|
369
|
+
if len(clients) == 1:
|
|
370
|
+
return f"{clients[0]} (client)"
|
|
371
|
+
items = "".join(f"<li>{_escape_html(name)}</li>" for name in clients)
|
|
372
|
+
return f'<ul class="unifi-port-clients">{items}</ul>'
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _aggregate_base_groups(port_table: list[PortInfo]) -> dict[str, list[PortInfo]]:
|
|
376
|
+
groups: dict[str, list[PortInfo]] = defaultdict(list)
|
|
377
|
+
for port in port_table:
|
|
378
|
+
group = getattr(port, "aggregation_group", None)
|
|
379
|
+
if group:
|
|
380
|
+
groups[str(group)].append(port)
|
|
381
|
+
continue
|
|
382
|
+
if _looks_like_lag(port):
|
|
383
|
+
port_idx = getattr(port, "port_idx", None)
|
|
384
|
+
if port_idx is not None:
|
|
385
|
+
groups[f"lag-{port_idx}"].append(port)
|
|
386
|
+
return groups
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _extend_singleton_groups(
|
|
390
|
+
groups: dict[str, list[PortInfo]],
|
|
391
|
+
port_table: list[PortInfo],
|
|
392
|
+
) -> None:
|
|
393
|
+
if not groups:
|
|
394
|
+
return
|
|
395
|
+
port_by_idx: dict[int, PortInfo] = {
|
|
396
|
+
port.port_idx: port for port in port_table if port.port_idx is not None
|
|
397
|
+
}
|
|
398
|
+
for group_id, group_ports in list(groups.items()):
|
|
399
|
+
if len(group_ports) > 1:
|
|
400
|
+
continue
|
|
401
|
+
lone_port = group_ports[0]
|
|
402
|
+
if not _looks_like_lag(lone_port):
|
|
403
|
+
continue
|
|
404
|
+
port_idx = lone_port.port_idx
|
|
405
|
+
if port_idx is None:
|
|
406
|
+
continue
|
|
407
|
+
candidates: list[PortInfo] = []
|
|
408
|
+
for neighbor in (port_idx - 1, port_idx + 1):
|
|
409
|
+
port = port_by_idx.get(neighbor)
|
|
410
|
+
if port and not getattr(port, "aggregation_group", None):
|
|
411
|
+
if getattr(port, "speed", None) == getattr(lone_port, "speed", None):
|
|
412
|
+
candidates.append(port)
|
|
413
|
+
if candidates:
|
|
414
|
+
groups[group_id].extend(candidates)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _aggregate_ports(port_table: list[PortInfo]) -> dict[str, list[PortInfo]]:
|
|
418
|
+
groups = _aggregate_base_groups(port_table)
|
|
419
|
+
_extend_singleton_groups(groups, port_table)
|
|
420
|
+
return groups
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _looks_like_lag(port: PortInfo) -> bool:
|
|
424
|
+
name = (getattr(port, "name", "") or "").lower()
|
|
425
|
+
ifname = (getattr(port, "ifname", "") or "").lower()
|
|
426
|
+
return "lag" in name or "lag" in ifname or "aggregate" in name
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _format_aggregate_label(group_ports: list[PortInfo]) -> str:
|
|
430
|
+
ports = sorted([int(p.port_idx) for p in group_ports if p.port_idx is not None])
|
|
431
|
+
if ports:
|
|
432
|
+
if len(ports) == 1:
|
|
433
|
+
return f"Port {ports[0]} (LAG)"
|
|
434
|
+
if ports == list(range(ports[0], ports[-1] + 1)):
|
|
435
|
+
return f"Port {ports[0]}-{ports[-1]} (LAG)"
|
|
436
|
+
return "Ports " + "+".join(str(port) for port in ports) + " (LAG)"
|
|
437
|
+
return "Aggregated ports"
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _aggregate_sort_key(group_ports: list[PortInfo]) -> int:
|
|
441
|
+
ports = sorted([int(p.port_idx) for p in group_ports if p.port_idx is not None])
|
|
442
|
+
return ports[0] if ports else 10_000
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _format_aggregate_connections(
|
|
446
|
+
device_name: str,
|
|
447
|
+
group_ports: list[PortInfo],
|
|
448
|
+
connections: dict[int, list[str]],
|
|
449
|
+
client_connections: dict[int, list[str]],
|
|
450
|
+
port_map: PortMap,
|
|
451
|
+
) -> str:
|
|
452
|
+
rendered: list[str] = []
|
|
453
|
+
for port in group_ports:
|
|
454
|
+
port_idx = _port_index(getattr(port, "port_idx", None), getattr(port, "name", None))
|
|
455
|
+
if port_idx is None:
|
|
456
|
+
continue
|
|
457
|
+
text = _format_connections(
|
|
458
|
+
device_name,
|
|
459
|
+
port_idx,
|
|
460
|
+
connections,
|
|
461
|
+
client_connections,
|
|
462
|
+
port_map,
|
|
463
|
+
)
|
|
464
|
+
if text:
|
|
465
|
+
rendered.append(text)
|
|
466
|
+
return ", ".join([item for item in rendered if item])
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _format_aggregate_speed(group_ports: list[PortInfo]) -> str:
|
|
470
|
+
speeds = {getattr(port, "speed", None) for port in group_ports}
|
|
471
|
+
speeds.discard(None)
|
|
472
|
+
if not speeds:
|
|
473
|
+
return "-"
|
|
474
|
+
if len(speeds) == 1:
|
|
475
|
+
return _format_speed(next(iter(speeds)))
|
|
476
|
+
return "mixed"
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _format_aggregate_poe_state(group_ports: list[PortInfo]) -> str:
|
|
480
|
+
states = {_format_poe_state(port) for port in group_ports}
|
|
481
|
+
if "active" in states:
|
|
482
|
+
return "active"
|
|
483
|
+
if "disabled" in states:
|
|
484
|
+
return "disabled"
|
|
485
|
+
if "capable" in states:
|
|
486
|
+
return "capable"
|
|
487
|
+
return "-"
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _format_aggregate_power(group_ports: list[PortInfo]) -> str:
|
|
491
|
+
total = sum(getattr(port, "poe_power", 0.0) or 0.0 for port in group_ports)
|
|
492
|
+
return _format_poe_power(total)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Legend rendering helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .mermaid import render_legend, render_legend_compact
|
|
6
|
+
from .mermaid_theme import MermaidTheme
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def resolve_legend_style(*, format_name: str, legend_style: str) -> str:
|
|
10
|
+
if legend_style == "auto":
|
|
11
|
+
return "compact" if format_name == "mkdocs" else "diagram"
|
|
12
|
+
return legend_style
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def render_legend_only(
|
|
16
|
+
*,
|
|
17
|
+
legend_style: str,
|
|
18
|
+
legend_scale: float,
|
|
19
|
+
markdown: bool,
|
|
20
|
+
theme: MermaidTheme,
|
|
21
|
+
) -> str:
|
|
22
|
+
if legend_style == "compact":
|
|
23
|
+
content = "# Legend\n\n" + render_legend_compact(theme=theme)
|
|
24
|
+
else:
|
|
25
|
+
content = render_legend(theme=theme, legend_scale=legend_scale)
|
|
26
|
+
if markdown:
|
|
27
|
+
content = f"""```mermaid
|
|
28
|
+
{content}```
|
|
29
|
+
"""
|
|
30
|
+
return content
|