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.
- unifi_network_maps/__init__.py +1 -1
- unifi_network_maps/__main__.py +8 -0
- unifi_network_maps/adapters/unifi.py +90 -9
- unifi_network_maps/cli/main.py +320 -23
- 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 +111 -3
- unifi_network_maps/render/device_ports_md.py +462 -0
- unifi_network_maps/render/lldp_md.py +33 -12
- unifi_network_maps/render/mermaid.py +62 -3
- {unifi_network_maps-1.3.1.dist-info → unifi_network_maps-1.4.1.dist-info}/METADATA +89 -15
- {unifi_network_maps-1.3.1.dist-info → unifi_network_maps-1.4.1.dist-info}/RECORD +17 -13
- {unifi_network_maps-1.3.1.dist-info → unifi_network_maps-1.4.1.dist-info}/WHEEL +0 -0
- {unifi_network_maps-1.3.1.dist-info → unifi_network_maps-1.4.1.dist-info}/entry_points.txt +0 -0
- {unifi_network_maps-1.3.1.dist-info → unifi_network_maps-1.4.1.dist-info}/licenses/LICENSE +0 -0
- {unifi_network_maps-1.3.1.dist-info → unifi_network_maps-1.4.1.dist-info}/top_level.txt +0 -0
unifi_network_maps/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "1.
|
|
1
|
+
__version__ = "1.4.1"
|
|
@@ -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,17 +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
|
)
|
|
26
|
+
from ..render.device_ports_md import render_device_port_overview
|
|
21
27
|
from ..render.lldp_md import render_lldp_md
|
|
22
|
-
from ..render.mermaid import render_legend, render_mermaid
|
|
28
|
+
from ..render.mermaid import render_legend, render_legend_compact, render_mermaid
|
|
23
29
|
from ..render.mermaid_theme import MermaidTheme
|
|
24
30
|
from ..render.svg import SvgOptions, render_svg
|
|
25
31
|
from ..render.svg_theme import SvgTheme
|
|
@@ -42,6 +48,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
42
48
|
description="Generate network maps from UniFi LLDP data, as mermaid or SVG"
|
|
43
49
|
)
|
|
44
50
|
_add_source_args(parser.add_argument_group("Source"))
|
|
51
|
+
_add_mock_args(parser.add_argument_group("Mock"))
|
|
45
52
|
_add_functional_args(parser.add_argument_group("Functional"))
|
|
46
53
|
_add_mermaid_args(parser.add_argument_group("Mermaid"))
|
|
47
54
|
_add_svg_args(parser.add_argument_group("SVG"))
|
|
@@ -57,6 +64,44 @@ def _add_source_args(parser: argparse._ArgumentGroup) -> None:
|
|
|
57
64
|
default=None,
|
|
58
65
|
help="Path to .env file (overrides default .env discovery)",
|
|
59
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
|
+
)
|
|
60
105
|
|
|
61
106
|
|
|
62
107
|
def _add_functional_args(parser: argparse._ArgumentGroup) -> None:
|
|
@@ -84,6 +129,18 @@ def _add_mermaid_args(parser: argparse._ArgumentGroup) -> None:
|
|
|
84
129
|
action="store_true",
|
|
85
130
|
help="Group nodes by gateway/switch/ap in Mermaid subgraphs",
|
|
86
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
|
+
)
|
|
87
144
|
parser.add_argument(
|
|
88
145
|
"--legend-only",
|
|
89
146
|
action="store_true",
|
|
@@ -101,7 +158,7 @@ def _add_general_render_args(parser: argparse._ArgumentGroup) -> None:
|
|
|
101
158
|
parser.add_argument(
|
|
102
159
|
"--format",
|
|
103
160
|
default="mermaid",
|
|
104
|
-
choices=["mermaid", "svg", "svg-iso", "lldp-md"],
|
|
161
|
+
choices=["mermaid", "svg", "svg-iso", "lldp-md", "mkdocs"],
|
|
105
162
|
help="Output format",
|
|
106
163
|
)
|
|
107
164
|
parser.add_argument(
|
|
@@ -111,6 +168,11 @@ def _add_general_render_args(parser: argparse._ArgumentGroup) -> None:
|
|
|
111
168
|
)
|
|
112
169
|
parser.add_argument("--output", default=None, help="Output file path")
|
|
113
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
|
+
)
|
|
114
176
|
|
|
115
177
|
|
|
116
178
|
def _add_debug_args(parser: argparse._ArgumentGroup) -> None:
|
|
@@ -145,8 +207,18 @@ def _resolve_site(args: argparse.Namespace, config: Config) -> str:
|
|
|
145
207
|
return args.site or config.site
|
|
146
208
|
|
|
147
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
|
+
|
|
148
216
|
def _render_legend_only(args: argparse.Namespace, mermaid_theme: MermaidTheme) -> str:
|
|
149
|
-
|
|
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)
|
|
150
222
|
if args.markdown:
|
|
151
223
|
content = f"""```mermaid
|
|
152
224
|
{content}```
|
|
@@ -155,9 +227,18 @@ def _render_legend_only(args: argparse.Namespace, mermaid_theme: MermaidTheme) -
|
|
|
155
227
|
|
|
156
228
|
|
|
157
229
|
def _load_devices_data(
|
|
158
|
-
args: argparse.Namespace,
|
|
230
|
+
args: argparse.Namespace,
|
|
231
|
+
config: Config | None,
|
|
232
|
+
site: str,
|
|
233
|
+
*,
|
|
234
|
+
raw_devices_override: list[object] | None = None,
|
|
159
235
|
) -> tuple[list[object], list[Device]]:
|
|
160
|
-
|
|
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
|
|
161
242
|
devices = normalize_devices(raw_devices)
|
|
162
243
|
if args.debug_dump:
|
|
163
244
|
debug_dump_devices(raw_devices, devices, sample_count=max(0, args.debug_sample))
|
|
@@ -165,14 +246,24 @@ def _load_devices_data(
|
|
|
165
246
|
|
|
166
247
|
|
|
167
248
|
def _build_topology_data(
|
|
168
|
-
args: argparse.Namespace,
|
|
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,
|
|
169
255
|
) -> tuple[list[Device], list[str], object]:
|
|
170
|
-
_raw_devices, devices = _load_devices_data(
|
|
256
|
+
_raw_devices, devices = _load_devices_data(
|
|
257
|
+
args,
|
|
258
|
+
config,
|
|
259
|
+
site,
|
|
260
|
+
raw_devices_override=raw_devices_override,
|
|
261
|
+
)
|
|
171
262
|
groups_for_rank = group_devices_by_type(devices)
|
|
172
263
|
gateways = groups_for_rank.get("gateway", [])
|
|
173
264
|
topology = build_topology(
|
|
174
265
|
devices,
|
|
175
|
-
include_ports=args.include_ports,
|
|
266
|
+
include_ports=include_ports if include_ports is not None else args.include_ports,
|
|
176
267
|
only_unifi=args.only_unifi,
|
|
177
268
|
gateways=gateways,
|
|
178
269
|
)
|
|
@@ -183,12 +274,19 @@ def _build_edges_with_clients(
|
|
|
183
274
|
args: argparse.Namespace,
|
|
184
275
|
edges: list,
|
|
185
276
|
devices: list[Device],
|
|
186
|
-
config: Config,
|
|
277
|
+
config: Config | None,
|
|
187
278
|
site: str,
|
|
279
|
+
*,
|
|
280
|
+
clients_override: list[object] | None = None,
|
|
188
281
|
) -> tuple[list, list | None]:
|
|
189
282
|
clients = None
|
|
190
283
|
if args.include_clients:
|
|
191
|
-
|
|
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
|
|
192
290
|
device_index = build_device_index(devices)
|
|
193
291
|
edges = edges + build_client_edges(
|
|
194
292
|
clients,
|
|
@@ -210,12 +308,21 @@ def _render_mermaid_output(
|
|
|
210
308
|
args: argparse.Namespace,
|
|
211
309
|
devices: list[Device],
|
|
212
310
|
topology: object,
|
|
213
|
-
config: Config,
|
|
311
|
+
config: Config | None,
|
|
214
312
|
site: str,
|
|
215
313
|
mermaid_theme: MermaidTheme,
|
|
314
|
+
*,
|
|
315
|
+
clients_override: list[object] | None = None,
|
|
216
316
|
) -> str:
|
|
217
317
|
edges, _has_tree = _select_edges(topology)
|
|
218
|
-
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
|
+
)
|
|
219
326
|
groups = None
|
|
220
327
|
group_order = None
|
|
221
328
|
if args.group_by_type:
|
|
@@ -236,16 +343,125 @@ def _render_mermaid_output(
|
|
|
236
343
|
return content
|
|
237
344
|
|
|
238
345
|
|
|
239
|
-
def
|
|
346
|
+
def _render_mkdocs_output(
|
|
240
347
|
args: argparse.Namespace,
|
|
241
348
|
devices: list[Device],
|
|
242
349
|
topology: object,
|
|
243
350
|
config: Config,
|
|
244
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,
|
|
245
452
|
svg_theme: SvgTheme,
|
|
453
|
+
*,
|
|
454
|
+
clients_override: list[object] | None = None,
|
|
246
455
|
) -> str:
|
|
247
456
|
edges, _has_tree = _select_edges(topology)
|
|
248
|
-
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
|
+
)
|
|
249
465
|
options = SvgOptions(width=args.svg_width, height=args.svg_height)
|
|
250
466
|
if args.format == "svg-iso":
|
|
251
467
|
from ..render.svg import render_svg_isometric
|
|
@@ -267,10 +483,37 @@ def _render_svg_output(
|
|
|
267
483
|
def main(argv: list[str] | None = None) -> int:
|
|
268
484
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
|
269
485
|
args = _parse_args(argv)
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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)
|
|
274
517
|
mermaid_theme, svg_theme = resolve_themes(args.theme_file)
|
|
275
518
|
|
|
276
519
|
if args.legend_only:
|
|
@@ -280,11 +523,22 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
280
523
|
|
|
281
524
|
if args.format == "lldp-md":
|
|
282
525
|
try:
|
|
283
|
-
_raw_devices, devices = _load_devices_data(
|
|
526
|
+
_raw_devices, devices = _load_devices_data(
|
|
527
|
+
args,
|
|
528
|
+
config,
|
|
529
|
+
site,
|
|
530
|
+
raw_devices_override=mock_devices,
|
|
531
|
+
)
|
|
284
532
|
except Exception as exc:
|
|
285
533
|
logging.error("Failed to load devices: %s", exc)
|
|
286
534
|
return 1
|
|
287
|
-
|
|
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
|
|
288
542
|
content = render_lldp_md(
|
|
289
543
|
devices,
|
|
290
544
|
clients=clients,
|
|
@@ -296,15 +550,58 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
296
550
|
return 0
|
|
297
551
|
|
|
298
552
|
try:
|
|
299
|
-
|
|
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
|
+
)
|
|
300
561
|
except Exception as exc:
|
|
301
562
|
logging.error("Failed to build topology: %s", exc)
|
|
302
563
|
return 1
|
|
303
564
|
|
|
304
565
|
if args.format == "mermaid":
|
|
305
|
-
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
|
+
)
|
|
306
595
|
elif args.format in {"svg", "svg-iso"}:
|
|
307
|
-
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
|
+
)
|
|
308
605
|
else:
|
|
309
606
|
logging.error("Unsupported format: %s", args.format)
|
|
310
607
|
return 2
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Load mock UniFi data from JSON fixtures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _as_list(value: object, name: str) -> list[object]:
|
|
10
|
+
if value is None:
|
|
11
|
+
return []
|
|
12
|
+
if isinstance(value, list):
|
|
13
|
+
return value
|
|
14
|
+
raise ValueError(f"Mock data field '{name}' must be a list")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_mock_data(path: str) -> tuple[list[object], list[object]]:
|
|
18
|
+
payload = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
19
|
+
if not isinstance(payload, dict):
|
|
20
|
+
raise ValueError("Mock data must be a JSON object")
|
|
21
|
+
devices = _as_list(payload.get("devices"), "devices")
|
|
22
|
+
clients = _as_list(payload.get("clients"), "clients")
|
|
23
|
+
return devices, clients
|