unifi-network-maps 1.4.1__py3-none-any.whl → 1.4.2__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 +266 -24
- unifi_network_maps/cli/main.py +352 -107
- unifi_network_maps/io/debug.py +15 -5
- unifi_network_maps/io/export.py +20 -1
- unifi_network_maps/model/topology.py +125 -71
- unifi_network_maps/render/device_ports_md.py +31 -18
- unifi_network_maps/render/lldp_md.py +87 -43
- unifi_network_maps/render/mermaid.py +96 -49
- unifi_network_maps/render/svg.py +614 -318
- {unifi_network_maps-1.4.1.dist-info → unifi_network_maps-1.4.2.dist-info}/METADATA +57 -82
- {unifi_network_maps-1.4.1.dist-info → unifi_network_maps-1.4.2.dist-info}/RECORD +16 -16
- {unifi_network_maps-1.4.1.dist-info → unifi_network_maps-1.4.2.dist-info}/WHEEL +0 -0
- {unifi_network_maps-1.4.1.dist-info → unifi_network_maps-1.4.2.dist-info}/entry_points.txt +0 -0
- {unifi_network_maps-1.4.1.dist-info → unifi_network_maps-1.4.2.dist-info}/licenses/LICENSE +0 -0
- {unifi_network_maps-1.4.1.dist-info → unifi_network_maps-1.4.2.dist-info}/top_level.txt +0 -0
unifi_network_maps/cli/main.py
CHANGED
|
@@ -4,6 +4,9 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
6
|
import logging
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from zoneinfo import ZoneInfo
|
|
7
10
|
|
|
8
11
|
from ..adapters.config import Config
|
|
9
12
|
from ..adapters.unifi import fetch_clients, fetch_devices
|
|
@@ -14,6 +17,7 @@ from ..model.topology import (
|
|
|
14
17
|
ClientPortMap,
|
|
15
18
|
Device,
|
|
16
19
|
PortMap,
|
|
20
|
+
TopologyResult,
|
|
17
21
|
build_client_edges,
|
|
18
22
|
build_client_port_map,
|
|
19
23
|
build_device_index,
|
|
@@ -29,7 +33,7 @@ from ..render.mermaid import render_legend, render_legend_compact, render_mermai
|
|
|
29
33
|
from ..render.mermaid_theme import MermaidTheme
|
|
30
34
|
from ..render.svg import SvgOptions, render_svg
|
|
31
35
|
from ..render.svg_theme import SvgTheme
|
|
32
|
-
from ..render.theme import resolve_themes
|
|
36
|
+
from ..render.theme import load_theme, resolve_themes
|
|
33
37
|
|
|
34
38
|
logger = logging.getLogger(__name__)
|
|
35
39
|
|
|
@@ -120,6 +124,11 @@ def _add_functional_args(parser: argparse._ArgumentGroup) -> None:
|
|
|
120
124
|
parser.add_argument(
|
|
121
125
|
"--only-unifi", action="store_true", help="Only include neighbors that are UniFi devices"
|
|
122
126
|
)
|
|
127
|
+
parser.add_argument(
|
|
128
|
+
"--no-cache",
|
|
129
|
+
action="store_true",
|
|
130
|
+
help="Disable UniFi API cache reads and writes",
|
|
131
|
+
)
|
|
123
132
|
|
|
124
133
|
|
|
125
134
|
def _add_mermaid_args(parser: argparse._ArgumentGroup) -> None:
|
|
@@ -173,6 +182,16 @@ def _add_general_render_args(parser: argparse._ArgumentGroup) -> None:
|
|
|
173
182
|
action="store_true",
|
|
174
183
|
help="For mkdocs output, write sidebar legend assets next to the output file",
|
|
175
184
|
)
|
|
185
|
+
parser.add_argument(
|
|
186
|
+
"--mkdocs-dual-theme",
|
|
187
|
+
action="store_true",
|
|
188
|
+
help="Render light/dark Mermaid blocks for MkDocs Material theme switching",
|
|
189
|
+
)
|
|
190
|
+
parser.add_argument(
|
|
191
|
+
"--mkdocs-timestamp-zone",
|
|
192
|
+
default="Europe/Amsterdam",
|
|
193
|
+
help="Timezone for mkdocs generated timestamp (use 'off' to disable)",
|
|
194
|
+
)
|
|
176
195
|
|
|
177
196
|
|
|
178
197
|
def _add_debug_args(parser: argparse._ArgumentGroup) -> None:
|
|
@@ -236,7 +255,9 @@ def _load_devices_data(
|
|
|
236
255
|
if raw_devices_override is None:
|
|
237
256
|
if config is None:
|
|
238
257
|
raise ValueError("Config required to fetch devices")
|
|
239
|
-
raw_devices = list(
|
|
258
|
+
raw_devices = list(
|
|
259
|
+
fetch_devices(config, site=site, detailed=True, use_cache=not args.no_cache)
|
|
260
|
+
)
|
|
240
261
|
else:
|
|
241
262
|
raw_devices = raw_devices_override
|
|
242
263
|
devices = normalize_devices(raw_devices)
|
|
@@ -252,7 +273,7 @@ def _build_topology_data(
|
|
|
252
273
|
*,
|
|
253
274
|
include_ports: bool | None = None,
|
|
254
275
|
raw_devices_override: list[object] | None = None,
|
|
255
|
-
) -> tuple[list[Device], list[str],
|
|
276
|
+
) -> tuple[list[Device], list[str], TopologyResult]:
|
|
256
277
|
_raw_devices, devices = _load_devices_data(
|
|
257
278
|
args,
|
|
258
279
|
config,
|
|
@@ -284,7 +305,7 @@ def _build_edges_with_clients(
|
|
|
284
305
|
if clients_override is None:
|
|
285
306
|
if config is None:
|
|
286
307
|
raise ValueError("Config required to fetch clients")
|
|
287
|
-
clients = list(fetch_clients(config, site=site))
|
|
308
|
+
clients = list(fetch_clients(config, site=site, use_cache=not args.no_cache))
|
|
288
309
|
else:
|
|
289
310
|
clients = clients_override
|
|
290
311
|
device_index = build_device_index(devices)
|
|
@@ -297,7 +318,7 @@ def _build_edges_with_clients(
|
|
|
297
318
|
return edges, clients
|
|
298
319
|
|
|
299
320
|
|
|
300
|
-
def _select_edges(topology:
|
|
321
|
+
def _select_edges(topology: TopologyResult) -> tuple[list, bool]:
|
|
301
322
|
if topology.tree_edges:
|
|
302
323
|
return topology.tree_edges, True
|
|
303
324
|
logging.warning("No gateway found for hierarchy; rendering raw edges.")
|
|
@@ -307,7 +328,7 @@ def _select_edges(topology: object) -> tuple[list, bool]:
|
|
|
307
328
|
def _render_mermaid_output(
|
|
308
329
|
args: argparse.Namespace,
|
|
309
330
|
devices: list[Device],
|
|
310
|
-
topology:
|
|
331
|
+
topology: TopologyResult,
|
|
311
332
|
config: Config | None,
|
|
312
333
|
site: str,
|
|
313
334
|
mermaid_theme: MermaidTheme,
|
|
@@ -346,40 +367,134 @@ def _render_mermaid_output(
|
|
|
346
367
|
def _render_mkdocs_output(
|
|
347
368
|
args: argparse.Namespace,
|
|
348
369
|
devices: list[Device],
|
|
349
|
-
topology:
|
|
350
|
-
config: Config,
|
|
351
|
-
site: str,
|
|
370
|
+
topology: TopologyResult,
|
|
352
371
|
mermaid_theme: MermaidTheme,
|
|
353
372
|
port_map: PortMap,
|
|
354
373
|
client_ports: ClientPortMap | None,
|
|
374
|
+
timestamp_zone: str,
|
|
375
|
+
dark_mermaid_theme: MermaidTheme | None = None,
|
|
355
376
|
) -> str:
|
|
356
377
|
edges, _has_tree = _select_edges(topology)
|
|
357
378
|
clients = None
|
|
379
|
+
node_types = build_node_type_map(devices, clients, client_mode=args.client_scope)
|
|
358
380
|
content = render_mermaid(
|
|
359
381
|
edges,
|
|
360
382
|
direction=args.direction,
|
|
361
|
-
node_types=
|
|
383
|
+
node_types=node_types,
|
|
362
384
|
theme=mermaid_theme,
|
|
363
385
|
)
|
|
364
386
|
legend_style = _resolve_legend_style(args)
|
|
387
|
+
dual_theme = args.mkdocs_dual_theme and dark_mermaid_theme is not None
|
|
388
|
+
legend_header = "## Legend\n\n" if legend_style != "compact" else ""
|
|
389
|
+
if dual_theme and dark_mermaid_theme is not None:
|
|
390
|
+
dark_content = render_mermaid(
|
|
391
|
+
edges,
|
|
392
|
+
direction=args.direction,
|
|
393
|
+
node_types=node_types,
|
|
394
|
+
theme=dark_mermaid_theme,
|
|
395
|
+
)
|
|
396
|
+
map_block = _mkdocs_dual_mermaid_block(content, dark_content, base_class="unifi-mermaid")
|
|
397
|
+
legend_block = _mkdocs_dual_legend_block(
|
|
398
|
+
legend_style,
|
|
399
|
+
mermaid_theme=mermaid_theme,
|
|
400
|
+
dark_mermaid_theme=dark_mermaid_theme,
|
|
401
|
+
legend_scale=args.legend_scale,
|
|
402
|
+
)
|
|
403
|
+
dual_style = _mkdocs_dual_theme_style()
|
|
404
|
+
else:
|
|
405
|
+
map_block = _mkdocs_mermaid_block(content, class_name="unifi-mermaid")
|
|
406
|
+
legend_block = _mkdocs_single_legend_block(
|
|
407
|
+
legend_style,
|
|
408
|
+
mermaid_theme=mermaid_theme,
|
|
409
|
+
legend_scale=args.legend_scale,
|
|
410
|
+
)
|
|
411
|
+
dual_style = ""
|
|
412
|
+
timestamp_line = ""
|
|
413
|
+
if timestamp_zone.strip().lower() not in {"off", "none", "false"}:
|
|
414
|
+
try:
|
|
415
|
+
zone = ZoneInfo(timestamp_zone)
|
|
416
|
+
except Exception as exc: # noqa: BLE001
|
|
417
|
+
logger.warning("Invalid mkdocs timestamp zone '%s': %s", timestamp_zone, exc)
|
|
418
|
+
else:
|
|
419
|
+
generated_at = datetime.now(zone).strftime("%Y-%m-%d %H:%M:%S %Z")
|
|
420
|
+
timestamp_line = f"Generated: {generated_at}\n\n"
|
|
421
|
+
return (
|
|
422
|
+
f"# UniFi network\n\n{timestamp_line}{dual_style}## Map\n\n{map_block}\n\n"
|
|
423
|
+
f"{legend_header}{legend_block}\n\n"
|
|
424
|
+
f"{render_device_port_overview(devices, port_map, client_ports=client_ports)}"
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _mkdocs_mermaid_block(content: str, *, class_name: str) -> str:
|
|
429
|
+
return f'<div class="{class_name}">\n```mermaid\n{content}```\n</div>'
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _mkdocs_dual_mermaid_block(
|
|
433
|
+
light_content: str,
|
|
434
|
+
dark_content: str,
|
|
435
|
+
*,
|
|
436
|
+
base_class: str,
|
|
437
|
+
) -> str:
|
|
438
|
+
light = _mkdocs_mermaid_block(light_content, class_name=f"{base_class} {base_class}--light")
|
|
439
|
+
dark = _mkdocs_mermaid_block(dark_content, class_name=f"{base_class} {base_class}--dark")
|
|
440
|
+
return f"{light}\n{dark}"
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _mkdocs_single_legend_block(
|
|
444
|
+
legend_style: str,
|
|
445
|
+
*,
|
|
446
|
+
mermaid_theme: MermaidTheme,
|
|
447
|
+
legend_scale: float,
|
|
448
|
+
) -> str:
|
|
365
449
|
if legend_style == "compact":
|
|
366
|
-
|
|
450
|
+
return (
|
|
367
451
|
'<div class="unifi-legend" data-unifi-legend>\n'
|
|
368
452
|
+ render_legend_compact(theme=mermaid_theme)
|
|
369
453
|
+ "</div>"
|
|
370
454
|
)
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
455
|
+
return "```mermaid\n" + render_legend(theme=mermaid_theme, legend_scale=legend_scale) + "```"
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _mkdocs_dual_legend_block(
|
|
459
|
+
legend_style: str,
|
|
460
|
+
*,
|
|
461
|
+
mermaid_theme: MermaidTheme,
|
|
462
|
+
dark_mermaid_theme: MermaidTheme,
|
|
463
|
+
legend_scale: float,
|
|
464
|
+
) -> str:
|
|
465
|
+
if legend_style == "compact":
|
|
466
|
+
light = (
|
|
467
|
+
'<div class="unifi-legend unifi-legend--light" data-unifi-legend>\n'
|
|
468
|
+
+ render_legend_compact(theme=mermaid_theme)
|
|
469
|
+
+ "</div>"
|
|
470
|
+
)
|
|
471
|
+
dark = (
|
|
472
|
+
'<div class="unifi-legend unifi-legend--dark" data-unifi-legend>\n'
|
|
473
|
+
+ render_legend_compact(theme=dark_mermaid_theme)
|
|
474
|
+
+ "</div>"
|
|
377
475
|
)
|
|
378
|
-
|
|
476
|
+
return f"{light}\n{dark}"
|
|
477
|
+
light = _mkdocs_mermaid_block(
|
|
478
|
+
render_legend(theme=mermaid_theme, legend_scale=legend_scale),
|
|
479
|
+
class_name="unifi-legend unifi-legend--light",
|
|
480
|
+
)
|
|
481
|
+
dark = _mkdocs_mermaid_block(
|
|
482
|
+
render_legend(theme=dark_mermaid_theme, legend_scale=legend_scale),
|
|
483
|
+
class_name="unifi-legend unifi-legend--dark",
|
|
484
|
+
)
|
|
485
|
+
return f"{light}\n{dark}"
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _mkdocs_dual_theme_style() -> str:
|
|
379
489
|
return (
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
490
|
+
"<style>\n"
|
|
491
|
+
".unifi-mermaid--light,.unifi-legend--light{display:none;}\n"
|
|
492
|
+
".unifi-mermaid--dark,.unifi-legend--dark{display:none;}\n"
|
|
493
|
+
'[data-md-color-scheme="default"] .unifi-mermaid--light{display:block;}\n'
|
|
494
|
+
'[data-md-color-scheme="default"] .unifi-legend--light{display:block;}\n'
|
|
495
|
+
'[data-md-color-scheme="slate"] .unifi-mermaid--dark{display:block;}\n'
|
|
496
|
+
'[data-md-color-scheme="slate"] .unifi-legend--dark{display:block;}\n'
|
|
497
|
+
"</style>\n\n"
|
|
383
498
|
)
|
|
384
499
|
|
|
385
500
|
|
|
@@ -446,7 +561,7 @@ def _write_mkdocs_sidebar_assets(output_path: str) -> None:
|
|
|
446
561
|
def _render_svg_output(
|
|
447
562
|
args: argparse.Namespace,
|
|
448
563
|
devices: list[Device],
|
|
449
|
-
topology:
|
|
564
|
+
topology: TopologyResult,
|
|
450
565
|
config: Config | None,
|
|
451
566
|
site: str,
|
|
452
567
|
svg_theme: SvgTheme,
|
|
@@ -480,87 +595,97 @@ def _render_svg_output(
|
|
|
480
595
|
)
|
|
481
596
|
|
|
482
597
|
|
|
483
|
-
def
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
598
|
+
def _handle_generate_mock(args: argparse.Namespace) -> int | None:
|
|
599
|
+
if not args.generate_mock:
|
|
600
|
+
return None
|
|
601
|
+
try:
|
|
602
|
+
from ..io.mock_generate import MockOptions, mock_payload_json
|
|
603
|
+
except ImportError as exc:
|
|
604
|
+
logging.error("Faker is required for --generate-mock: %s", exc)
|
|
605
|
+
return 2
|
|
606
|
+
options = MockOptions(
|
|
607
|
+
seed=args.mock_seed,
|
|
608
|
+
switch_count=max(1, args.mock_switches),
|
|
609
|
+
ap_count=max(0, args.mock_aps),
|
|
610
|
+
wired_client_count=max(0, args.mock_wired_clients),
|
|
611
|
+
wireless_client_count=max(0, args.mock_wireless_clients),
|
|
612
|
+
)
|
|
613
|
+
content = mock_payload_json(options)
|
|
614
|
+
write_output(content, output_path=args.generate_mock, stdout=args.stdout)
|
|
615
|
+
return 0
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _load_runtime_context(
|
|
619
|
+
args: argparse.Namespace,
|
|
620
|
+
) -> tuple[Config | None, str, list[object] | None, list[object] | None]:
|
|
504
621
|
if args.mock_data:
|
|
505
622
|
try:
|
|
506
623
|
mock_devices, mock_clients = load_mock_data(args.mock_data)
|
|
507
624
|
except Exception as exc: # noqa: BLE001
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
return 2
|
|
516
|
-
site = _resolve_site(args, config)
|
|
517
|
-
mermaid_theme, svg_theme = resolve_themes(args.theme_file)
|
|
625
|
+
raise ValueError(f"Failed to load mock data: {exc}") from exc
|
|
626
|
+
return None, "mock", mock_devices, mock_clients
|
|
627
|
+
config = _load_config(args)
|
|
628
|
+
if config is None:
|
|
629
|
+
raise ValueError("Config required to run")
|
|
630
|
+
site = _resolve_site(args, config)
|
|
631
|
+
return config, site, None, None
|
|
518
632
|
|
|
519
|
-
if args.legend_only:
|
|
520
|
-
content = _render_legend_only(args, mermaid_theme)
|
|
521
|
-
write_output(content, output_path=args.output, stdout=args.stdout)
|
|
522
|
-
return 0
|
|
523
|
-
|
|
524
|
-
if args.format == "lldp-md":
|
|
525
|
-
try:
|
|
526
|
-
_raw_devices, devices = _load_devices_data(
|
|
527
|
-
args,
|
|
528
|
-
config,
|
|
529
|
-
site,
|
|
530
|
-
raw_devices_override=mock_devices,
|
|
531
|
-
)
|
|
532
|
-
except Exception as exc:
|
|
533
|
-
logging.error("Failed to load devices: %s", exc)
|
|
534
|
-
return 1
|
|
535
|
-
if mock_clients is None:
|
|
536
|
-
if config is None:
|
|
537
|
-
logging.error("Mock data required for client rendering")
|
|
538
|
-
return 2
|
|
539
|
-
clients = list(fetch_clients(config, site=site))
|
|
540
|
-
else:
|
|
541
|
-
clients = mock_clients
|
|
542
|
-
content = render_lldp_md(
|
|
543
|
-
devices,
|
|
544
|
-
clients=clients,
|
|
545
|
-
include_ports=args.include_ports,
|
|
546
|
-
show_clients=args.include_clients,
|
|
547
|
-
client_mode=args.client_scope,
|
|
548
|
-
)
|
|
549
|
-
write_output(content, output_path=args.output, stdout=args.stdout)
|
|
550
|
-
return 0
|
|
551
633
|
|
|
634
|
+
def _render_lldp_format(
|
|
635
|
+
args: argparse.Namespace,
|
|
636
|
+
*,
|
|
637
|
+
config: Config | None,
|
|
638
|
+
site: str,
|
|
639
|
+
mock_devices: list[object] | None,
|
|
640
|
+
mock_clients: list[object] | None,
|
|
641
|
+
) -> int:
|
|
552
642
|
try:
|
|
553
|
-
|
|
554
|
-
devices, _gateways, topology = _build_topology_data(
|
|
643
|
+
_raw_devices, devices = _load_devices_data(
|
|
555
644
|
args,
|
|
556
645
|
config,
|
|
557
646
|
site,
|
|
558
|
-
include_ports=include_ports,
|
|
559
647
|
raw_devices_override=mock_devices,
|
|
560
648
|
)
|
|
561
649
|
except Exception as exc:
|
|
562
|
-
logging.error("Failed to
|
|
650
|
+
logging.error("Failed to load devices: %s", exc)
|
|
563
651
|
return 1
|
|
652
|
+
if mock_clients is None:
|
|
653
|
+
if config is None:
|
|
654
|
+
logging.error("Mock data required for client rendering")
|
|
655
|
+
return 2
|
|
656
|
+
clients = list(fetch_clients(config, site=site))
|
|
657
|
+
else:
|
|
658
|
+
clients = mock_clients
|
|
659
|
+
content = render_lldp_md(
|
|
660
|
+
devices,
|
|
661
|
+
clients=clients,
|
|
662
|
+
include_ports=args.include_ports,
|
|
663
|
+
show_clients=args.include_clients,
|
|
664
|
+
client_mode=args.client_scope,
|
|
665
|
+
)
|
|
666
|
+
write_output(content, output_path=args.output, stdout=args.stdout)
|
|
667
|
+
return 0
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _render_standard_format(
|
|
671
|
+
args: argparse.Namespace,
|
|
672
|
+
*,
|
|
673
|
+
config: Config | None,
|
|
674
|
+
site: str,
|
|
675
|
+
mock_devices: list[object] | None,
|
|
676
|
+
mock_clients: list[object] | None,
|
|
677
|
+
mermaid_theme: MermaidTheme,
|
|
678
|
+
svg_theme: SvgTheme,
|
|
679
|
+
) -> int:
|
|
680
|
+
topology_result = _load_topology_for_render(
|
|
681
|
+
args,
|
|
682
|
+
config=config,
|
|
683
|
+
site=site,
|
|
684
|
+
mock_devices=mock_devices,
|
|
685
|
+
)
|
|
686
|
+
if topology_result is None:
|
|
687
|
+
return 1
|
|
688
|
+
devices, topology = topology_result
|
|
564
689
|
|
|
565
690
|
if args.format == "mermaid":
|
|
566
691
|
content = _render_mermaid_output(
|
|
@@ -573,25 +698,17 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
573
698
|
clients_override=mock_clients,
|
|
574
699
|
)
|
|
575
700
|
elif args.format == "mkdocs":
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
if mock_clients is None:
|
|
585
|
-
if config is None:
|
|
586
|
-
logging.error("Mock data required for client rendering")
|
|
587
|
-
return 2
|
|
588
|
-
clients = list(fetch_clients(config, site=site))
|
|
589
|
-
else:
|
|
590
|
-
clients = mock_clients
|
|
591
|
-
client_ports = build_client_port_map(devices, clients, client_mode=args.client_scope)
|
|
592
|
-
content = _render_mkdocs_output(
|
|
593
|
-
args, devices, topology, config, site, mermaid_theme, port_map, client_ports
|
|
701
|
+
content = _render_mkdocs_format(
|
|
702
|
+
args,
|
|
703
|
+
devices=devices,
|
|
704
|
+
topology=topology,
|
|
705
|
+
config=config,
|
|
706
|
+
site=site,
|
|
707
|
+
mermaid_theme=mermaid_theme,
|
|
708
|
+
mock_clients=mock_clients,
|
|
594
709
|
)
|
|
710
|
+
if content is None:
|
|
711
|
+
return 2
|
|
595
712
|
elif args.format in {"svg", "svg-iso"}:
|
|
596
713
|
content = _render_svg_output(
|
|
597
714
|
args,
|
|
@@ -610,5 +727,133 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
610
727
|
return 0
|
|
611
728
|
|
|
612
729
|
|
|
730
|
+
def _load_topology_for_render(
|
|
731
|
+
args: argparse.Namespace,
|
|
732
|
+
*,
|
|
733
|
+
config: Config | None,
|
|
734
|
+
site: str,
|
|
735
|
+
mock_devices: list[object] | None,
|
|
736
|
+
) -> tuple[list[Device], TopologyResult] | None:
|
|
737
|
+
try:
|
|
738
|
+
include_ports = True if args.format == "mkdocs" else None
|
|
739
|
+
devices, _gateways, topology = _build_topology_data(
|
|
740
|
+
args,
|
|
741
|
+
config,
|
|
742
|
+
site,
|
|
743
|
+
include_ports=include_ports,
|
|
744
|
+
raw_devices_override=mock_devices,
|
|
745
|
+
)
|
|
746
|
+
except Exception as exc:
|
|
747
|
+
logging.error("Failed to build topology: %s", exc)
|
|
748
|
+
return None
|
|
749
|
+
return devices, topology
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _load_dark_mermaid_theme() -> MermaidTheme | None:
|
|
753
|
+
dark_theme_path = Path(__file__).resolve().parents[1] / "assets" / "themes" / "dark.yaml"
|
|
754
|
+
try:
|
|
755
|
+
dark_theme, _ = load_theme(dark_theme_path)
|
|
756
|
+
except Exception as exc: # noqa: BLE001
|
|
757
|
+
logger.warning("Failed to load dark theme: %s", exc)
|
|
758
|
+
return None
|
|
759
|
+
return dark_theme
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _resolve_mkdocs_client_ports(
|
|
763
|
+
args: argparse.Namespace,
|
|
764
|
+
devices: list[Device],
|
|
765
|
+
config: Config | None,
|
|
766
|
+
site: str,
|
|
767
|
+
mock_clients: list[object] | None,
|
|
768
|
+
) -> tuple[ClientPortMap | None, int | None]:
|
|
769
|
+
if not args.include_clients:
|
|
770
|
+
return None, None
|
|
771
|
+
if mock_clients is None:
|
|
772
|
+
if config is None:
|
|
773
|
+
return None, 2
|
|
774
|
+
clients = list(fetch_clients(config, site=site))
|
|
775
|
+
else:
|
|
776
|
+
clients = mock_clients
|
|
777
|
+
client_ports = build_client_port_map(devices, clients, client_mode=args.client_scope)
|
|
778
|
+
return client_ports, None
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _render_mkdocs_format(
|
|
782
|
+
args: argparse.Namespace,
|
|
783
|
+
*,
|
|
784
|
+
devices: list[Device],
|
|
785
|
+
topology: TopologyResult,
|
|
786
|
+
config: Config | None,
|
|
787
|
+
site: str,
|
|
788
|
+
mermaid_theme: MermaidTheme,
|
|
789
|
+
mock_clients: list[object] | None,
|
|
790
|
+
) -> str | None:
|
|
791
|
+
if args.mkdocs_sidebar_legend and not args.output:
|
|
792
|
+
logging.error("--mkdocs-sidebar-legend requires --output")
|
|
793
|
+
return None
|
|
794
|
+
if args.mkdocs_sidebar_legend:
|
|
795
|
+
_write_mkdocs_sidebar_assets(args.output)
|
|
796
|
+
port_map = build_port_map(devices, only_unifi=args.only_unifi)
|
|
797
|
+
client_ports, error_code = _resolve_mkdocs_client_ports(
|
|
798
|
+
args,
|
|
799
|
+
devices,
|
|
800
|
+
config,
|
|
801
|
+
site,
|
|
802
|
+
mock_clients,
|
|
803
|
+
)
|
|
804
|
+
if error_code is not None:
|
|
805
|
+
logging.error("Mock data required for client rendering")
|
|
806
|
+
return None
|
|
807
|
+
dark_mermaid_theme = _load_dark_mermaid_theme() if args.mkdocs_dual_theme else None
|
|
808
|
+
return _render_mkdocs_output(
|
|
809
|
+
args,
|
|
810
|
+
devices,
|
|
811
|
+
topology,
|
|
812
|
+
mermaid_theme,
|
|
813
|
+
port_map,
|
|
814
|
+
client_ports,
|
|
815
|
+
args.mkdocs_timestamp_zone,
|
|
816
|
+
dark_mermaid_theme,
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def main(argv: list[str] | None = None) -> int:
|
|
821
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
|
822
|
+
args = _parse_args(argv)
|
|
823
|
+
mock_result = _handle_generate_mock(args)
|
|
824
|
+
if mock_result is not None:
|
|
825
|
+
return mock_result
|
|
826
|
+
try:
|
|
827
|
+
config, site, mock_devices, mock_clients = _load_runtime_context(args)
|
|
828
|
+
except ValueError as exc:
|
|
829
|
+
logging.error(str(exc))
|
|
830
|
+
return 2
|
|
831
|
+
mermaid_theme, svg_theme = resolve_themes(args.theme_file)
|
|
832
|
+
|
|
833
|
+
if args.legend_only:
|
|
834
|
+
content = _render_legend_only(args, mermaid_theme)
|
|
835
|
+
write_output(content, output_path=args.output, stdout=args.stdout)
|
|
836
|
+
return 0
|
|
837
|
+
|
|
838
|
+
if args.format == "lldp-md":
|
|
839
|
+
return _render_lldp_format(
|
|
840
|
+
args,
|
|
841
|
+
config=config,
|
|
842
|
+
site=site,
|
|
843
|
+
mock_devices=mock_devices,
|
|
844
|
+
mock_clients=mock_clients,
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
return _render_standard_format(
|
|
848
|
+
args,
|
|
849
|
+
config=config,
|
|
850
|
+
site=site,
|
|
851
|
+
mock_devices=mock_devices,
|
|
852
|
+
mock_clients=mock_clients,
|
|
853
|
+
mermaid_theme=mermaid_theme,
|
|
854
|
+
svg_theme=svg_theme,
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
|
|
613
858
|
if __name__ == "__main__":
|
|
614
859
|
raise SystemExit(main())
|
unifi_network_maps/io/debug.py
CHANGED
|
@@ -4,22 +4,32 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import logging
|
|
7
|
+
from collections.abc import Iterable, Sequence
|
|
7
8
|
|
|
8
|
-
from ..model.topology import group_devices_by_type
|
|
9
|
+
from ..model.topology import Device, group_devices_by_type
|
|
9
10
|
|
|
10
11
|
logger = logging.getLogger(__name__)
|
|
11
12
|
|
|
12
13
|
|
|
13
|
-
def device_to_dict(device: object) -> dict:
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
def device_to_dict(device: object) -> dict[str, object]:
|
|
15
|
+
to_dict = getattr(device, "to_dict", None)
|
|
16
|
+
if callable(to_dict):
|
|
17
|
+
result = to_dict()
|
|
18
|
+
if isinstance(result, dict):
|
|
19
|
+
return result
|
|
20
|
+
return {"repr": repr(result)}
|
|
16
21
|
if hasattr(device, "__dict__"):
|
|
17
22
|
return dict(device.__dict__)
|
|
23
|
+
if isinstance(device, dict):
|
|
24
|
+
return dict(device)
|
|
18
25
|
return {"repr": repr(device)}
|
|
19
26
|
|
|
20
27
|
|
|
21
28
|
def debug_dump_devices(
|
|
22
|
-
raw_devices:
|
|
29
|
+
raw_devices: Sequence[object],
|
|
30
|
+
normalized: Iterable[Device],
|
|
31
|
+
*,
|
|
32
|
+
sample_count: int,
|
|
23
33
|
) -> None:
|
|
24
34
|
name_to_device: dict[str, object] = {}
|
|
25
35
|
for device in raw_devices:
|
unifi_network_maps/io/export.py
CHANGED
|
@@ -2,12 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import os
|
|
5
6
|
import sys
|
|
7
|
+
import tempfile
|
|
6
8
|
from pathlib import Path
|
|
7
9
|
|
|
8
10
|
|
|
9
11
|
def write_output(content: str, *, output_path: str | None, stdout: bool) -> None:
|
|
10
12
|
if output_path:
|
|
11
|
-
Path(output_path)
|
|
13
|
+
_write_atomic(Path(output_path), content)
|
|
12
14
|
if stdout or not output_path:
|
|
13
15
|
sys.stdout.write(content)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _write_atomic(path: Path, content: str) -> None:
|
|
19
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
with tempfile.NamedTemporaryFile(
|
|
21
|
+
mode="w",
|
|
22
|
+
encoding="utf-8",
|
|
23
|
+
dir=str(path.parent),
|
|
24
|
+
prefix=f".{path.name}.",
|
|
25
|
+
suffix=".tmp",
|
|
26
|
+
delete=False,
|
|
27
|
+
) as temp_file:
|
|
28
|
+
temp_file.write(content)
|
|
29
|
+
temp_file.flush()
|
|
30
|
+
os.fsync(temp_file.fileno())
|
|
31
|
+
tmp_path = Path(temp_file.name)
|
|
32
|
+
tmp_path.replace(path)
|