unifi-network-maps 1.3.0__py3-none-any.whl → 1.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- unifi_network_maps/__init__.py +1 -1
- unifi_network_maps/adapters/unifi.py +90 -9
- unifi_network_maps/cli/main.py +358 -25
- unifi_network_maps/io/mock_data.py +23 -0
- unifi_network_maps/io/mock_generate.py +299 -0
- unifi_network_maps/model/lldp.py +26 -12
- unifi_network_maps/model/topology.py +141 -7
- unifi_network_maps/render/device_ports_md.py +462 -0
- unifi_network_maps/render/lldp_md.py +275 -0
- unifi_network_maps/render/mermaid.py +67 -3
- unifi_network_maps/render/svg.py +18 -6
- {unifi_network_maps-1.3.0.dist-info → unifi_network_maps-1.4.0.dist-info}/METADATA +109 -18
- {unifi_network_maps-1.3.0.dist-info → unifi_network_maps-1.4.0.dist-info}/RECORD +17 -14
- unifi_network_maps-1.3.0.dist-info/licenses/LICENSES.md +0 -10
- {unifi_network_maps-1.3.0.dist-info → unifi_network_maps-1.4.0.dist-info}/WHEEL +0 -0
- {unifi_network_maps-1.3.0.dist-info → unifi_network_maps-1.4.0.dist-info}/entry_points.txt +0 -0
- {unifi_network_maps-1.3.0.dist-info → unifi_network_maps-1.4.0.dist-info}/licenses/LICENSE +0 -0
- {unifi_network_maps-1.3.0.dist-info → unifi_network_maps-1.4.0.dist-info}/top_level.txt +0 -0
unifi_network_maps/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "1.
|
|
1
|
+
__version__ = "1.4.0"
|
|
@@ -39,30 +39,83 @@ def _cache_key(*parts: str) -> str:
|
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
def _load_cache(path: Path, ttl_seconds: int) -> object | None:
|
|
42
|
-
|
|
42
|
+
data, age = _load_cache_with_age(path)
|
|
43
|
+
if data is None:
|
|
43
44
|
return None
|
|
45
|
+
if ttl_seconds <= 0:
|
|
46
|
+
return None
|
|
47
|
+
if age is None or age > ttl_seconds:
|
|
48
|
+
return None
|
|
49
|
+
return data
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _load_cache_with_age(path: Path) -> tuple[object | None, float | None]:
|
|
53
|
+
if not path.exists():
|
|
54
|
+
return None, None
|
|
44
55
|
try:
|
|
45
56
|
payload = pickle.loads(path.read_bytes())
|
|
46
57
|
except Exception as exc:
|
|
47
58
|
logger.debug("Failed to read cache %s: %s", path, exc)
|
|
48
|
-
return None
|
|
59
|
+
return None, None
|
|
49
60
|
timestamp = payload.get("timestamp")
|
|
50
61
|
if not isinstance(timestamp, int | float):
|
|
51
|
-
return None
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
62
|
+
return None, None
|
|
63
|
+
data = payload.get("data")
|
|
64
|
+
if not isinstance(data, list):
|
|
65
|
+
logger.debug("Cached payload at %s is not a list", path)
|
|
66
|
+
return None, None
|
|
67
|
+
return data, time.time() - timestamp
|
|
55
68
|
|
|
56
69
|
|
|
57
70
|
def _save_cache(path: Path, data: object) -> None:
|
|
58
71
|
try:
|
|
59
72
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
60
73
|
payload = {"timestamp": time.time(), "data": data}
|
|
61
|
-
path.
|
|
74
|
+
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
|
75
|
+
tmp_path.write_bytes(pickle.dumps(payload))
|
|
76
|
+
tmp_path.replace(path)
|
|
62
77
|
except Exception as exc:
|
|
63
78
|
logger.debug("Failed to write cache %s: %s", path, exc)
|
|
64
79
|
|
|
65
80
|
|
|
81
|
+
def _retry_attempts() -> int:
|
|
82
|
+
value = os.environ.get("UNIFI_RETRY_ATTEMPTS", "").strip()
|
|
83
|
+
if not value:
|
|
84
|
+
return 2
|
|
85
|
+
if value.isdigit():
|
|
86
|
+
return max(1, int(value))
|
|
87
|
+
logger.warning("Invalid UNIFI_RETRY_ATTEMPTS value: %s", value)
|
|
88
|
+
return 2
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _retry_backoff_seconds() -> float:
|
|
92
|
+
value = os.environ.get("UNIFI_RETRY_BACKOFF_SECONDS", "").strip()
|
|
93
|
+
if not value:
|
|
94
|
+
return 0.5
|
|
95
|
+
try:
|
|
96
|
+
return max(0.0, float(value))
|
|
97
|
+
except ValueError:
|
|
98
|
+
logger.warning("Invalid UNIFI_RETRY_BACKOFF_SECONDS value: %s", value)
|
|
99
|
+
return 0.5
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _call_with_retries(operation: str, func) -> object:
|
|
103
|
+
attempts = _retry_attempts()
|
|
104
|
+
backoff = _retry_backoff_seconds()
|
|
105
|
+
last_exc: Exception | None = None
|
|
106
|
+
for attempt in range(1, attempts + 1):
|
|
107
|
+
try:
|
|
108
|
+
return func()
|
|
109
|
+
except Exception as exc: # noqa: BLE001 - surface full error after retries
|
|
110
|
+
last_exc = exc
|
|
111
|
+
logger.warning("Failed %s attempt %d/%d: %s", operation, attempt, attempts, exc)
|
|
112
|
+
if attempt < attempts and backoff > 0:
|
|
113
|
+
time.sleep(backoff * attempt)
|
|
114
|
+
if last_exc:
|
|
115
|
+
raise last_exc
|
|
116
|
+
raise RuntimeError(f"Failed {operation}")
|
|
117
|
+
|
|
118
|
+
|
|
66
119
|
def _init_controller(config: Config, *, is_udm_pro: bool) -> UnifiController:
|
|
67
120
|
from unifi_controller_api import UnifiController
|
|
68
121
|
|
|
@@ -91,6 +144,7 @@ def fetch_devices(
|
|
|
91
144
|
ttl_seconds = _cache_ttl_seconds()
|
|
92
145
|
cache_path = _cache_dir() / f"devices_{_cache_key(config.url, site_name, str(detailed))}.pkl"
|
|
93
146
|
cached = _load_cache(cache_path, ttl_seconds)
|
|
147
|
+
stale_cached, cache_age = _load_cache_with_age(cache_path)
|
|
94
148
|
if cached is not None:
|
|
95
149
|
logger.info("Using cached devices (%d)", len(cached))
|
|
96
150
|
return cached
|
|
@@ -101,7 +155,20 @@ def fetch_devices(
|
|
|
101
155
|
logger.info("UDM Pro authentication failed, retrying legacy auth")
|
|
102
156
|
controller = _init_controller(config, is_udm_pro=False)
|
|
103
157
|
|
|
104
|
-
|
|
158
|
+
def _fetch() -> list[object]:
|
|
159
|
+
return controller.get_unifi_site_device(site_name=site_name, detailed=detailed, raw=False)
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
devices = _call_with_retries("device fetch", _fetch)
|
|
163
|
+
except Exception as exc: # noqa: BLE001 - fallback to cache
|
|
164
|
+
if stale_cached is not None:
|
|
165
|
+
logger.warning(
|
|
166
|
+
"Device fetch failed; using stale cache (%ds old): %s",
|
|
167
|
+
int(cache_age or 0),
|
|
168
|
+
exc,
|
|
169
|
+
)
|
|
170
|
+
return stale_cached
|
|
171
|
+
raise
|
|
105
172
|
_save_cache(cache_path, devices)
|
|
106
173
|
logger.info("Fetched %d devices", len(devices))
|
|
107
174
|
return devices
|
|
@@ -118,6 +185,7 @@ def fetch_clients(config: Config, *, site: str | None = None) -> Iterable[object
|
|
|
118
185
|
ttl_seconds = _cache_ttl_seconds()
|
|
119
186
|
cache_path = _cache_dir() / f"clients_{_cache_key(config.url, site_name)}.pkl"
|
|
120
187
|
cached = _load_cache(cache_path, ttl_seconds)
|
|
188
|
+
stale_cached, cache_age = _load_cache_with_age(cache_path)
|
|
121
189
|
if cached is not None:
|
|
122
190
|
logger.info("Using cached clients (%d)", len(cached))
|
|
123
191
|
return cached
|
|
@@ -128,7 +196,20 @@ def fetch_clients(config: Config, *, site: str | None = None) -> Iterable[object
|
|
|
128
196
|
logger.info("UDM Pro authentication failed, retrying legacy auth")
|
|
129
197
|
controller = _init_controller(config, is_udm_pro=False)
|
|
130
198
|
|
|
131
|
-
|
|
199
|
+
def _fetch() -> list[object]:
|
|
200
|
+
return controller.get_unifi_site_client(site_name=site_name, raw=True)
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
clients = _call_with_retries("client fetch", _fetch)
|
|
204
|
+
except Exception as exc: # noqa: BLE001 - fallback to cache
|
|
205
|
+
if stale_cached is not None:
|
|
206
|
+
logger.warning(
|
|
207
|
+
"Client fetch failed; using stale cache (%ds old): %s",
|
|
208
|
+
int(cache_age or 0),
|
|
209
|
+
exc,
|
|
210
|
+
)
|
|
211
|
+
return stale_cached
|
|
212
|
+
raise
|
|
132
213
|
_save_cache(cache_path, clients)
|
|
133
214
|
logger.info("Fetched %d clients", len(clients))
|
|
134
215
|
return clients
|
unifi_network_maps/cli/main.py
CHANGED
|
@@ -9,16 +9,23 @@ from ..adapters.config import Config
|
|
|
9
9
|
from ..adapters.unifi import fetch_clients, fetch_devices
|
|
10
10
|
from ..io.debug import debug_dump_devices
|
|
11
11
|
from ..io.export import write_output
|
|
12
|
+
from ..io.mock_data import load_mock_data
|
|
12
13
|
from ..model.topology import (
|
|
14
|
+
ClientPortMap,
|
|
13
15
|
Device,
|
|
16
|
+
PortMap,
|
|
14
17
|
build_client_edges,
|
|
18
|
+
build_client_port_map,
|
|
15
19
|
build_device_index,
|
|
16
20
|
build_node_type_map,
|
|
21
|
+
build_port_map,
|
|
17
22
|
build_topology,
|
|
18
23
|
group_devices_by_type,
|
|
19
24
|
normalize_devices,
|
|
20
25
|
)
|
|
21
|
-
from ..render.
|
|
26
|
+
from ..render.device_ports_md import render_device_port_overview
|
|
27
|
+
from ..render.lldp_md import render_lldp_md
|
|
28
|
+
from ..render.mermaid import render_legend, render_legend_compact, render_mermaid
|
|
22
29
|
from ..render.mermaid_theme import MermaidTheme
|
|
23
30
|
from ..render.svg import SvgOptions, render_svg
|
|
24
31
|
from ..render.svg_theme import SvgTheme
|
|
@@ -41,6 +48,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
41
48
|
description="Generate network maps from UniFi LLDP data, as mermaid or SVG"
|
|
42
49
|
)
|
|
43
50
|
_add_source_args(parser.add_argument_group("Source"))
|
|
51
|
+
_add_mock_args(parser.add_argument_group("Mock"))
|
|
44
52
|
_add_functional_args(parser.add_argument_group("Functional"))
|
|
45
53
|
_add_mermaid_args(parser.add_argument_group("Mermaid"))
|
|
46
54
|
_add_svg_args(parser.add_argument_group("SVG"))
|
|
@@ -56,6 +64,44 @@ def _add_source_args(parser: argparse._ArgumentGroup) -> None:
|
|
|
56
64
|
default=None,
|
|
57
65
|
help="Path to .env file (overrides default .env discovery)",
|
|
58
66
|
)
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"--mock-data",
|
|
69
|
+
default=None,
|
|
70
|
+
help="Path to mock data JSON (skips UniFi API calls)",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _add_mock_args(parser: argparse._ArgumentGroup) -> None:
|
|
75
|
+
parser.add_argument(
|
|
76
|
+
"--generate-mock",
|
|
77
|
+
default=None,
|
|
78
|
+
help="Write mock data JSON to the given path and exit",
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument("--mock-seed", type=int, default=1337, help="Seed for mock generation")
|
|
81
|
+
parser.add_argument(
|
|
82
|
+
"--mock-switches",
|
|
83
|
+
type=int,
|
|
84
|
+
default=1,
|
|
85
|
+
help="Number of switches to generate (default: 1)",
|
|
86
|
+
)
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--mock-aps",
|
|
89
|
+
type=int,
|
|
90
|
+
default=2,
|
|
91
|
+
help="Number of access points to generate (default: 2)",
|
|
92
|
+
)
|
|
93
|
+
parser.add_argument(
|
|
94
|
+
"--mock-wired-clients",
|
|
95
|
+
type=int,
|
|
96
|
+
default=2,
|
|
97
|
+
help="Number of wired clients to generate (default: 2)",
|
|
98
|
+
)
|
|
99
|
+
parser.add_argument(
|
|
100
|
+
"--mock-wireless-clients",
|
|
101
|
+
type=int,
|
|
102
|
+
default=2,
|
|
103
|
+
help="Number of wireless clients to generate (default: 2)",
|
|
104
|
+
)
|
|
59
105
|
|
|
60
106
|
|
|
61
107
|
def _add_functional_args(parser: argparse._ArgumentGroup) -> None:
|
|
@@ -65,6 +111,12 @@ def _add_functional_args(parser: argparse._ArgumentGroup) -> None:
|
|
|
65
111
|
action="store_true",
|
|
66
112
|
help="Include active clients as leaf nodes",
|
|
67
113
|
)
|
|
114
|
+
parser.add_argument(
|
|
115
|
+
"--client-scope",
|
|
116
|
+
choices=["wired", "wireless", "all"],
|
|
117
|
+
default="wired",
|
|
118
|
+
help="Client types to include (default: wired)",
|
|
119
|
+
)
|
|
68
120
|
parser.add_argument(
|
|
69
121
|
"--only-unifi", action="store_true", help="Only include neighbors that are UniFi devices"
|
|
70
122
|
)
|
|
@@ -77,6 +129,18 @@ def _add_mermaid_args(parser: argparse._ArgumentGroup) -> None:
|
|
|
77
129
|
action="store_true",
|
|
78
130
|
help="Group nodes by gateway/switch/ap in Mermaid subgraphs",
|
|
79
131
|
)
|
|
132
|
+
parser.add_argument(
|
|
133
|
+
"--legend-scale",
|
|
134
|
+
type=float,
|
|
135
|
+
default=1.0,
|
|
136
|
+
help="Scale legend font/link sizes for Mermaid output (default: 1.0)",
|
|
137
|
+
)
|
|
138
|
+
parser.add_argument(
|
|
139
|
+
"--legend-style",
|
|
140
|
+
default="auto",
|
|
141
|
+
choices=["auto", "compact", "diagram"],
|
|
142
|
+
help="Legend style (auto uses compact for mkdocs, diagram otherwise)",
|
|
143
|
+
)
|
|
80
144
|
parser.add_argument(
|
|
81
145
|
"--legend-only",
|
|
82
146
|
action="store_true",
|
|
@@ -94,7 +158,7 @@ def _add_general_render_args(parser: argparse._ArgumentGroup) -> None:
|
|
|
94
158
|
parser.add_argument(
|
|
95
159
|
"--format",
|
|
96
160
|
default="mermaid",
|
|
97
|
-
choices=["mermaid", "svg", "svg-iso"],
|
|
161
|
+
choices=["mermaid", "svg", "svg-iso", "lldp-md", "mkdocs"],
|
|
98
162
|
help="Output format",
|
|
99
163
|
)
|
|
100
164
|
parser.add_argument(
|
|
@@ -104,6 +168,11 @@ def _add_general_render_args(parser: argparse._ArgumentGroup) -> None:
|
|
|
104
168
|
)
|
|
105
169
|
parser.add_argument("--output", default=None, help="Output file path")
|
|
106
170
|
parser.add_argument("--stdout", action="store_true", help="Write output to stdout")
|
|
171
|
+
parser.add_argument(
|
|
172
|
+
"--mkdocs-sidebar-legend",
|
|
173
|
+
action="store_true",
|
|
174
|
+
help="For mkdocs output, write sidebar legend assets next to the output file",
|
|
175
|
+
)
|
|
107
176
|
|
|
108
177
|
|
|
109
178
|
def _add_debug_args(parser: argparse._ArgumentGroup) -> None:
|
|
@@ -138,8 +207,18 @@ def _resolve_site(args: argparse.Namespace, config: Config) -> str:
|
|
|
138
207
|
return args.site or config.site
|
|
139
208
|
|
|
140
209
|
|
|
210
|
+
def _resolve_legend_style(args: argparse.Namespace) -> str:
|
|
211
|
+
if args.legend_style == "auto":
|
|
212
|
+
return "compact" if args.format == "mkdocs" else "diagram"
|
|
213
|
+
return args.legend_style
|
|
214
|
+
|
|
215
|
+
|
|
141
216
|
def _render_legend_only(args: argparse.Namespace, mermaid_theme: MermaidTheme) -> str:
|
|
142
|
-
|
|
217
|
+
legend_style = _resolve_legend_style(args)
|
|
218
|
+
if legend_style == "compact":
|
|
219
|
+
content = "# Legend\n\n" + render_legend_compact(theme=mermaid_theme)
|
|
220
|
+
else:
|
|
221
|
+
content = render_legend(theme=mermaid_theme, legend_scale=args.legend_scale)
|
|
143
222
|
if args.markdown:
|
|
144
223
|
content = f"""```mermaid
|
|
145
224
|
{content}```
|
|
@@ -147,18 +226,44 @@ def _render_legend_only(args: argparse.Namespace, mermaid_theme: MermaidTheme) -
|
|
|
147
226
|
return content
|
|
148
227
|
|
|
149
228
|
|
|
150
|
-
def
|
|
151
|
-
args: argparse.Namespace,
|
|
152
|
-
|
|
153
|
-
|
|
229
|
+
def _load_devices_data(
|
|
230
|
+
args: argparse.Namespace,
|
|
231
|
+
config: Config | None,
|
|
232
|
+
site: str,
|
|
233
|
+
*,
|
|
234
|
+
raw_devices_override: list[object] | None = None,
|
|
235
|
+
) -> tuple[list[object], list[Device]]:
|
|
236
|
+
if raw_devices_override is None:
|
|
237
|
+
if config is None:
|
|
238
|
+
raise ValueError("Config required to fetch devices")
|
|
239
|
+
raw_devices = list(fetch_devices(config, site=site, detailed=True))
|
|
240
|
+
else:
|
|
241
|
+
raw_devices = raw_devices_override
|
|
154
242
|
devices = normalize_devices(raw_devices)
|
|
155
243
|
if args.debug_dump:
|
|
156
244
|
debug_dump_devices(raw_devices, devices, sample_count=max(0, args.debug_sample))
|
|
245
|
+
return raw_devices, devices
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _build_topology_data(
|
|
249
|
+
args: argparse.Namespace,
|
|
250
|
+
config: Config | None,
|
|
251
|
+
site: str,
|
|
252
|
+
*,
|
|
253
|
+
include_ports: bool | None = None,
|
|
254
|
+
raw_devices_override: list[object] | None = None,
|
|
255
|
+
) -> tuple[list[Device], list[str], object]:
|
|
256
|
+
_raw_devices, devices = _load_devices_data(
|
|
257
|
+
args,
|
|
258
|
+
config,
|
|
259
|
+
site,
|
|
260
|
+
raw_devices_override=raw_devices_override,
|
|
261
|
+
)
|
|
157
262
|
groups_for_rank = group_devices_by_type(devices)
|
|
158
263
|
gateways = groups_for_rank.get("gateway", [])
|
|
159
264
|
topology = build_topology(
|
|
160
265
|
devices,
|
|
161
|
-
include_ports=args.include_ports,
|
|
266
|
+
include_ports=include_ports if include_ports is not None else args.include_ports,
|
|
162
267
|
only_unifi=args.only_unifi,
|
|
163
268
|
gateways=gateways,
|
|
164
269
|
)
|
|
@@ -169,14 +274,26 @@ def _build_edges_with_clients(
|
|
|
169
274
|
args: argparse.Namespace,
|
|
170
275
|
edges: list,
|
|
171
276
|
devices: list[Device],
|
|
172
|
-
config: Config,
|
|
277
|
+
config: Config | None,
|
|
173
278
|
site: str,
|
|
279
|
+
*,
|
|
280
|
+
clients_override: list[object] | None = None,
|
|
174
281
|
) -> tuple[list, list | None]:
|
|
175
282
|
clients = None
|
|
176
283
|
if args.include_clients:
|
|
177
|
-
|
|
284
|
+
if clients_override is None:
|
|
285
|
+
if config is None:
|
|
286
|
+
raise ValueError("Config required to fetch clients")
|
|
287
|
+
clients = list(fetch_clients(config, site=site))
|
|
288
|
+
else:
|
|
289
|
+
clients = clients_override
|
|
178
290
|
device_index = build_device_index(devices)
|
|
179
|
-
edges = edges + build_client_edges(
|
|
291
|
+
edges = edges + build_client_edges(
|
|
292
|
+
clients,
|
|
293
|
+
device_index,
|
|
294
|
+
include_ports=args.include_ports,
|
|
295
|
+
client_mode=args.client_scope,
|
|
296
|
+
)
|
|
180
297
|
return edges, clients
|
|
181
298
|
|
|
182
299
|
|
|
@@ -191,12 +308,21 @@ def _render_mermaid_output(
|
|
|
191
308
|
args: argparse.Namespace,
|
|
192
309
|
devices: list[Device],
|
|
193
310
|
topology: object,
|
|
194
|
-
config: Config,
|
|
311
|
+
config: Config | None,
|
|
195
312
|
site: str,
|
|
196
313
|
mermaid_theme: MermaidTheme,
|
|
314
|
+
*,
|
|
315
|
+
clients_override: list[object] | None = None,
|
|
197
316
|
) -> str:
|
|
198
317
|
edges, _has_tree = _select_edges(topology)
|
|
199
|
-
edges, clients = _build_edges_with_clients(
|
|
318
|
+
edges, clients = _build_edges_with_clients(
|
|
319
|
+
args,
|
|
320
|
+
edges,
|
|
321
|
+
devices,
|
|
322
|
+
config,
|
|
323
|
+
site,
|
|
324
|
+
clients_override=clients_override,
|
|
325
|
+
)
|
|
200
326
|
groups = None
|
|
201
327
|
group_order = None
|
|
202
328
|
if args.group_by_type:
|
|
@@ -207,7 +333,7 @@ def _render_mermaid_output(
|
|
|
207
333
|
direction=args.direction,
|
|
208
334
|
groups=groups,
|
|
209
335
|
group_order=group_order,
|
|
210
|
-
node_types=build_node_type_map(devices, clients),
|
|
336
|
+
node_types=build_node_type_map(devices, clients, client_mode=args.client_scope),
|
|
211
337
|
theme=mermaid_theme,
|
|
212
338
|
)
|
|
213
339
|
if args.markdown:
|
|
@@ -217,29 +343,138 @@ def _render_mermaid_output(
|
|
|
217
343
|
return content
|
|
218
344
|
|
|
219
345
|
|
|
220
|
-
def
|
|
346
|
+
def _render_mkdocs_output(
|
|
221
347
|
args: argparse.Namespace,
|
|
222
348
|
devices: list[Device],
|
|
223
349
|
topology: object,
|
|
224
350
|
config: Config,
|
|
225
351
|
site: str,
|
|
352
|
+
mermaid_theme: MermaidTheme,
|
|
353
|
+
port_map: PortMap,
|
|
354
|
+
client_ports: ClientPortMap | None,
|
|
355
|
+
) -> str:
|
|
356
|
+
edges, _has_tree = _select_edges(topology)
|
|
357
|
+
clients = None
|
|
358
|
+
content = render_mermaid(
|
|
359
|
+
edges,
|
|
360
|
+
direction=args.direction,
|
|
361
|
+
node_types=build_node_type_map(devices, clients, client_mode=args.client_scope),
|
|
362
|
+
theme=mermaid_theme,
|
|
363
|
+
)
|
|
364
|
+
legend_style = _resolve_legend_style(args)
|
|
365
|
+
if legend_style == "compact":
|
|
366
|
+
legend_block = (
|
|
367
|
+
'<div class="unifi-legend" data-unifi-legend>\n'
|
|
368
|
+
+ render_legend_compact(theme=mermaid_theme)
|
|
369
|
+
+ "</div>"
|
|
370
|
+
)
|
|
371
|
+
legend_header = ""
|
|
372
|
+
else:
|
|
373
|
+
legend_block = (
|
|
374
|
+
"```mermaid\n"
|
|
375
|
+
+ render_legend(theme=mermaid_theme, legend_scale=args.legend_scale)
|
|
376
|
+
+ "```"
|
|
377
|
+
)
|
|
378
|
+
legend_header = "## Legend\n\n"
|
|
379
|
+
return (
|
|
380
|
+
f"# UniFi network\n\n## Map\n\n```mermaid\n{content}```\n\n"
|
|
381
|
+
f"{legend_header}{legend_block}\n\n"
|
|
382
|
+
f"{render_device_port_overview(devices, port_map, client_ports=client_ports)}"
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _write_mkdocs_sidebar_assets(output_path: str) -> None:
|
|
387
|
+
from pathlib import Path
|
|
388
|
+
|
|
389
|
+
output_dir = Path(output_path).resolve().parent
|
|
390
|
+
assets_dir = output_dir / "assets"
|
|
391
|
+
assets_dir.mkdir(parents=True, exist_ok=True)
|
|
392
|
+
(assets_dir / "legend.js").write_text(
|
|
393
|
+
(
|
|
394
|
+
'document.addEventListener("DOMContentLoaded", () => {\n'
|
|
395
|
+
' const legend = document.querySelector("[data-unifi-legend]");\n'
|
|
396
|
+
' const sidebar = document.querySelector(".md-sidebar--secondary .md-sidebar__scrollwrap");\n'
|
|
397
|
+
" if (!legend || !sidebar) {\n"
|
|
398
|
+
" return;\n"
|
|
399
|
+
" }\n"
|
|
400
|
+
' const wrapper = document.createElement("div");\n'
|
|
401
|
+
' wrapper.className = "unifi-legend-sidebar";\n'
|
|
402
|
+
' const title = document.createElement("div");\n'
|
|
403
|
+
' title.className = "unifi-legend-title";\n'
|
|
404
|
+
' title.textContent = "Legend";\n'
|
|
405
|
+
" wrapper.appendChild(title);\n"
|
|
406
|
+
" wrapper.appendChild(legend.cloneNode(true));\n"
|
|
407
|
+
" sidebar.appendChild(wrapper);\n"
|
|
408
|
+
' legend.classList.add("unifi-legend-hidden");\n'
|
|
409
|
+
"});\n"
|
|
410
|
+
),
|
|
411
|
+
encoding="utf-8",
|
|
412
|
+
)
|
|
413
|
+
(assets_dir / "legend.css").write_text(
|
|
414
|
+
(
|
|
415
|
+
".unifi-legend-hidden {\n"
|
|
416
|
+
" display: none;\n"
|
|
417
|
+
"}\n\n"
|
|
418
|
+
".unifi-legend-sidebar {\n"
|
|
419
|
+
" margin-top: 1rem;\n"
|
|
420
|
+
" padding: 0.5rem 0.75rem;\n"
|
|
421
|
+
" border: 1px solid rgba(0, 0, 0, 0.08);\n"
|
|
422
|
+
" border-radius: 6px;\n"
|
|
423
|
+
" font-size: 0.75rem;\n"
|
|
424
|
+
"}\n\n"
|
|
425
|
+
".unifi-legend-title {\n"
|
|
426
|
+
" font-weight: 600;\n"
|
|
427
|
+
" margin-bottom: 0.5rem;\n"
|
|
428
|
+
"}\n\n"
|
|
429
|
+
".unifi-legend-sidebar table {\n"
|
|
430
|
+
" width: 100%;\n"
|
|
431
|
+
" border-collapse: collapse;\n"
|
|
432
|
+
"}\n\n"
|
|
433
|
+
".unifi-legend-sidebar td,\n"
|
|
434
|
+
".unifi-legend-sidebar th {\n"
|
|
435
|
+
" border: 0;\n"
|
|
436
|
+
" padding: 0.15rem 0;\n"
|
|
437
|
+
"}\n\n"
|
|
438
|
+
".unifi-legend-sidebar svg {\n"
|
|
439
|
+
" display: block;\n"
|
|
440
|
+
"}\n"
|
|
441
|
+
),
|
|
442
|
+
encoding="utf-8",
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _render_svg_output(
|
|
447
|
+
args: argparse.Namespace,
|
|
448
|
+
devices: list[Device],
|
|
449
|
+
topology: object,
|
|
450
|
+
config: Config | None,
|
|
451
|
+
site: str,
|
|
226
452
|
svg_theme: SvgTheme,
|
|
453
|
+
*,
|
|
454
|
+
clients_override: list[object] | None = None,
|
|
227
455
|
) -> str:
|
|
228
456
|
edges, _has_tree = _select_edges(topology)
|
|
229
|
-
edges, clients = _build_edges_with_clients(
|
|
457
|
+
edges, clients = _build_edges_with_clients(
|
|
458
|
+
args,
|
|
459
|
+
edges,
|
|
460
|
+
devices,
|
|
461
|
+
config,
|
|
462
|
+
site,
|
|
463
|
+
clients_override=clients_override,
|
|
464
|
+
)
|
|
230
465
|
options = SvgOptions(width=args.svg_width, height=args.svg_height)
|
|
231
466
|
if args.format == "svg-iso":
|
|
232
467
|
from ..render.svg import render_svg_isometric
|
|
233
468
|
|
|
234
469
|
return render_svg_isometric(
|
|
235
470
|
edges,
|
|
236
|
-
node_types=build_node_type_map(devices, clients),
|
|
471
|
+
node_types=build_node_type_map(devices, clients, client_mode=args.client_scope),
|
|
237
472
|
options=options,
|
|
238
473
|
theme=svg_theme,
|
|
239
474
|
)
|
|
240
475
|
return render_svg(
|
|
241
476
|
edges,
|
|
242
|
-
node_types=build_node_type_map(devices, clients),
|
|
477
|
+
node_types=build_node_type_map(devices, clients, client_mode=args.client_scope),
|
|
243
478
|
options=options,
|
|
244
479
|
theme=svg_theme,
|
|
245
480
|
)
|
|
@@ -248,10 +483,37 @@ def _render_svg_output(
|
|
|
248
483
|
def main(argv: list[str] | None = None) -> int:
|
|
249
484
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
|
250
485
|
args = _parse_args(argv)
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
486
|
+
if args.generate_mock:
|
|
487
|
+
try:
|
|
488
|
+
from ..io.mock_generate import MockOptions, mock_payload_json
|
|
489
|
+
except ImportError as exc:
|
|
490
|
+
logging.error("Faker is required for --generate-mock: %s", exc)
|
|
491
|
+
return 2
|
|
492
|
+
options = MockOptions(
|
|
493
|
+
seed=args.mock_seed,
|
|
494
|
+
switch_count=max(1, args.mock_switches),
|
|
495
|
+
ap_count=max(0, args.mock_aps),
|
|
496
|
+
wired_client_count=max(0, args.mock_wired_clients),
|
|
497
|
+
wireless_client_count=max(0, args.mock_wireless_clients),
|
|
498
|
+
)
|
|
499
|
+
content = mock_payload_json(options)
|
|
500
|
+
write_output(content, output_path=args.generate_mock, stdout=args.stdout)
|
|
501
|
+
return 0
|
|
502
|
+
mock_devices = None
|
|
503
|
+
mock_clients: list[object] | None = None
|
|
504
|
+
if args.mock_data:
|
|
505
|
+
try:
|
|
506
|
+
mock_devices, mock_clients = load_mock_data(args.mock_data)
|
|
507
|
+
except Exception as exc: # noqa: BLE001
|
|
508
|
+
logging.error("Failed to load mock data: %s", exc)
|
|
509
|
+
return 2
|
|
510
|
+
config = None
|
|
511
|
+
site = "mock"
|
|
512
|
+
else:
|
|
513
|
+
config = _load_config(args)
|
|
514
|
+
if config is None:
|
|
515
|
+
return 2
|
|
516
|
+
site = _resolve_site(args, config)
|
|
255
517
|
mermaid_theme, svg_theme = resolve_themes(args.theme_file)
|
|
256
518
|
|
|
257
519
|
if args.legend_only:
|
|
@@ -259,16 +521,87 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
259
521
|
write_output(content, output_path=args.output, stdout=args.stdout)
|
|
260
522
|
return 0
|
|
261
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
|
+
|
|
262
552
|
try:
|
|
263
|
-
|
|
553
|
+
include_ports = True if args.format == "mkdocs" else None
|
|
554
|
+
devices, _gateways, topology = _build_topology_data(
|
|
555
|
+
args,
|
|
556
|
+
config,
|
|
557
|
+
site,
|
|
558
|
+
include_ports=include_ports,
|
|
559
|
+
raw_devices_override=mock_devices,
|
|
560
|
+
)
|
|
264
561
|
except Exception as exc:
|
|
265
562
|
logging.error("Failed to build topology: %s", exc)
|
|
266
563
|
return 1
|
|
267
564
|
|
|
268
565
|
if args.format == "mermaid":
|
|
269
|
-
content = _render_mermaid_output(
|
|
566
|
+
content = _render_mermaid_output(
|
|
567
|
+
args,
|
|
568
|
+
devices,
|
|
569
|
+
topology,
|
|
570
|
+
config,
|
|
571
|
+
site,
|
|
572
|
+
mermaid_theme,
|
|
573
|
+
clients_override=mock_clients,
|
|
574
|
+
)
|
|
575
|
+
elif args.format == "mkdocs":
|
|
576
|
+
if args.mkdocs_sidebar_legend and not args.output:
|
|
577
|
+
logging.error("--mkdocs-sidebar-legend requires --output")
|
|
578
|
+
return 2
|
|
579
|
+
if args.mkdocs_sidebar_legend:
|
|
580
|
+
_write_mkdocs_sidebar_assets(args.output)
|
|
581
|
+
port_map = build_port_map(devices, only_unifi=args.only_unifi)
|
|
582
|
+
client_ports = None
|
|
583
|
+
if args.include_clients:
|
|
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
|
|
594
|
+
)
|
|
270
595
|
elif args.format in {"svg", "svg-iso"}:
|
|
271
|
-
content = _render_svg_output(
|
|
596
|
+
content = _render_svg_output(
|
|
597
|
+
args,
|
|
598
|
+
devices,
|
|
599
|
+
topology,
|
|
600
|
+
config,
|
|
601
|
+
site,
|
|
602
|
+
svg_theme,
|
|
603
|
+
clients_override=mock_clients,
|
|
604
|
+
)
|
|
272
605
|
else:
|
|
273
606
|
logging.error("Unsupported format: %s", args.format)
|
|
274
607
|
return 2
|