unifi-network-maps 1.4.4__py3-none-any.whl → 1.4.5__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/cli/__init__.py +2 -38
- unifi_network_maps/cli/args.py +166 -0
- unifi_network_maps/cli/main.py +18 -752
- unifi_network_maps/cli/render.py +255 -0
- unifi_network_maps/cli/runtime.py +157 -0
- unifi_network_maps/io/mkdocs_assets.py +21 -0
- unifi_network_maps/io/mock_generate.py +2 -294
- unifi_network_maps/model/mock.py +307 -0
- unifi_network_maps/render/device_ports_md.py +44 -27
- unifi_network_maps/render/legend.py +30 -0
- unifi_network_maps/render/lldp_md.py +81 -60
- unifi_network_maps/render/markdown_tables.py +21 -0
- unifi_network_maps/render/mermaid.py +72 -85
- unifi_network_maps/render/mkdocs.py +167 -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-1.4.4.dist-info → unifi_network_maps-1.4.5.dist-info}/METADATA +2 -1
- {unifi_network_maps-1.4.4.dist-info → unifi_network_maps-1.4.5.dist-info}/RECORD +33 -13
- {unifi_network_maps-1.4.4.dist-info → unifi_network_maps-1.4.5.dist-info}/WHEEL +0 -0
- {unifi_network_maps-1.4.4.dist-info → unifi_network_maps-1.4.5.dist-info}/entry_points.txt +0 -0
- {unifi_network_maps-1.4.4.dist-info → unifi_network_maps-1.4.5.dist-info}/licenses/LICENSE +0 -0
- {unifi_network_maps-1.4.4.dist-info → unifi_network_maps-1.4.5.dist-info}/top_level.txt +0 -0
unifi_network_maps/cli/main.py
CHANGED
|
@@ -4,36 +4,14 @@ 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
|
|
10
7
|
|
|
11
8
|
from ..adapters.config import Config
|
|
12
|
-
from ..adapters.unifi import fetch_clients, fetch_devices
|
|
13
|
-
from ..io.debug import debug_dump_devices
|
|
14
9
|
from ..io.export import write_output
|
|
15
10
|
from ..io.mock_data import load_mock_data
|
|
16
|
-
from ..
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
TopologyResult,
|
|
21
|
-
build_client_edges,
|
|
22
|
-
build_client_port_map,
|
|
23
|
-
build_device_index,
|
|
24
|
-
build_node_type_map,
|
|
25
|
-
build_port_map,
|
|
26
|
-
build_topology,
|
|
27
|
-
group_devices_by_type,
|
|
28
|
-
normalize_devices,
|
|
29
|
-
)
|
|
30
|
-
from ..render.device_ports_md import render_device_port_overview
|
|
31
|
-
from ..render.lldp_md import render_lldp_md
|
|
32
|
-
from ..render.mermaid import render_legend, render_legend_compact, render_mermaid
|
|
33
|
-
from ..render.mermaid_theme import MermaidTheme
|
|
34
|
-
from ..render.svg import SvgOptions, render_svg
|
|
35
|
-
from ..render.svg_theme import SvgTheme
|
|
36
|
-
from ..render.theme import load_theme, resolve_themes
|
|
11
|
+
from ..render.legend import render_legend_only, resolve_legend_style
|
|
12
|
+
from ..render.theme import resolve_themes
|
|
13
|
+
from .args import build_parser
|
|
14
|
+
from .render import render_lldp_format, render_standard_format
|
|
37
15
|
|
|
38
16
|
logger = logging.getLogger(__name__)
|
|
39
17
|
|
|
@@ -47,169 +25,8 @@ def _load_dotenv(env_file: str | None = None) -> None:
|
|
|
47
25
|
load_dotenv(dotenv_path=env_file) if env_file else load_dotenv()
|
|
48
26
|
|
|
49
27
|
|
|
50
|
-
def _build_parser() -> argparse.ArgumentParser:
|
|
51
|
-
parser = argparse.ArgumentParser(
|
|
52
|
-
description="Generate network maps from UniFi LLDP data, as mermaid or SVG"
|
|
53
|
-
)
|
|
54
|
-
_add_source_args(parser.add_argument_group("Source"))
|
|
55
|
-
_add_mock_args(parser.add_argument_group("Mock"))
|
|
56
|
-
_add_functional_args(parser.add_argument_group("Functional"))
|
|
57
|
-
_add_mermaid_args(parser.add_argument_group("Mermaid"))
|
|
58
|
-
_add_svg_args(parser.add_argument_group("SVG"))
|
|
59
|
-
_add_general_render_args(parser.add_argument_group("Output"))
|
|
60
|
-
_add_debug_args(parser.add_argument_group("Debug"))
|
|
61
|
-
return parser
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def _add_source_args(parser: argparse._ArgumentGroup) -> None:
|
|
65
|
-
parser.add_argument("--site", default=None, help="UniFi site name (overrides UNIFI_SITE)")
|
|
66
|
-
parser.add_argument(
|
|
67
|
-
"--env-file",
|
|
68
|
-
default=None,
|
|
69
|
-
help="Path to .env file (overrides default .env discovery)",
|
|
70
|
-
)
|
|
71
|
-
parser.add_argument(
|
|
72
|
-
"--mock-data",
|
|
73
|
-
default=None,
|
|
74
|
-
help="Path to mock data JSON (skips UniFi API calls)",
|
|
75
|
-
)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def _add_mock_args(parser: argparse._ArgumentGroup) -> None:
|
|
79
|
-
parser.add_argument(
|
|
80
|
-
"--generate-mock",
|
|
81
|
-
default=None,
|
|
82
|
-
help="Write mock data JSON to the given path and exit",
|
|
83
|
-
)
|
|
84
|
-
parser.add_argument("--mock-seed", type=int, default=1337, help="Seed for mock generation")
|
|
85
|
-
parser.add_argument(
|
|
86
|
-
"--mock-switches",
|
|
87
|
-
type=int,
|
|
88
|
-
default=1,
|
|
89
|
-
help="Number of switches to generate (default: 1)",
|
|
90
|
-
)
|
|
91
|
-
parser.add_argument(
|
|
92
|
-
"--mock-aps",
|
|
93
|
-
type=int,
|
|
94
|
-
default=2,
|
|
95
|
-
help="Number of access points to generate (default: 2)",
|
|
96
|
-
)
|
|
97
|
-
parser.add_argument(
|
|
98
|
-
"--mock-wired-clients",
|
|
99
|
-
type=int,
|
|
100
|
-
default=2,
|
|
101
|
-
help="Number of wired clients to generate (default: 2)",
|
|
102
|
-
)
|
|
103
|
-
parser.add_argument(
|
|
104
|
-
"--mock-wireless-clients",
|
|
105
|
-
type=int,
|
|
106
|
-
default=2,
|
|
107
|
-
help="Number of wireless clients to generate (default: 2)",
|
|
108
|
-
)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def _add_functional_args(parser: argparse._ArgumentGroup) -> None:
|
|
112
|
-
parser.add_argument("--include-ports", action="store_true", help="Include port labels in edges")
|
|
113
|
-
parser.add_argument(
|
|
114
|
-
"--include-clients",
|
|
115
|
-
action="store_true",
|
|
116
|
-
help="Include active clients as leaf nodes",
|
|
117
|
-
)
|
|
118
|
-
parser.add_argument(
|
|
119
|
-
"--client-scope",
|
|
120
|
-
choices=["wired", "wireless", "all"],
|
|
121
|
-
default="wired",
|
|
122
|
-
help="Client types to include (default: wired)",
|
|
123
|
-
)
|
|
124
|
-
parser.add_argument(
|
|
125
|
-
"--only-unifi", action="store_true", help="Only include neighbors that are UniFi devices"
|
|
126
|
-
)
|
|
127
|
-
parser.add_argument(
|
|
128
|
-
"--no-cache",
|
|
129
|
-
action="store_true",
|
|
130
|
-
help="Disable UniFi API cache reads and writes",
|
|
131
|
-
)
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
def _add_mermaid_args(parser: argparse._ArgumentGroup) -> None:
|
|
135
|
-
parser.add_argument("--direction", default="TB", choices=["LR", "TB"], help="Mermaid direction")
|
|
136
|
-
parser.add_argument(
|
|
137
|
-
"--group-by-type",
|
|
138
|
-
action="store_true",
|
|
139
|
-
help="Group nodes by gateway/switch/ap in Mermaid subgraphs",
|
|
140
|
-
)
|
|
141
|
-
parser.add_argument(
|
|
142
|
-
"--legend-scale",
|
|
143
|
-
type=float,
|
|
144
|
-
default=1.0,
|
|
145
|
-
help="Scale legend font/link sizes for Mermaid output (default: 1.0)",
|
|
146
|
-
)
|
|
147
|
-
parser.add_argument(
|
|
148
|
-
"--legend-style",
|
|
149
|
-
default="auto",
|
|
150
|
-
choices=["auto", "compact", "diagram"],
|
|
151
|
-
help="Legend style (auto uses compact for mkdocs, diagram otherwise)",
|
|
152
|
-
)
|
|
153
|
-
parser.add_argument(
|
|
154
|
-
"--legend-only",
|
|
155
|
-
action="store_true",
|
|
156
|
-
help="Render only the legend as a separate Mermaid graph",
|
|
157
|
-
)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
def _add_svg_args(parser: argparse._ArgumentGroup) -> None:
|
|
161
|
-
parser.add_argument("--svg-width", type=int, default=None, help="SVG width override")
|
|
162
|
-
parser.add_argument("--svg-height", type=int, default=None, help="SVG height override")
|
|
163
|
-
parser.add_argument("--theme-file", default=None, help="Path to theme YAML file")
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
def _add_general_render_args(parser: argparse._ArgumentGroup) -> None:
|
|
167
|
-
parser.add_argument(
|
|
168
|
-
"--format",
|
|
169
|
-
default="mermaid",
|
|
170
|
-
choices=["mermaid", "svg", "svg-iso", "lldp-md", "mkdocs"],
|
|
171
|
-
help="Output format",
|
|
172
|
-
)
|
|
173
|
-
parser.add_argument(
|
|
174
|
-
"--markdown",
|
|
175
|
-
action="store_true",
|
|
176
|
-
help="Wrap output in a Markdown mermaid code fence for notes tools like Obsidian",
|
|
177
|
-
)
|
|
178
|
-
parser.add_argument("--output", default=None, help="Output file path")
|
|
179
|
-
parser.add_argument("--stdout", action="store_true", help="Write output to stdout")
|
|
180
|
-
parser.add_argument(
|
|
181
|
-
"--mkdocs-sidebar-legend",
|
|
182
|
-
action="store_true",
|
|
183
|
-
help="For mkdocs output, write sidebar legend assets next to the output file",
|
|
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
|
-
)
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
def _add_debug_args(parser: argparse._ArgumentGroup) -> None:
|
|
198
|
-
parser.add_argument(
|
|
199
|
-
"--debug-dump",
|
|
200
|
-
action="store_true",
|
|
201
|
-
help="Dump gateway and sample device data to stderr for debugging",
|
|
202
|
-
)
|
|
203
|
-
parser.add_argument(
|
|
204
|
-
"--debug-sample",
|
|
205
|
-
type=int,
|
|
206
|
-
default=2,
|
|
207
|
-
help="Number of non-gateway devices to include in debug dump (default: 2)",
|
|
208
|
-
)
|
|
209
|
-
|
|
210
|
-
|
|
211
28
|
def _parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|
212
|
-
parser =
|
|
29
|
+
parser = build_parser()
|
|
213
30
|
return parser.parse_args(argv)
|
|
214
31
|
|
|
215
32
|
|
|
@@ -226,385 +43,11 @@ def _resolve_site(args: argparse.Namespace, config: Config) -> str:
|
|
|
226
43
|
return args.site or config.site
|
|
227
44
|
|
|
228
45
|
|
|
229
|
-
def _resolve_legend_style(args: argparse.Namespace) -> str:
|
|
230
|
-
if args.legend_style == "auto":
|
|
231
|
-
return "compact" if args.format == "mkdocs" else "diagram"
|
|
232
|
-
return args.legend_style
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
def _render_legend_only(args: argparse.Namespace, mermaid_theme: MermaidTheme) -> str:
|
|
236
|
-
legend_style = _resolve_legend_style(args)
|
|
237
|
-
if legend_style == "compact":
|
|
238
|
-
content = "# Legend\n\n" + render_legend_compact(theme=mermaid_theme)
|
|
239
|
-
else:
|
|
240
|
-
content = render_legend(theme=mermaid_theme, legend_scale=args.legend_scale)
|
|
241
|
-
if args.markdown:
|
|
242
|
-
content = f"""```mermaid
|
|
243
|
-
{content}```
|
|
244
|
-
"""
|
|
245
|
-
return content
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
def _load_devices_data(
|
|
249
|
-
args: argparse.Namespace,
|
|
250
|
-
config: Config | None,
|
|
251
|
-
site: str,
|
|
252
|
-
*,
|
|
253
|
-
raw_devices_override: list[object] | None = None,
|
|
254
|
-
) -> tuple[list[object], list[Device]]:
|
|
255
|
-
if raw_devices_override is None:
|
|
256
|
-
if config is None:
|
|
257
|
-
raise ValueError("Config required to fetch devices")
|
|
258
|
-
raw_devices = list(
|
|
259
|
-
fetch_devices(config, site=site, detailed=True, use_cache=not args.no_cache)
|
|
260
|
-
)
|
|
261
|
-
else:
|
|
262
|
-
raw_devices = raw_devices_override
|
|
263
|
-
devices = normalize_devices(raw_devices)
|
|
264
|
-
if args.debug_dump:
|
|
265
|
-
debug_dump_devices(raw_devices, devices, sample_count=max(0, args.debug_sample))
|
|
266
|
-
return raw_devices, devices
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
def _build_topology_data(
|
|
270
|
-
args: argparse.Namespace,
|
|
271
|
-
config: Config | None,
|
|
272
|
-
site: str,
|
|
273
|
-
*,
|
|
274
|
-
include_ports: bool | None = None,
|
|
275
|
-
raw_devices_override: list[object] | None = None,
|
|
276
|
-
) -> tuple[list[Device], list[str], TopologyResult]:
|
|
277
|
-
_raw_devices, devices = _load_devices_data(
|
|
278
|
-
args,
|
|
279
|
-
config,
|
|
280
|
-
site,
|
|
281
|
-
raw_devices_override=raw_devices_override,
|
|
282
|
-
)
|
|
283
|
-
groups_for_rank = group_devices_by_type(devices)
|
|
284
|
-
gateways = groups_for_rank.get("gateway", [])
|
|
285
|
-
topology = build_topology(
|
|
286
|
-
devices,
|
|
287
|
-
include_ports=include_ports if include_ports is not None else args.include_ports,
|
|
288
|
-
only_unifi=args.only_unifi,
|
|
289
|
-
gateways=gateways,
|
|
290
|
-
)
|
|
291
|
-
return devices, gateways, topology
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
def _build_edges_with_clients(
|
|
295
|
-
args: argparse.Namespace,
|
|
296
|
-
edges: list,
|
|
297
|
-
devices: list[Device],
|
|
298
|
-
config: Config | None,
|
|
299
|
-
site: str,
|
|
300
|
-
*,
|
|
301
|
-
clients_override: list[object] | None = None,
|
|
302
|
-
) -> tuple[list, list | None]:
|
|
303
|
-
clients = None
|
|
304
|
-
if args.include_clients:
|
|
305
|
-
if clients_override is None:
|
|
306
|
-
if config is None:
|
|
307
|
-
raise ValueError("Config required to fetch clients")
|
|
308
|
-
clients = list(fetch_clients(config, site=site, use_cache=not args.no_cache))
|
|
309
|
-
else:
|
|
310
|
-
clients = clients_override
|
|
311
|
-
device_index = build_device_index(devices)
|
|
312
|
-
edges = edges + build_client_edges(
|
|
313
|
-
clients,
|
|
314
|
-
device_index,
|
|
315
|
-
include_ports=args.include_ports,
|
|
316
|
-
client_mode=args.client_scope,
|
|
317
|
-
)
|
|
318
|
-
return edges, clients
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
def _select_edges(topology: TopologyResult) -> tuple[list, bool]:
|
|
322
|
-
if topology.tree_edges:
|
|
323
|
-
return topology.tree_edges, True
|
|
324
|
-
logging.warning("No gateway found for hierarchy; rendering raw edges.")
|
|
325
|
-
return topology.raw_edges, False
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
def _render_mermaid_output(
|
|
329
|
-
args: argparse.Namespace,
|
|
330
|
-
devices: list[Device],
|
|
331
|
-
topology: TopologyResult,
|
|
332
|
-
config: Config | None,
|
|
333
|
-
site: str,
|
|
334
|
-
mermaid_theme: MermaidTheme,
|
|
335
|
-
*,
|
|
336
|
-
clients_override: list[object] | None = None,
|
|
337
|
-
) -> str:
|
|
338
|
-
edges, _has_tree = _select_edges(topology)
|
|
339
|
-
edges, clients = _build_edges_with_clients(
|
|
340
|
-
args,
|
|
341
|
-
edges,
|
|
342
|
-
devices,
|
|
343
|
-
config,
|
|
344
|
-
site,
|
|
345
|
-
clients_override=clients_override,
|
|
346
|
-
)
|
|
347
|
-
groups = None
|
|
348
|
-
group_order = None
|
|
349
|
-
if args.group_by_type:
|
|
350
|
-
groups = group_devices_by_type(devices)
|
|
351
|
-
group_order = ["gateway", "switch", "ap", "other"]
|
|
352
|
-
content = render_mermaid(
|
|
353
|
-
edges,
|
|
354
|
-
direction=args.direction,
|
|
355
|
-
groups=groups,
|
|
356
|
-
group_order=group_order,
|
|
357
|
-
node_types=build_node_type_map(devices, clients, client_mode=args.client_scope),
|
|
358
|
-
theme=mermaid_theme,
|
|
359
|
-
)
|
|
360
|
-
if args.markdown:
|
|
361
|
-
content = f"""```mermaid
|
|
362
|
-
{content}```
|
|
363
|
-
"""
|
|
364
|
-
return content
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
def _render_mkdocs_output(
|
|
368
|
-
args: argparse.Namespace,
|
|
369
|
-
devices: list[Device],
|
|
370
|
-
topology: TopologyResult,
|
|
371
|
-
mermaid_theme: MermaidTheme,
|
|
372
|
-
port_map: PortMap,
|
|
373
|
-
client_ports: ClientPortMap | None,
|
|
374
|
-
timestamp_zone: str,
|
|
375
|
-
dark_mermaid_theme: MermaidTheme | None = None,
|
|
376
|
-
) -> str:
|
|
377
|
-
edges, _has_tree = _select_edges(topology)
|
|
378
|
-
clients = None
|
|
379
|
-
node_types = build_node_type_map(devices, clients, client_mode=args.client_scope)
|
|
380
|
-
content = render_mermaid(
|
|
381
|
-
edges,
|
|
382
|
-
direction=args.direction,
|
|
383
|
-
node_types=node_types,
|
|
384
|
-
theme=mermaid_theme,
|
|
385
|
-
)
|
|
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:
|
|
449
|
-
if legend_style == "compact":
|
|
450
|
-
return (
|
|
451
|
-
'<div class="unifi-legend" data-unifi-legend>\n'
|
|
452
|
-
+ render_legend_compact(theme=mermaid_theme)
|
|
453
|
-
+ "</div>"
|
|
454
|
-
)
|
|
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>"
|
|
475
|
-
)
|
|
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:
|
|
489
|
-
return (
|
|
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"
|
|
498
|
-
)
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
def _write_mkdocs_sidebar_assets(output_path: str) -> None:
|
|
502
|
-
from pathlib import Path
|
|
503
|
-
|
|
504
|
-
output_dir = Path(output_path).resolve().parent
|
|
505
|
-
assets_dir = output_dir / "assets"
|
|
506
|
-
assets_dir.mkdir(parents=True, exist_ok=True)
|
|
507
|
-
(assets_dir / "legend.js").write_text(
|
|
508
|
-
(
|
|
509
|
-
'document.addEventListener("DOMContentLoaded", () => {\n'
|
|
510
|
-
' const legends = document.querySelectorAll("[data-unifi-legend]");\n'
|
|
511
|
-
' const sidebar = document.querySelector(".md-sidebar--secondary .md-sidebar__scrollwrap");\n'
|
|
512
|
-
" if (!legends.length || !sidebar) {\n"
|
|
513
|
-
" return;\n"
|
|
514
|
-
" }\n"
|
|
515
|
-
' const wrapper = document.createElement("div");\n'
|
|
516
|
-
' wrapper.className = "unifi-legend-sidebar";\n'
|
|
517
|
-
' const title = document.createElement("div");\n'
|
|
518
|
-
' title.className = "unifi-legend-title";\n'
|
|
519
|
-
' title.textContent = "Legend";\n'
|
|
520
|
-
" wrapper.appendChild(title);\n"
|
|
521
|
-
" legends.forEach((legend) => {\n"
|
|
522
|
-
" wrapper.appendChild(legend.cloneNode(true));\n"
|
|
523
|
-
' legend.classList.add("unifi-legend-hidden");\n'
|
|
524
|
-
" });\n"
|
|
525
|
-
" sidebar.appendChild(wrapper);\n"
|
|
526
|
-
"});\n"
|
|
527
|
-
),
|
|
528
|
-
encoding="utf-8",
|
|
529
|
-
)
|
|
530
|
-
(assets_dir / "legend.css").write_text(
|
|
531
|
-
(
|
|
532
|
-
".unifi-legend-hidden,\n"
|
|
533
|
-
".unifi-legend-hidden.unifi-legend,\n"
|
|
534
|
-
".unifi-legend-hidden.unifi-legend--light,\n"
|
|
535
|
-
".unifi-legend-hidden.unifi-legend--dark {\n"
|
|
536
|
-
" display: none !important;\n"
|
|
537
|
-
"}\n\n"
|
|
538
|
-
".unifi-legend-sidebar {\n"
|
|
539
|
-
" margin-top: 1rem;\n"
|
|
540
|
-
" padding: 0.5rem 0.75rem;\n"
|
|
541
|
-
" border: 1px solid rgba(0, 0, 0, 0.08);\n"
|
|
542
|
-
" border-radius: 6px;\n"
|
|
543
|
-
" font-size: 0.75rem;\n"
|
|
544
|
-
"}\n\n"
|
|
545
|
-
".unifi-legend-title {\n"
|
|
546
|
-
" font-weight: 600;\n"
|
|
547
|
-
" margin-bottom: 0.5rem;\n"
|
|
548
|
-
"}\n\n"
|
|
549
|
-
".unifi-legend-sidebar table {\n"
|
|
550
|
-
" width: 100%;\n"
|
|
551
|
-
" border-collapse: collapse;\n"
|
|
552
|
-
"}\n\n"
|
|
553
|
-
".unifi-legend-sidebar td,\n"
|
|
554
|
-
".unifi-legend-sidebar th {\n"
|
|
555
|
-
" border: 0;\n"
|
|
556
|
-
" padding: 0.15rem 0;\n"
|
|
557
|
-
"}\n\n"
|
|
558
|
-
".unifi-legend-sidebar svg {\n"
|
|
559
|
-
" display: block;\n"
|
|
560
|
-
"}\n"
|
|
561
|
-
),
|
|
562
|
-
encoding="utf-8",
|
|
563
|
-
)
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
def _render_svg_output(
|
|
567
|
-
args: argparse.Namespace,
|
|
568
|
-
devices: list[Device],
|
|
569
|
-
topology: TopologyResult,
|
|
570
|
-
config: Config | None,
|
|
571
|
-
site: str,
|
|
572
|
-
svg_theme: SvgTheme,
|
|
573
|
-
*,
|
|
574
|
-
clients_override: list[object] | None = None,
|
|
575
|
-
) -> str:
|
|
576
|
-
edges, _has_tree = _select_edges(topology)
|
|
577
|
-
edges, clients = _build_edges_with_clients(
|
|
578
|
-
args,
|
|
579
|
-
edges,
|
|
580
|
-
devices,
|
|
581
|
-
config,
|
|
582
|
-
site,
|
|
583
|
-
clients_override=clients_override,
|
|
584
|
-
)
|
|
585
|
-
options = SvgOptions(width=args.svg_width, height=args.svg_height)
|
|
586
|
-
if args.format == "svg-iso":
|
|
587
|
-
from ..render.svg import render_svg_isometric
|
|
588
|
-
|
|
589
|
-
return render_svg_isometric(
|
|
590
|
-
edges,
|
|
591
|
-
node_types=build_node_type_map(devices, clients, client_mode=args.client_scope),
|
|
592
|
-
options=options,
|
|
593
|
-
theme=svg_theme,
|
|
594
|
-
)
|
|
595
|
-
return render_svg(
|
|
596
|
-
edges,
|
|
597
|
-
node_types=build_node_type_map(devices, clients, client_mode=args.client_scope),
|
|
598
|
-
options=options,
|
|
599
|
-
theme=svg_theme,
|
|
600
|
-
)
|
|
601
|
-
|
|
602
|
-
|
|
603
46
|
def _handle_generate_mock(args: argparse.Namespace) -> int | None:
|
|
604
47
|
if not args.generate_mock:
|
|
605
48
|
return None
|
|
606
49
|
try:
|
|
607
|
-
from ..
|
|
50
|
+
from ..model.mock import MockOptions, mock_payload_json
|
|
608
51
|
except ImportError as exc:
|
|
609
52
|
logging.error("Faker is required for --generate-mock: %s", exc)
|
|
610
53
|
return 2
|
|
@@ -636,192 +79,6 @@ def _load_runtime_context(
|
|
|
636
79
|
return config, site, None, None
|
|
637
80
|
|
|
638
81
|
|
|
639
|
-
def _render_lldp_format(
|
|
640
|
-
args: argparse.Namespace,
|
|
641
|
-
*,
|
|
642
|
-
config: Config | None,
|
|
643
|
-
site: str,
|
|
644
|
-
mock_devices: list[object] | None,
|
|
645
|
-
mock_clients: list[object] | None,
|
|
646
|
-
) -> int:
|
|
647
|
-
try:
|
|
648
|
-
_raw_devices, devices = _load_devices_data(
|
|
649
|
-
args,
|
|
650
|
-
config,
|
|
651
|
-
site,
|
|
652
|
-
raw_devices_override=mock_devices,
|
|
653
|
-
)
|
|
654
|
-
except Exception as exc:
|
|
655
|
-
logging.error("Failed to load devices: %s", exc)
|
|
656
|
-
return 1
|
|
657
|
-
if mock_clients is None:
|
|
658
|
-
if config is None:
|
|
659
|
-
logging.error("Mock data required for client rendering")
|
|
660
|
-
return 2
|
|
661
|
-
clients = list(fetch_clients(config, site=site))
|
|
662
|
-
else:
|
|
663
|
-
clients = mock_clients
|
|
664
|
-
content = render_lldp_md(
|
|
665
|
-
devices,
|
|
666
|
-
clients=clients,
|
|
667
|
-
include_ports=args.include_ports,
|
|
668
|
-
show_clients=args.include_clients,
|
|
669
|
-
client_mode=args.client_scope,
|
|
670
|
-
)
|
|
671
|
-
write_output(content, output_path=args.output, stdout=args.stdout)
|
|
672
|
-
return 0
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
def _render_standard_format(
|
|
676
|
-
args: argparse.Namespace,
|
|
677
|
-
*,
|
|
678
|
-
config: Config | None,
|
|
679
|
-
site: str,
|
|
680
|
-
mock_devices: list[object] | None,
|
|
681
|
-
mock_clients: list[object] | None,
|
|
682
|
-
mermaid_theme: MermaidTheme,
|
|
683
|
-
svg_theme: SvgTheme,
|
|
684
|
-
) -> int:
|
|
685
|
-
topology_result = _load_topology_for_render(
|
|
686
|
-
args,
|
|
687
|
-
config=config,
|
|
688
|
-
site=site,
|
|
689
|
-
mock_devices=mock_devices,
|
|
690
|
-
)
|
|
691
|
-
if topology_result is None:
|
|
692
|
-
return 1
|
|
693
|
-
devices, topology = topology_result
|
|
694
|
-
|
|
695
|
-
if args.format == "mermaid":
|
|
696
|
-
content = _render_mermaid_output(
|
|
697
|
-
args,
|
|
698
|
-
devices,
|
|
699
|
-
topology,
|
|
700
|
-
config,
|
|
701
|
-
site,
|
|
702
|
-
mermaid_theme,
|
|
703
|
-
clients_override=mock_clients,
|
|
704
|
-
)
|
|
705
|
-
elif args.format == "mkdocs":
|
|
706
|
-
content = _render_mkdocs_format(
|
|
707
|
-
args,
|
|
708
|
-
devices=devices,
|
|
709
|
-
topology=topology,
|
|
710
|
-
config=config,
|
|
711
|
-
site=site,
|
|
712
|
-
mermaid_theme=mermaid_theme,
|
|
713
|
-
mock_clients=mock_clients,
|
|
714
|
-
)
|
|
715
|
-
if content is None:
|
|
716
|
-
return 2
|
|
717
|
-
elif args.format in {"svg", "svg-iso"}:
|
|
718
|
-
content = _render_svg_output(
|
|
719
|
-
args,
|
|
720
|
-
devices,
|
|
721
|
-
topology,
|
|
722
|
-
config,
|
|
723
|
-
site,
|
|
724
|
-
svg_theme,
|
|
725
|
-
clients_override=mock_clients,
|
|
726
|
-
)
|
|
727
|
-
else:
|
|
728
|
-
logging.error("Unsupported format: %s", args.format)
|
|
729
|
-
return 2
|
|
730
|
-
|
|
731
|
-
write_output(content, output_path=args.output, stdout=args.stdout)
|
|
732
|
-
return 0
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
def _load_topology_for_render(
|
|
736
|
-
args: argparse.Namespace,
|
|
737
|
-
*,
|
|
738
|
-
config: Config | None,
|
|
739
|
-
site: str,
|
|
740
|
-
mock_devices: list[object] | None,
|
|
741
|
-
) -> tuple[list[Device], TopologyResult] | None:
|
|
742
|
-
try:
|
|
743
|
-
include_ports = True if args.format == "mkdocs" else None
|
|
744
|
-
devices, _gateways, topology = _build_topology_data(
|
|
745
|
-
args,
|
|
746
|
-
config,
|
|
747
|
-
site,
|
|
748
|
-
include_ports=include_ports,
|
|
749
|
-
raw_devices_override=mock_devices,
|
|
750
|
-
)
|
|
751
|
-
except Exception as exc:
|
|
752
|
-
logging.error("Failed to build topology: %s", exc)
|
|
753
|
-
return None
|
|
754
|
-
return devices, topology
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
def _load_dark_mermaid_theme() -> MermaidTheme | None:
|
|
758
|
-
dark_theme_path = Path(__file__).resolve().parents[1] / "assets" / "themes" / "dark.yaml"
|
|
759
|
-
try:
|
|
760
|
-
dark_theme, _ = load_theme(dark_theme_path)
|
|
761
|
-
except Exception as exc: # noqa: BLE001
|
|
762
|
-
logger.warning("Failed to load dark theme: %s", exc)
|
|
763
|
-
return None
|
|
764
|
-
return dark_theme
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
def _resolve_mkdocs_client_ports(
|
|
768
|
-
args: argparse.Namespace,
|
|
769
|
-
devices: list[Device],
|
|
770
|
-
config: Config | None,
|
|
771
|
-
site: str,
|
|
772
|
-
mock_clients: list[object] | None,
|
|
773
|
-
) -> tuple[ClientPortMap | None, int | None]:
|
|
774
|
-
if not args.include_clients:
|
|
775
|
-
return None, None
|
|
776
|
-
if mock_clients is None:
|
|
777
|
-
if config is None:
|
|
778
|
-
return None, 2
|
|
779
|
-
clients = list(fetch_clients(config, site=site))
|
|
780
|
-
else:
|
|
781
|
-
clients = mock_clients
|
|
782
|
-
client_ports = build_client_port_map(devices, clients, client_mode=args.client_scope)
|
|
783
|
-
return client_ports, None
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
def _render_mkdocs_format(
|
|
787
|
-
args: argparse.Namespace,
|
|
788
|
-
*,
|
|
789
|
-
devices: list[Device],
|
|
790
|
-
topology: TopologyResult,
|
|
791
|
-
config: Config | None,
|
|
792
|
-
site: str,
|
|
793
|
-
mermaid_theme: MermaidTheme,
|
|
794
|
-
mock_clients: list[object] | None,
|
|
795
|
-
) -> str | None:
|
|
796
|
-
if args.mkdocs_sidebar_legend and not args.output:
|
|
797
|
-
logging.error("--mkdocs-sidebar-legend requires --output")
|
|
798
|
-
return None
|
|
799
|
-
if args.mkdocs_sidebar_legend:
|
|
800
|
-
_write_mkdocs_sidebar_assets(args.output)
|
|
801
|
-
port_map = build_port_map(devices, only_unifi=args.only_unifi)
|
|
802
|
-
client_ports, error_code = _resolve_mkdocs_client_ports(
|
|
803
|
-
args,
|
|
804
|
-
devices,
|
|
805
|
-
config,
|
|
806
|
-
site,
|
|
807
|
-
mock_clients,
|
|
808
|
-
)
|
|
809
|
-
if error_code is not None:
|
|
810
|
-
logging.error("Mock data required for client rendering")
|
|
811
|
-
return None
|
|
812
|
-
dark_mermaid_theme = _load_dark_mermaid_theme() if args.mkdocs_dual_theme else None
|
|
813
|
-
return _render_mkdocs_output(
|
|
814
|
-
args,
|
|
815
|
-
devices,
|
|
816
|
-
topology,
|
|
817
|
-
mermaid_theme,
|
|
818
|
-
port_map,
|
|
819
|
-
client_ports,
|
|
820
|
-
args.mkdocs_timestamp_zone,
|
|
821
|
-
dark_mermaid_theme,
|
|
822
|
-
)
|
|
823
|
-
|
|
824
|
-
|
|
825
82
|
def main(argv: list[str] | None = None) -> int:
|
|
826
83
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
|
827
84
|
args = _parse_args(argv)
|
|
@@ -836,12 +93,21 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
836
93
|
mermaid_theme, svg_theme = resolve_themes(args.theme_file)
|
|
837
94
|
|
|
838
95
|
if args.legend_only:
|
|
839
|
-
|
|
96
|
+
legend_style = resolve_legend_style(
|
|
97
|
+
format_name=args.format,
|
|
98
|
+
legend_style=args.legend_style,
|
|
99
|
+
)
|
|
100
|
+
content = render_legend_only(
|
|
101
|
+
legend_style=legend_style,
|
|
102
|
+
legend_scale=args.legend_scale,
|
|
103
|
+
markdown=args.markdown,
|
|
104
|
+
theme=mermaid_theme,
|
|
105
|
+
)
|
|
840
106
|
write_output(content, output_path=args.output, stdout=args.stdout)
|
|
841
107
|
return 0
|
|
842
108
|
|
|
843
109
|
if args.format == "lldp-md":
|
|
844
|
-
return
|
|
110
|
+
return render_lldp_format(
|
|
845
111
|
args,
|
|
846
112
|
config=config,
|
|
847
113
|
site=site,
|
|
@@ -849,7 +115,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
849
115
|
mock_clients=mock_clients,
|
|
850
116
|
)
|
|
851
117
|
|
|
852
|
-
return
|
|
118
|
+
return render_standard_format(
|
|
853
119
|
args,
|
|
854
120
|
config=config,
|
|
855
121
|
site=site,
|