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.
@@ -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
- lines = [f"## {device.name}"]
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:{theme.poe_link_width}px,"
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:{theme.standard_link_width}px,"
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"