unifi-network-maps 1.4.11__py3-none-any.whl → 1.4.13__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/config.py +6 -2
- unifi_network_maps/adapters/unifi.py +17 -7
- unifi_network_maps/cli/main.py +46 -3
- unifi_network_maps/cli/render.py +23 -5
- unifi_network_maps/cli/runtime.py +7 -1
- unifi_network_maps/io/export.py +11 -2
- unifi_network_maps/io/mkdocs_assets.py +4 -2
- unifi_network_maps/io/mock_data.py +4 -2
- unifi_network_maps/io/paths.py +197 -0
- unifi_network_maps/model/topology.py +91 -7
- unifi_network_maps/render/device_ports_md.py +27 -18
- unifi_network_maps/render/lldp_md.py +104 -5
- unifi_network_maps/render/theme.py +2 -1
- {unifi_network_maps-1.4.11.dist-info → unifi_network_maps-1.4.13.dist-info}/METADATA +3 -3
- {unifi_network_maps-1.4.11.dist-info → unifi_network_maps-1.4.13.dist-info}/RECORD +20 -19
- {unifi_network_maps-1.4.11.dist-info → unifi_network_maps-1.4.13.dist-info}/WHEEL +1 -1
- {unifi_network_maps-1.4.11.dist-info → unifi_network_maps-1.4.13.dist-info}/entry_points.txt +0 -0
- {unifi_network_maps-1.4.11.dist-info → unifi_network_maps-1.4.13.dist-info}/licenses/LICENSE +0 -0
- {unifi_network_maps-1.4.11.dist-info → unifi_network_maps-1.4.13.dist-info}/top_level.txt +0 -0
unifi_network_maps/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "1.4.
|
|
1
|
+
__version__ = "1.4.13"
|
|
@@ -4,6 +4,9 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
6
|
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ..io.paths import resolve_env_file
|
|
7
10
|
|
|
8
11
|
|
|
9
12
|
def _parse_bool(value: str | None, default: bool = True) -> bool:
|
|
@@ -26,13 +29,14 @@ class Config:
|
|
|
26
29
|
verify_ssl: bool
|
|
27
30
|
|
|
28
31
|
@classmethod
|
|
29
|
-
def from_env(cls, *, env_file: str | None = None) -> Config:
|
|
32
|
+
def from_env(cls, *, env_file: str | Path | None = None) -> Config:
|
|
30
33
|
if env_file:
|
|
31
34
|
try:
|
|
32
35
|
from dotenv import load_dotenv
|
|
33
36
|
except ImportError:
|
|
34
37
|
raise ValueError("python-dotenv required for --env-file") from None
|
|
35
|
-
|
|
38
|
+
env_path = resolve_env_file(env_file)
|
|
39
|
+
load_dotenv(dotenv_path=env_path)
|
|
36
40
|
url = os.environ.get("UNIFI_URL", "").strip()
|
|
37
41
|
site = os.environ.get("UNIFI_SITE", "default").strip()
|
|
38
42
|
user = os.environ.get("UNIFI_USER", "").strip()
|
|
@@ -7,6 +7,7 @@ import json
|
|
|
7
7
|
import logging
|
|
8
8
|
import os
|
|
9
9
|
import stat
|
|
10
|
+
import tempfile
|
|
10
11
|
import time
|
|
11
12
|
from collections.abc import Callable, Iterator, Sequence
|
|
12
13
|
from concurrent.futures import ThreadPoolExecutor
|
|
@@ -15,6 +16,7 @@ from contextlib import contextmanager
|
|
|
15
16
|
from pathlib import Path
|
|
16
17
|
from typing import IO, TYPE_CHECKING
|
|
17
18
|
|
|
19
|
+
from ..io.paths import resolve_cache_dir
|
|
18
20
|
from .config import Config
|
|
19
21
|
|
|
20
22
|
if TYPE_CHECKING:
|
|
@@ -24,7 +26,15 @@ logger = logging.getLogger(__name__)
|
|
|
24
26
|
|
|
25
27
|
|
|
26
28
|
def _cache_dir() -> Path:
|
|
27
|
-
|
|
29
|
+
default_dir = ".cache/unifi_network_maps"
|
|
30
|
+
if os.environ.get("PYTEST_CURRENT_TEST"):
|
|
31
|
+
default_dir = str(Path(tempfile.gettempdir()) / f"unifi_network_maps_pytest_{os.getpid()}")
|
|
32
|
+
value = os.environ.get("UNIFI_CACHE_DIR", default_dir)
|
|
33
|
+
try:
|
|
34
|
+
return resolve_cache_dir(value)
|
|
35
|
+
except ValueError as exc:
|
|
36
|
+
logger.warning("Invalid UNIFI_CACHE_DIR (%s); using default: %s", value, exc)
|
|
37
|
+
return resolve_cache_dir(".cache/unifi_network_maps")
|
|
28
38
|
|
|
29
39
|
|
|
30
40
|
def _device_attr(device: object, name: str) -> object | None:
|
|
@@ -377,13 +387,13 @@ def fetch_devices(
|
|
|
377
387
|
cached = None
|
|
378
388
|
stale_cached, cache_age = None, None
|
|
379
389
|
if cached is not None:
|
|
380
|
-
logger.
|
|
390
|
+
logger.debug("Using cached devices (%d)", len(cached))
|
|
381
391
|
return cached
|
|
382
392
|
|
|
383
393
|
try:
|
|
384
394
|
controller = _init_controller(config, is_udm_pro=True)
|
|
385
395
|
except UnifiAuthenticationError:
|
|
386
|
-
logger.
|
|
396
|
+
logger.debug("UDM Pro authentication failed, retrying legacy auth")
|
|
387
397
|
controller = _init_controller(config, is_udm_pro=False)
|
|
388
398
|
|
|
389
399
|
def _fetch() -> Sequence[object]:
|
|
@@ -402,7 +412,7 @@ def fetch_devices(
|
|
|
402
412
|
raise
|
|
403
413
|
if use_cache:
|
|
404
414
|
_save_cache(cache_path, _serialize_devices_for_cache(devices))
|
|
405
|
-
logger.
|
|
415
|
+
logger.debug("Fetched %d devices", len(devices))
|
|
406
416
|
return devices
|
|
407
417
|
|
|
408
418
|
|
|
@@ -428,13 +438,13 @@ def fetch_clients(
|
|
|
428
438
|
cached = None
|
|
429
439
|
stale_cached, cache_age = None, None
|
|
430
440
|
if cached is not None:
|
|
431
|
-
logger.
|
|
441
|
+
logger.debug("Using cached clients (%d)", len(cached))
|
|
432
442
|
return cached
|
|
433
443
|
|
|
434
444
|
try:
|
|
435
445
|
controller = _init_controller(config, is_udm_pro=True)
|
|
436
446
|
except UnifiAuthenticationError:
|
|
437
|
-
logger.
|
|
447
|
+
logger.debug("UDM Pro authentication failed, retrying legacy auth")
|
|
438
448
|
controller = _init_controller(config, is_udm_pro=False)
|
|
439
449
|
|
|
440
450
|
def _fetch() -> Sequence[object]:
|
|
@@ -453,5 +463,5 @@ def fetch_clients(
|
|
|
453
463
|
raise
|
|
454
464
|
if use_cache:
|
|
455
465
|
_save_cache(cache_path, clients)
|
|
456
|
-
logger.
|
|
466
|
+
logger.debug("Fetched %d clients", len(clients))
|
|
457
467
|
return clients
|
unifi_network_maps/cli/main.py
CHANGED
|
@@ -4,10 +4,17 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
6
|
import logging
|
|
7
|
+
from pathlib import Path
|
|
7
8
|
|
|
8
9
|
from ..adapters.config import Config
|
|
9
10
|
from ..io.export import write_output
|
|
10
11
|
from ..io.mock_data import load_mock_data
|
|
12
|
+
from ..io.paths import (
|
|
13
|
+
resolve_env_file,
|
|
14
|
+
resolve_mock_data_path,
|
|
15
|
+
resolve_output_path,
|
|
16
|
+
resolve_theme_path,
|
|
17
|
+
)
|
|
11
18
|
from ..render.legend import render_legend_only, resolve_legend_style
|
|
12
19
|
from ..render.theme import resolve_themes
|
|
13
20
|
from .args import build_parser
|
|
@@ -16,7 +23,7 @@ from .render import render_lldp_format, render_standard_format
|
|
|
16
23
|
logger = logging.getLogger(__name__)
|
|
17
24
|
|
|
18
25
|
|
|
19
|
-
def _load_dotenv(env_file: str | None = None) -> None:
|
|
26
|
+
def _load_dotenv(env_file: str | Path | None = None) -> None:
|
|
20
27
|
try:
|
|
21
28
|
from dotenv import load_dotenv
|
|
22
29
|
except ImportError:
|
|
@@ -30,6 +37,36 @@ def _parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|
|
30
37
|
return parser.parse_args(argv)
|
|
31
38
|
|
|
32
39
|
|
|
40
|
+
class _DowngradeInfoToDebugFilter(logging.Filter):
|
|
41
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
42
|
+
if record.name.startswith("unifi_controller_api") and record.levelno == logging.INFO:
|
|
43
|
+
record.levelno = logging.DEBUG
|
|
44
|
+
record.levelname = logging.getLevelName(logging.DEBUG)
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _downgrade_unifi_controller_logs() -> logging.Filter:
|
|
49
|
+
return _DowngradeInfoToDebugFilter()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _validate_paths(args: argparse.Namespace) -> bool:
|
|
53
|
+
try:
|
|
54
|
+
if args.env_file:
|
|
55
|
+
resolve_env_file(args.env_file)
|
|
56
|
+
if args.mock_data:
|
|
57
|
+
resolve_mock_data_path(args.mock_data, require_exists=False)
|
|
58
|
+
if args.theme_file:
|
|
59
|
+
resolve_theme_path(args.theme_file, require_exists=False)
|
|
60
|
+
if args.generate_mock:
|
|
61
|
+
resolve_output_path(args.generate_mock, format_name="mock")
|
|
62
|
+
if args.output:
|
|
63
|
+
resolve_output_path(args.output, format_name=args.format)
|
|
64
|
+
except ValueError as exc:
|
|
65
|
+
logging.error(str(exc))
|
|
66
|
+
return False
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
|
|
33
70
|
def _load_config(args: argparse.Namespace) -> Config | None:
|
|
34
71
|
try:
|
|
35
72
|
_load_dotenv(args.env_file)
|
|
@@ -59,7 +96,8 @@ def _handle_generate_mock(args: argparse.Namespace) -> int | None:
|
|
|
59
96
|
wireless_client_count=max(0, args.mock_wireless_clients),
|
|
60
97
|
)
|
|
61
98
|
content = mock_payload_json(options)
|
|
62
|
-
|
|
99
|
+
output_kwargs = {"format_name": "mock"} if args.generate_mock else {}
|
|
100
|
+
write_output(content, output_path=args.generate_mock, stdout=args.stdout, **output_kwargs)
|
|
63
101
|
return 0
|
|
64
102
|
|
|
65
103
|
|
|
@@ -81,7 +119,11 @@ def _load_runtime_context(
|
|
|
81
119
|
|
|
82
120
|
def main(argv: list[str] | None = None) -> int:
|
|
83
121
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
|
122
|
+
for handler in logging.getLogger().handlers:
|
|
123
|
+
handler.addFilter(_downgrade_unifi_controller_logs())
|
|
84
124
|
args = _parse_args(argv)
|
|
125
|
+
if not _validate_paths(args):
|
|
126
|
+
return 2
|
|
85
127
|
mock_result = _handle_generate_mock(args)
|
|
86
128
|
if mock_result is not None:
|
|
87
129
|
return mock_result
|
|
@@ -107,7 +149,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
107
149
|
markdown=args.markdown,
|
|
108
150
|
theme=mermaid_theme,
|
|
109
151
|
)
|
|
110
|
-
|
|
152
|
+
output_kwargs = {"format_name": args.format} if args.output else {}
|
|
153
|
+
write_output(content, output_path=args.output, stdout=args.stdout, **output_kwargs)
|
|
111
154
|
return 0
|
|
112
155
|
|
|
113
156
|
if args.format == "lldp-md":
|
unifi_network_maps/cli/render.py
CHANGED
|
@@ -62,7 +62,12 @@ def render_mermaid_output(
|
|
|
62
62
|
direction=args.direction,
|
|
63
63
|
groups=groups,
|
|
64
64
|
group_order=group_order,
|
|
65
|
-
node_types=build_node_type_map(
|
|
65
|
+
node_types=build_node_type_map(
|
|
66
|
+
devices,
|
|
67
|
+
clients,
|
|
68
|
+
client_mode=args.client_scope,
|
|
69
|
+
only_unifi=args.only_unifi,
|
|
70
|
+
),
|
|
66
71
|
theme=mermaid_theme,
|
|
67
72
|
)
|
|
68
73
|
if args.markdown:
|
|
@@ -97,13 +102,23 @@ def render_svg_output(
|
|
|
97
102
|
|
|
98
103
|
return render_svg_isometric(
|
|
99
104
|
edges,
|
|
100
|
-
node_types=build_node_type_map(
|
|
105
|
+
node_types=build_node_type_map(
|
|
106
|
+
devices,
|
|
107
|
+
clients,
|
|
108
|
+
client_mode=args.client_scope,
|
|
109
|
+
only_unifi=args.only_unifi,
|
|
110
|
+
),
|
|
101
111
|
options=options,
|
|
102
112
|
theme=svg_theme,
|
|
103
113
|
)
|
|
104
114
|
return render_svg(
|
|
105
115
|
edges,
|
|
106
|
-
node_types=build_node_type_map(
|
|
116
|
+
node_types=build_node_type_map(
|
|
117
|
+
devices,
|
|
118
|
+
clients,
|
|
119
|
+
client_mode=args.client_scope,
|
|
120
|
+
only_unifi=args.only_unifi,
|
|
121
|
+
),
|
|
107
122
|
options=options,
|
|
108
123
|
theme=svg_theme,
|
|
109
124
|
)
|
|
@@ -190,8 +205,10 @@ def render_lldp_format(
|
|
|
190
205
|
include_ports=args.include_ports,
|
|
191
206
|
show_clients=args.include_clients,
|
|
192
207
|
client_mode=args.client_scope,
|
|
208
|
+
only_unifi=args.only_unifi,
|
|
193
209
|
)
|
|
194
|
-
|
|
210
|
+
output_kwargs = {"format_name": args.format} if args.output else {}
|
|
211
|
+
write_output(content, output_path=args.output, stdout=args.stdout, **output_kwargs)
|
|
195
212
|
return 0
|
|
196
213
|
|
|
197
214
|
|
|
@@ -251,5 +268,6 @@ def render_standard_format(
|
|
|
251
268
|
logging.error("Unsupported format: %s", args.format)
|
|
252
269
|
return 2
|
|
253
270
|
|
|
254
|
-
|
|
271
|
+
output_kwargs = {"format_name": args.format} if args.output else {}
|
|
272
|
+
write_output(content, output_path=args.output, stdout=args.stdout, **output_kwargs)
|
|
255
273
|
return 0
|
|
@@ -95,6 +95,7 @@ def build_edges_with_clients(
|
|
|
95
95
|
device_index,
|
|
96
96
|
include_ports=args.include_ports,
|
|
97
97
|
client_mode=args.client_scope,
|
|
98
|
+
only_unifi=args.only_unifi,
|
|
98
99
|
)
|
|
99
100
|
return edges, clients
|
|
100
101
|
|
|
@@ -153,5 +154,10 @@ def resolve_mkdocs_client_ports(
|
|
|
153
154
|
clients = list(fetch_clients(config, site=site))
|
|
154
155
|
else:
|
|
155
156
|
clients = mock_clients
|
|
156
|
-
client_ports = build_client_port_map(
|
|
157
|
+
client_ports = build_client_port_map(
|
|
158
|
+
devices,
|
|
159
|
+
clients,
|
|
160
|
+
client_mode=args.client_scope,
|
|
161
|
+
only_unifi=args.only_unifi,
|
|
162
|
+
)
|
|
157
163
|
return client_ports, None
|
unifi_network_maps/io/export.py
CHANGED
|
@@ -7,10 +7,19 @@ import sys
|
|
|
7
7
|
import tempfile
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
|
+
from .paths import resolve_output_path
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
|
|
13
|
+
def write_output(
|
|
14
|
+
content: str,
|
|
15
|
+
*,
|
|
16
|
+
output_path: str | Path | None,
|
|
17
|
+
stdout: bool,
|
|
18
|
+
format_name: str | None = None,
|
|
19
|
+
) -> None:
|
|
12
20
|
if output_path:
|
|
13
|
-
|
|
21
|
+
resolved = resolve_output_path(output_path, format_name=format_name)
|
|
22
|
+
_write_atomic(resolved, content)
|
|
14
23
|
if stdout or not output_path:
|
|
15
24
|
sys.stdout.write(content)
|
|
16
25
|
|
|
@@ -5,10 +5,12 @@ from __future__ import annotations
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
7
|
from ..render.templating import render_template
|
|
8
|
+
from .paths import resolve_output_file
|
|
8
9
|
|
|
9
10
|
|
|
10
|
-
def write_mkdocs_sidebar_assets(output_path: str) -> None:
|
|
11
|
-
|
|
11
|
+
def write_mkdocs_sidebar_assets(output_path: str | Path) -> None:
|
|
12
|
+
resolved = resolve_output_file(output_path, extensions=None, label="MkDocs output file")
|
|
13
|
+
output_dir = resolved.parent
|
|
12
14
|
assets_dir = output_dir / "assets"
|
|
13
15
|
assets_dir.mkdir(parents=True, exist_ok=True)
|
|
14
16
|
(assets_dir / "legend.js").write_text(
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
-
|
|
6
|
+
|
|
7
|
+
from .paths import resolve_mock_data_path
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
def _as_list(value: object, name: str) -> list[object]:
|
|
@@ -15,7 +16,8 @@ def _as_list(value: object, name: str) -> list[object]:
|
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
def load_mock_data(path: str) -> tuple[list[object], list[object]]:
|
|
18
|
-
|
|
19
|
+
resolved = resolve_mock_data_path(path)
|
|
20
|
+
payload = json.loads(resolved.read_text(encoding="utf-8"))
|
|
19
21
|
if not isinstance(payload, dict):
|
|
20
22
|
raise ValueError("Mock data must be a JSON object")
|
|
21
23
|
devices = _as_list(payload.get("devices"), "devices")
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Path validation helpers for user-supplied file system inputs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _safe_home_dir() -> Path | None:
|
|
12
|
+
try:
|
|
13
|
+
return Path.home().resolve()
|
|
14
|
+
except Exception:
|
|
15
|
+
return None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _base_roots() -> list[Path]:
|
|
19
|
+
roots = [Path.cwd().resolve()]
|
|
20
|
+
home = _safe_home_dir()
|
|
21
|
+
if home:
|
|
22
|
+
roots.append(home)
|
|
23
|
+
try:
|
|
24
|
+
roots.append(Path(tempfile.gettempdir()).resolve())
|
|
25
|
+
except Exception:
|
|
26
|
+
pass
|
|
27
|
+
return roots
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _extra_roots_from_env() -> list[Path]:
|
|
31
|
+
extra = os.environ.get("UNIFI_ALLOWED_PATHS", "")
|
|
32
|
+
roots: list[Path] = []
|
|
33
|
+
if extra:
|
|
34
|
+
for raw in extra.split(os.pathsep):
|
|
35
|
+
raw = raw.strip()
|
|
36
|
+
if raw:
|
|
37
|
+
roots.append(Path(raw).expanduser().resolve())
|
|
38
|
+
return roots
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _allowed_roots() -> tuple[Path, ...]:
|
|
42
|
+
roots = _base_roots() + _extra_roots_from_env()
|
|
43
|
+
seen: set[str] = set()
|
|
44
|
+
unique: list[Path] = []
|
|
45
|
+
for root in roots:
|
|
46
|
+
key = str(root)
|
|
47
|
+
if key not in seen:
|
|
48
|
+
seen.add(key)
|
|
49
|
+
unique.append(root)
|
|
50
|
+
return tuple(unique)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _resolve_user_path(path: str | Path) -> Path:
|
|
54
|
+
return Path(path).expanduser().resolve(strict=False)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _ensure_within_allowed(path: Path, roots: Iterable[Path], *, label: str) -> None:
|
|
58
|
+
for root in roots:
|
|
59
|
+
try:
|
|
60
|
+
path.relative_to(root)
|
|
61
|
+
except ValueError:
|
|
62
|
+
continue
|
|
63
|
+
else:
|
|
64
|
+
return
|
|
65
|
+
root_list = ", ".join(str(root) for root in roots)
|
|
66
|
+
raise ValueError(f"{label} must be within: {root_list}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _ensure_no_symlink(path: Path, *, label: str) -> None:
|
|
70
|
+
if path.exists() and path.is_symlink():
|
|
71
|
+
raise ValueError(f"{label} must not be a symlink: {path}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _ensure_no_symlink_in_parents(path: Path, *, label: str) -> None:
|
|
75
|
+
for parent in path.parents:
|
|
76
|
+
if parent.exists() and parent.is_symlink():
|
|
77
|
+
raise ValueError(f"{label} parent must not be a symlink: {parent}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _normalize_extensions(extensions: Iterable[str]) -> set[str]:
|
|
81
|
+
normalized = set()
|
|
82
|
+
for ext in extensions:
|
|
83
|
+
ext = ext.strip().lower()
|
|
84
|
+
if not ext:
|
|
85
|
+
continue
|
|
86
|
+
if not ext.startswith("."):
|
|
87
|
+
ext = f".{ext}"
|
|
88
|
+
normalized.add(ext)
|
|
89
|
+
return normalized
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _ensure_extension(
|
|
93
|
+
path: Path,
|
|
94
|
+
extensions: Iterable[str] | None,
|
|
95
|
+
*,
|
|
96
|
+
label: str,
|
|
97
|
+
allow_missing: bool = False,
|
|
98
|
+
) -> None:
|
|
99
|
+
if not extensions:
|
|
100
|
+
return
|
|
101
|
+
allowed = _normalize_extensions(extensions)
|
|
102
|
+
suffix = path.suffix.lower()
|
|
103
|
+
if not suffix:
|
|
104
|
+
if allow_missing:
|
|
105
|
+
return
|
|
106
|
+
raise ValueError(f"{label} must have one of: {', '.join(sorted(allowed))}")
|
|
107
|
+
if suffix not in allowed:
|
|
108
|
+
raise ValueError(f"{label} must have one of: {', '.join(sorted(allowed))}")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def resolve_input_file(
|
|
112
|
+
path: str | Path,
|
|
113
|
+
*,
|
|
114
|
+
extensions: Iterable[str] | None,
|
|
115
|
+
label: str,
|
|
116
|
+
require_exists: bool = True,
|
|
117
|
+
) -> Path:
|
|
118
|
+
resolved = _resolve_user_path(path)
|
|
119
|
+
_ensure_within_allowed(resolved, _allowed_roots(), label=label)
|
|
120
|
+
_ensure_extension(resolved, extensions, label=label)
|
|
121
|
+
if require_exists:
|
|
122
|
+
if not resolved.exists():
|
|
123
|
+
raise ValueError(f"{label} does not exist: {resolved}")
|
|
124
|
+
if not resolved.is_file():
|
|
125
|
+
raise ValueError(f"{label} must be a file: {resolved}")
|
|
126
|
+
return resolved
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def resolve_output_file(
|
|
130
|
+
path: str | Path,
|
|
131
|
+
*,
|
|
132
|
+
extensions: Iterable[str] | None,
|
|
133
|
+
label: str,
|
|
134
|
+
allow_missing_extension: bool = False,
|
|
135
|
+
) -> Path:
|
|
136
|
+
resolved = _resolve_user_path(path)
|
|
137
|
+
_ensure_within_allowed(resolved, _allowed_roots(), label=label)
|
|
138
|
+
_ensure_extension(
|
|
139
|
+
resolved,
|
|
140
|
+
extensions,
|
|
141
|
+
label=label,
|
|
142
|
+
allow_missing=allow_missing_extension,
|
|
143
|
+
)
|
|
144
|
+
return resolved
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def resolve_env_file(path: str | Path) -> Path:
|
|
148
|
+
resolved = _resolve_user_path(path)
|
|
149
|
+
_ensure_within_allowed(resolved, _allowed_roots(), label="Env file")
|
|
150
|
+
if not (resolved.name.startswith(".env") or resolved.name.endswith(".env")):
|
|
151
|
+
raise ValueError("Env file must end with .env")
|
|
152
|
+
if resolved.exists() and not resolved.is_file():
|
|
153
|
+
raise ValueError(f"Env file must be a file: {resolved}")
|
|
154
|
+
return resolved
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def resolve_mock_data_path(path: str | Path, *, require_exists: bool = True) -> Path:
|
|
158
|
+
return resolve_input_file(
|
|
159
|
+
path,
|
|
160
|
+
extensions={".json"},
|
|
161
|
+
label="Mock data file",
|
|
162
|
+
require_exists=require_exists,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def resolve_theme_path(path: str | Path, *, require_exists: bool = True) -> Path:
|
|
167
|
+
return resolve_input_file(
|
|
168
|
+
path,
|
|
169
|
+
extensions={".yml", ".yaml"},
|
|
170
|
+
label="Theme file",
|
|
171
|
+
require_exists=require_exists,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def resolve_output_path(path: str | Path, *, format_name: str | None) -> Path:
|
|
176
|
+
extensions: set[str] | None
|
|
177
|
+
if format_name == "svg" or format_name == "svg-iso":
|
|
178
|
+
extensions = {".svg"}
|
|
179
|
+
elif format_name == "mock":
|
|
180
|
+
extensions = {".json"}
|
|
181
|
+
elif format_name == "mermaid":
|
|
182
|
+
extensions = {".md", ".mermaid", ".mmd"}
|
|
183
|
+
elif format_name == "lldp-md":
|
|
184
|
+
extensions = {".md"}
|
|
185
|
+
elif format_name == "mkdocs":
|
|
186
|
+
extensions = {".md"}
|
|
187
|
+
else:
|
|
188
|
+
extensions = None
|
|
189
|
+
return resolve_output_file(path, extensions=extensions, label="Output file")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def resolve_cache_dir(path: str | Path) -> Path:
|
|
193
|
+
resolved = _resolve_user_path(path)
|
|
194
|
+
_ensure_within_allowed(resolved, _allowed_roots(), label="Cache directory")
|
|
195
|
+
_ensure_no_symlink(resolved, label="Cache directory")
|
|
196
|
+
_ensure_no_symlink_in_parents(resolved, label="Cache directory")
|
|
197
|
+
return resolved
|
|
@@ -460,7 +460,13 @@ def _client_field(client: object, name: str) -> object | None:
|
|
|
460
460
|
|
|
461
461
|
|
|
462
462
|
def _client_display_name(client: object) -> str | None:
|
|
463
|
-
|
|
463
|
+
raw_name = _client_field(client, "name")
|
|
464
|
+
if isinstance(raw_name, str) and raw_name.strip():
|
|
465
|
+
return raw_name.strip()
|
|
466
|
+
preferred = _client_ucore_display_name(client)
|
|
467
|
+
if preferred:
|
|
468
|
+
return preferred
|
|
469
|
+
for key in ("hostname", "mac"):
|
|
464
470
|
value = _client_field(client, key)
|
|
465
471
|
if isinstance(value, str) and value.strip():
|
|
466
472
|
return value.strip()
|
|
@@ -514,6 +520,73 @@ def _client_is_wired(client: object) -> bool:
|
|
|
514
520
|
return bool(_client_field(client, "is_wired"))
|
|
515
521
|
|
|
516
522
|
|
|
523
|
+
def _client_unifi_flag(client: object) -> bool | None:
|
|
524
|
+
for key in ("is_unifi", "is_unifi_device", "is_ubnt", "is_uap", "is_managed"):
|
|
525
|
+
value = _client_field(client, key)
|
|
526
|
+
if isinstance(value, bool):
|
|
527
|
+
return value
|
|
528
|
+
if isinstance(value, int):
|
|
529
|
+
return value != 0
|
|
530
|
+
return None
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _client_vendor(client: object) -> str | None:
|
|
534
|
+
for key in ("oui", "vendor", "vendor_name", "manufacturer", "manufacturer_name"):
|
|
535
|
+
value = _client_field(client, key)
|
|
536
|
+
if isinstance(value, str) and value.strip():
|
|
537
|
+
return value.strip()
|
|
538
|
+
return None
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _client_ucore_info(client: object) -> dict[str, object] | None:
|
|
542
|
+
info = _client_field(client, "unifi_device_info_from_ucore")
|
|
543
|
+
if isinstance(info, dict):
|
|
544
|
+
return info
|
|
545
|
+
return None
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _client_ucore_display_name(client: object) -> str | None:
|
|
549
|
+
ucore = _client_ucore_info(client)
|
|
550
|
+
if not ucore:
|
|
551
|
+
return None
|
|
552
|
+
for key in ("name", "computed_model", "product_model", "product_shortname"):
|
|
553
|
+
value = ucore.get(key)
|
|
554
|
+
if isinstance(value, str) and value.strip():
|
|
555
|
+
return value.strip()
|
|
556
|
+
return None
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _client_hostname_source(client: object) -> str | None:
|
|
560
|
+
value = _client_field(client, "hostname_source")
|
|
561
|
+
if isinstance(value, str) and value.strip():
|
|
562
|
+
return value.strip()
|
|
563
|
+
return None
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _client_is_unifi(client: object) -> bool:
|
|
567
|
+
flag = _client_unifi_flag(client)
|
|
568
|
+
if flag is not None:
|
|
569
|
+
return flag
|
|
570
|
+
ucore = _client_ucore_info(client)
|
|
571
|
+
if ucore:
|
|
572
|
+
managed = ucore.get("managed")
|
|
573
|
+
if isinstance(managed, bool) and managed:
|
|
574
|
+
return True
|
|
575
|
+
if isinstance(ucore.get("product_line"), str) and ucore.get("product_line"):
|
|
576
|
+
return True
|
|
577
|
+
if isinstance(ucore.get("product_shortname"), str) and ucore.get("product_shortname"):
|
|
578
|
+
return True
|
|
579
|
+
for key in ("name", "computed_model", "product_model"):
|
|
580
|
+
value = ucore.get(key)
|
|
581
|
+
if isinstance(value, str) and value.strip():
|
|
582
|
+
return True
|
|
583
|
+
vendor = _client_vendor(client)
|
|
584
|
+
if not vendor:
|
|
585
|
+
return False
|
|
586
|
+
normalized = vendor.lower()
|
|
587
|
+
return "ubiquiti" in normalized or "unifi" in normalized
|
|
588
|
+
|
|
589
|
+
|
|
517
590
|
def _client_channel(client: object) -> int | None:
|
|
518
591
|
for key in ("channel", "radio_channel", "wifi_channel"):
|
|
519
592
|
value = _client_field(client, key)
|
|
@@ -533,17 +606,26 @@ def _client_matches_mode(client: object, mode: str) -> bool:
|
|
|
533
606
|
return wired
|
|
534
607
|
|
|
535
608
|
|
|
609
|
+
def _client_matches_filters(client: object, *, client_mode: str, only_unifi: bool) -> bool:
|
|
610
|
+
if not _client_matches_mode(client, client_mode):
|
|
611
|
+
return False
|
|
612
|
+
if only_unifi and not _client_is_unifi(client):
|
|
613
|
+
return False
|
|
614
|
+
return True
|
|
615
|
+
|
|
616
|
+
|
|
536
617
|
def build_client_edges(
|
|
537
618
|
clients: Iterable[object],
|
|
538
619
|
device_index: dict[str, str],
|
|
539
620
|
*,
|
|
540
621
|
include_ports: bool = False,
|
|
541
622
|
client_mode: str = "wired",
|
|
623
|
+
only_unifi: bool = False,
|
|
542
624
|
) -> list[Edge]:
|
|
543
625
|
edges: list[Edge] = []
|
|
544
626
|
seen: set[tuple[str, str]] = set()
|
|
545
627
|
for client in clients:
|
|
546
|
-
if not
|
|
628
|
+
if not _client_matches_filters(client, client_mode=client_mode, only_unifi=only_unifi):
|
|
547
629
|
continue
|
|
548
630
|
name = _client_display_name(client)
|
|
549
631
|
uplink_mac = _client_uplink_mac(client)
|
|
@@ -580,13 +662,14 @@ def build_node_type_map(
|
|
|
580
662
|
clients: Iterable[object] | None = None,
|
|
581
663
|
*,
|
|
582
664
|
client_mode: str = "wired",
|
|
665
|
+
only_unifi: bool = False,
|
|
583
666
|
) -> dict[str, str]:
|
|
584
667
|
node_types: dict[str, str] = {}
|
|
585
668
|
for device in devices:
|
|
586
669
|
node_types[device.name] = classify_device_type(device)
|
|
587
670
|
if clients:
|
|
588
671
|
for client in clients:
|
|
589
|
-
if not
|
|
672
|
+
if not _client_matches_filters(client, client_mode=client_mode, only_unifi=only_unifi):
|
|
590
673
|
continue
|
|
591
674
|
name = _client_display_name(client)
|
|
592
675
|
if name:
|
|
@@ -640,7 +723,7 @@ def build_edges(
|
|
|
640
723
|
)
|
|
641
724
|
|
|
642
725
|
poe_edges = sum(1 for edge in edges if edge.poe)
|
|
643
|
-
logger.
|
|
726
|
+
logger.debug("Built %d unique edges (%d PoE)", len(edges), poe_edges)
|
|
644
727
|
return edges
|
|
645
728
|
|
|
646
729
|
|
|
@@ -683,11 +766,12 @@ def build_client_port_map(
|
|
|
683
766
|
clients: Iterable[object],
|
|
684
767
|
*,
|
|
685
768
|
client_mode: str,
|
|
769
|
+
only_unifi: bool = False,
|
|
686
770
|
) -> ClientPortMap:
|
|
687
771
|
device_index = build_device_index(devices)
|
|
688
772
|
port_map: ClientPortMap = {}
|
|
689
773
|
for client in clients:
|
|
690
|
-
if not
|
|
774
|
+
if not _client_matches_filters(client, client_mode=client_mode, only_unifi=only_unifi):
|
|
691
775
|
continue
|
|
692
776
|
name = _client_display_name(client)
|
|
693
777
|
uplink_mac = _client_uplink_mac(client)
|
|
@@ -894,14 +978,14 @@ def build_topology(
|
|
|
894
978
|
) -> TopologyResult:
|
|
895
979
|
normalized_devices = list(devices)
|
|
896
980
|
lldp_entries = sum(len(device.lldp_info) for device in normalized_devices)
|
|
897
|
-
logger.
|
|
981
|
+
logger.debug(
|
|
898
982
|
"Normalized %d devices (%d LLDP entries)",
|
|
899
983
|
len(normalized_devices),
|
|
900
984
|
lldp_entries,
|
|
901
985
|
)
|
|
902
986
|
raw_edges = build_edges(normalized_devices, include_ports=include_ports, only_unifi=only_unifi)
|
|
903
987
|
tree_edges = build_tree_edges_by_topology(raw_edges, gateways)
|
|
904
|
-
logger.
|
|
988
|
+
logger.debug(
|
|
905
989
|
"Built %d hierarchy edges (gateways=%d)",
|
|
906
990
|
len(tree_edges),
|
|
907
991
|
len(gateways),
|
|
@@ -83,11 +83,11 @@ def _render_device_ports(
|
|
|
83
83
|
rows = _build_port_rows(device, port_map, client_ports)
|
|
84
84
|
table_rows = [
|
|
85
85
|
[
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
86
|
+
_escape_markdown_text(port_label),
|
|
87
|
+
_escape_connected_cell(connected or "-"),
|
|
88
|
+
_escape_markdown_text(speed),
|
|
89
|
+
_escape_markdown_text(poe_state),
|
|
90
|
+
_escape_markdown_text(power),
|
|
91
91
|
]
|
|
92
92
|
for port_label, connected, speed, poe_state, power in rows
|
|
93
93
|
]
|
|
@@ -224,9 +224,11 @@ def _format_connections(
|
|
|
224
224
|
for peer in sorted(peers, key=str.lower):
|
|
225
225
|
peer_label = port_map.get((peer, device_name))
|
|
226
226
|
if peer_label:
|
|
227
|
-
peer_entries.append(
|
|
227
|
+
peer_entries.append(
|
|
228
|
+
f"{_escape_markdown_text(peer)} ({_escape_markdown_text(peer_label)})"
|
|
229
|
+
)
|
|
228
230
|
else:
|
|
229
|
-
peer_entries.append(peer)
|
|
231
|
+
peer_entries.append(_escape_markdown_text(peer))
|
|
230
232
|
peer_text = ", ".join(peer_entries)
|
|
231
233
|
client_text = _format_client_connections(clients)
|
|
232
234
|
if peer_text and client_text:
|
|
@@ -292,8 +294,15 @@ def _port_sort_key(port: object) -> tuple[int, str]:
|
|
|
292
294
|
return (1, name.lower())
|
|
293
295
|
|
|
294
296
|
|
|
295
|
-
def
|
|
296
|
-
|
|
297
|
+
def _escape_markdown_text(value: str) -> str:
|
|
298
|
+
escaped = value.replace("\\", "\\\\")
|
|
299
|
+
for char in ("|", "[", "]", "*", "_", "`", "<", ">"):
|
|
300
|
+
escaped = escaped.replace(char, f"\\{char}")
|
|
301
|
+
return escaped
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _escape_connected_cell(value: str) -> str:
|
|
305
|
+
return value
|
|
297
306
|
|
|
298
307
|
|
|
299
308
|
def _render_device_details(device: Device) -> list[str]:
|
|
@@ -302,14 +311,14 @@ def _render_device_details(device: Device) -> list[str]:
|
|
|
302
311
|
"",
|
|
303
312
|
"| Field | Value |",
|
|
304
313
|
"| --- | --- |",
|
|
305
|
-
f"| Model | {
|
|
306
|
-
f"| Type | {
|
|
307
|
-
f"| IP | {
|
|
308
|
-
f"| MAC | {
|
|
309
|
-
f"| Firmware | {
|
|
310
|
-
f"| Uplink | {
|
|
311
|
-
f"| Ports | {
|
|
312
|
-
f"| PoE | {
|
|
314
|
+
f"| Model | {_escape_markdown_text(_device_model_label(device))} |",
|
|
315
|
+
f"| Type | {_escape_markdown_text(device.type or '-')} |",
|
|
316
|
+
f"| IP | {_escape_markdown_text(device.ip or '-')} |",
|
|
317
|
+
f"| MAC | {_escape_markdown_text(device.mac or '-')} |",
|
|
318
|
+
f"| Firmware | {_escape_markdown_text(device.version or '-')} |",
|
|
319
|
+
f"| Uplink | {_escape_markdown_text(_uplink_summary(device))} |",
|
|
320
|
+
f"| Ports | {_escape_markdown_text(_port_summary(device))} |",
|
|
321
|
+
f"| PoE | {_escape_markdown_text(_poe_summary(device))} |",
|
|
313
322
|
"",
|
|
314
323
|
]
|
|
315
324
|
return lines
|
|
@@ -367,7 +376,7 @@ def _format_client_connections(clients: list[str]) -> str:
|
|
|
367
376
|
if not clients:
|
|
368
377
|
return ""
|
|
369
378
|
if len(clients) == 1:
|
|
370
|
-
return f"{clients[0]} (client)"
|
|
379
|
+
return f"{_escape_markdown_text(clients[0])} (client)"
|
|
371
380
|
items = "".join(f"<li>{_escape_html(name)}</li>" for name in clients)
|
|
372
381
|
return f'<ul class="unifi-port-clients">{items}</ul>'
|
|
373
382
|
|
|
@@ -23,7 +23,13 @@ def _client_field(client: object, name: str) -> object | None:
|
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
def _client_display_name(client: object) -> str | None:
|
|
26
|
-
|
|
26
|
+
raw_name = _client_field(client, "name")
|
|
27
|
+
if isinstance(raw_name, str) and raw_name.strip():
|
|
28
|
+
return raw_name.strip()
|
|
29
|
+
preferred = _client_ucore_display_name(client)
|
|
30
|
+
if preferred:
|
|
31
|
+
return preferred
|
|
32
|
+
for key in ("hostname", "mac"):
|
|
27
33
|
value = _client_field(client, key)
|
|
28
34
|
if isinstance(value, str) and value.strip():
|
|
29
35
|
return value.strip()
|
|
@@ -77,6 +83,73 @@ def _client_is_wired(client: object) -> bool:
|
|
|
77
83
|
return bool(_client_field(client, "is_wired"))
|
|
78
84
|
|
|
79
85
|
|
|
86
|
+
def _client_unifi_flag(client: object) -> bool | None:
|
|
87
|
+
for key in ("is_unifi", "is_unifi_device", "is_ubnt", "is_uap", "is_managed"):
|
|
88
|
+
value = _client_field(client, key)
|
|
89
|
+
if isinstance(value, bool):
|
|
90
|
+
return value
|
|
91
|
+
if isinstance(value, int):
|
|
92
|
+
return value != 0
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _client_vendor(client: object) -> str | None:
|
|
97
|
+
for key in ("oui", "vendor", "vendor_name", "manufacturer", "manufacturer_name"):
|
|
98
|
+
value = _client_field(client, key)
|
|
99
|
+
if isinstance(value, str) and value.strip():
|
|
100
|
+
return value.strip()
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _client_ucore_info(client: object) -> dict[str, object] | None:
|
|
105
|
+
info = _client_field(client, "unifi_device_info_from_ucore")
|
|
106
|
+
if isinstance(info, dict):
|
|
107
|
+
return info
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _client_ucore_display_name(client: object) -> str | None:
|
|
112
|
+
ucore = _client_ucore_info(client)
|
|
113
|
+
if not ucore:
|
|
114
|
+
return None
|
|
115
|
+
for key in ("name", "computed_model", "product_model", "product_shortname"):
|
|
116
|
+
value = ucore.get(key)
|
|
117
|
+
if isinstance(value, str) and value.strip():
|
|
118
|
+
return value.strip()
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _client_hostname_source(client: object) -> str | None:
|
|
123
|
+
value = _client_field(client, "hostname_source")
|
|
124
|
+
if isinstance(value, str) and value.strip():
|
|
125
|
+
return value.strip()
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _client_is_unifi(client: object) -> bool:
|
|
130
|
+
flag = _client_unifi_flag(client)
|
|
131
|
+
if flag is not None:
|
|
132
|
+
return flag
|
|
133
|
+
ucore = _client_ucore_info(client)
|
|
134
|
+
if ucore:
|
|
135
|
+
managed = ucore.get("managed")
|
|
136
|
+
if isinstance(managed, bool) and managed:
|
|
137
|
+
return True
|
|
138
|
+
if isinstance(ucore.get("product_line"), str) and ucore.get("product_line"):
|
|
139
|
+
return True
|
|
140
|
+
if isinstance(ucore.get("product_shortname"), str) and ucore.get("product_shortname"):
|
|
141
|
+
return True
|
|
142
|
+
for key in ("name", "computed_model", "product_model"):
|
|
143
|
+
value = ucore.get(key)
|
|
144
|
+
if isinstance(value, str) and value.strip():
|
|
145
|
+
return True
|
|
146
|
+
vendor = _client_vendor(client)
|
|
147
|
+
if not vendor:
|
|
148
|
+
return False
|
|
149
|
+
normalized = vendor.lower()
|
|
150
|
+
return "ubiquiti" in normalized or "unifi" in normalized
|
|
151
|
+
|
|
152
|
+
|
|
80
153
|
def _client_matches_mode(client: object, mode: str) -> bool:
|
|
81
154
|
wired = _client_is_wired(client)
|
|
82
155
|
if mode == "all":
|
|
@@ -86,6 +159,14 @@ def _client_matches_mode(client: object, mode: str) -> bool:
|
|
|
86
159
|
return wired
|
|
87
160
|
|
|
88
161
|
|
|
162
|
+
def _client_matches_filters(client: object, *, client_mode: str, only_unifi: bool) -> bool:
|
|
163
|
+
if not _client_matches_mode(client, client_mode):
|
|
164
|
+
return False
|
|
165
|
+
if only_unifi and not _client_is_unifi(client):
|
|
166
|
+
return False
|
|
167
|
+
return True
|
|
168
|
+
|
|
169
|
+
|
|
89
170
|
def _lldp_sort_key(entry: LLDPEntry) -> tuple[int, str, str]:
|
|
90
171
|
port_label = local_port_label(entry) or ""
|
|
91
172
|
port_number = "".join(ch for ch in port_label if ch.isdigit())
|
|
@@ -190,7 +271,10 @@ def _lldp_rows(
|
|
|
190
271
|
|
|
191
272
|
|
|
192
273
|
def _escape_cell(value: str) -> str:
|
|
193
|
-
|
|
274
|
+
escaped = value.replace("\\", "\\\\")
|
|
275
|
+
for char in ("|", "[", "]", "*", "_", "`", "<", ">"):
|
|
276
|
+
escaped = escaped.replace(char, f"\\{char}")
|
|
277
|
+
return escaped
|
|
194
278
|
|
|
195
279
|
|
|
196
280
|
def _client_rows(
|
|
@@ -199,10 +283,11 @@ def _client_rows(
|
|
|
199
283
|
*,
|
|
200
284
|
include_ports: bool,
|
|
201
285
|
client_mode: str,
|
|
286
|
+
only_unifi: bool,
|
|
202
287
|
) -> dict[str, list[tuple[str, str | None]]]:
|
|
203
288
|
rows_by_device: dict[str, list[tuple[str, str | None]]] = {}
|
|
204
289
|
for client in clients:
|
|
205
|
-
if not
|
|
290
|
+
if not _client_matches_filters(client, client_mode=client_mode, only_unifi=only_unifi):
|
|
206
291
|
continue
|
|
207
292
|
name = _client_display_name(client)
|
|
208
293
|
uplink_mac = _client_uplink_mac(client)
|
|
@@ -227,6 +312,7 @@ def _prepare_lldp_maps(
|
|
|
227
312
|
include_ports: bool,
|
|
228
313
|
show_clients: bool,
|
|
229
314
|
client_mode: str,
|
|
315
|
+
only_unifi: bool,
|
|
230
316
|
) -> tuple[
|
|
231
317
|
dict[tuple[str, str], str],
|
|
232
318
|
dict[str, list[tuple[int, str]]] | None,
|
|
@@ -234,7 +320,13 @@ def _prepare_lldp_maps(
|
|
|
234
320
|
]:
|
|
235
321
|
device_index = build_device_index(devices)
|
|
236
322
|
client_rows = (
|
|
237
|
-
_client_rows(
|
|
323
|
+
_client_rows(
|
|
324
|
+
clients,
|
|
325
|
+
device_index,
|
|
326
|
+
include_ports=include_ports,
|
|
327
|
+
client_mode=client_mode,
|
|
328
|
+
only_unifi=only_unifi,
|
|
329
|
+
)
|
|
238
330
|
if clients
|
|
239
331
|
else {}
|
|
240
332
|
)
|
|
@@ -243,7 +335,12 @@ def _prepare_lldp_maps(
|
|
|
243
335
|
if include_ports:
|
|
244
336
|
port_map = build_port_map(devices, only_unifi=False)
|
|
245
337
|
if clients and show_clients:
|
|
246
|
-
client_port_map = build_client_port_map(
|
|
338
|
+
client_port_map = build_client_port_map(
|
|
339
|
+
devices,
|
|
340
|
+
clients,
|
|
341
|
+
client_mode=client_mode,
|
|
342
|
+
only_unifi=only_unifi,
|
|
343
|
+
)
|
|
247
344
|
return port_map, client_port_map, client_rows
|
|
248
345
|
|
|
249
346
|
|
|
@@ -318,6 +415,7 @@ def render_lldp_md(
|
|
|
318
415
|
include_ports: bool = False,
|
|
319
416
|
show_clients: bool = False,
|
|
320
417
|
client_mode: str = "wired",
|
|
418
|
+
only_unifi: bool = False,
|
|
321
419
|
) -> str:
|
|
322
420
|
device_index = build_device_index(devices)
|
|
323
421
|
port_map, client_port_map, client_rows = _prepare_lldp_maps(
|
|
@@ -326,6 +424,7 @@ def render_lldp_md(
|
|
|
326
424
|
include_ports=include_ports,
|
|
327
425
|
show_clients=show_clients,
|
|
328
426
|
client_mode=client_mode,
|
|
427
|
+
only_unifi=only_unifi,
|
|
329
428
|
)
|
|
330
429
|
sections: list[str] = []
|
|
331
430
|
for device in sorted(devices, key=lambda item: item.name.lower()):
|
|
@@ -6,6 +6,7 @@ from pathlib import Path
|
|
|
6
6
|
|
|
7
7
|
import yaml
|
|
8
8
|
|
|
9
|
+
from ..io.paths import resolve_theme_path
|
|
9
10
|
from .mermaid_theme import DEFAULT_THEME as DEFAULT_MERMAID_THEME
|
|
10
11
|
from .mermaid_theme import MermaidTheme
|
|
11
12
|
from .svg_theme import DEFAULT_THEME as DEFAULT_SVG_THEME
|
|
@@ -90,7 +91,7 @@ def _svg_theme_from_dict(data: dict, base: SvgTheme) -> SvgTheme:
|
|
|
90
91
|
|
|
91
92
|
|
|
92
93
|
def load_theme(path: str | Path) -> tuple[MermaidTheme, SvgTheme]:
|
|
93
|
-
theme_path =
|
|
94
|
+
theme_path = resolve_theme_path(path, require_exists=False)
|
|
94
95
|
payload = yaml.safe_load(theme_path.read_text(encoding="utf-8"))
|
|
95
96
|
if not isinstance(payload, dict):
|
|
96
97
|
raise ValueError("Theme file must contain a YAML mapping")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: unifi-network-maps
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.13
|
|
4
4
|
Summary: Dynamic UniFi -> network maps in mermaid or svg
|
|
5
5
|
Author: Merlijn
|
|
6
6
|
License-Expression: MIT
|
|
@@ -32,7 +32,7 @@ Requires-Dist: pre-commit==4.5.1; extra == "dev"
|
|
|
32
32
|
Requires-Dist: pytest==9.0.2; extra == "dev"
|
|
33
33
|
Requires-Dist: pytest-cov==7.0.0; extra == "dev"
|
|
34
34
|
Requires-Dist: pyright==1.1.408; extra == "dev"
|
|
35
|
-
Requires-Dist: ruff==0.14.
|
|
35
|
+
Requires-Dist: ruff==0.14.14; extra == "dev"
|
|
36
36
|
Dynamic: license-file
|
|
37
37
|
|
|
38
38
|
# unifi-network-maps
|
|
@@ -223,7 +223,7 @@ Functional:
|
|
|
223
223
|
- `--include-ports`: show port labels (Mermaid shows both ends; SVG shows compact labels).
|
|
224
224
|
- `--include-clients`: add active wired clients as leaf nodes.
|
|
225
225
|
- `--client-scope wired|wireless|all`: which client types to include (default wired).
|
|
226
|
-
- `--only-unifi`: only include neighbors that are UniFi devices.
|
|
226
|
+
- `--only-unifi`: only include neighbors that are UniFi devices; when clients are included, filters to UniFi-managed clients (by explicit UniFi flags or vendor/OUI).
|
|
227
227
|
- `--no-cache`: disable UniFi API cache reads and writes.
|
|
228
228
|
|
|
229
229
|
Mermaid:
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
unifi_network_maps/__init__.py,sha256=
|
|
1
|
+
unifi_network_maps/__init__.py,sha256=3Jd0G1BzDzSEXU_O-VQeqAYwxMAmK7PBiIm9SYiPxoE,23
|
|
2
2
|
unifi_network_maps/__main__.py,sha256=XsOjaqslAVgyVlOTokjVddZ2iT8apZXpJ_OB-9WEEe4,179
|
|
3
3
|
unifi_network_maps/adapters/__init__.py,sha256=nzx1KsiYalL_YuXKE6acW8Dj5flmMg0-i4gyYO0gV54,22
|
|
4
|
-
unifi_network_maps/adapters/config.py,sha256=
|
|
5
|
-
unifi_network_maps/adapters/unifi.py,sha256=
|
|
4
|
+
unifi_network_maps/adapters/config.py,sha256=gnvgAj9wPmYGGQfhcZSHRkcVGojODUYIINU_ztTcuUc,1671
|
|
5
|
+
unifi_network_maps/adapters/unifi.py,sha256=MCW-kpLcmAF_9QZviT9T2oPfDI9hurtxALCBY8tbmfk,15601
|
|
6
6
|
unifi_network_maps/assets/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
7
|
unifi_network_maps/assets/icons/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
8
|
unifi_network_maps/assets/icons/access-point.svg,sha256=RJOgO2s9Ino5lWRrh4V7q8Jdwffros5bQq3UeDuQYF4,742
|
|
@@ -53,25 +53,26 @@ unifi_network_maps/assets/themes/default.yaml,sha256=F2Jj18NmdaJ_zyERvGAn8NEWBwa
|
|
|
53
53
|
unifi_network_maps/cli/__init__.py,sha256=cds9GvFNZmYAR22Ab3TSzfriSAW--kf9jvC5U-21AoA,70
|
|
54
54
|
unifi_network_maps/cli/__main__.py,sha256=nK_jh78VW3h3DRvSpjzpcf64zkCqniP2k82xUR9Hw2I,147
|
|
55
55
|
unifi_network_maps/cli/args.py,sha256=lIgQDeob_SIhjXg76hJsnpgNOKupSjSYum_MqarWOkE,5552
|
|
56
|
-
unifi_network_maps/cli/main.py,sha256=
|
|
57
|
-
unifi_network_maps/cli/render.py,sha256=
|
|
58
|
-
unifi_network_maps/cli/runtime.py,sha256=
|
|
56
|
+
unifi_network_maps/cli/main.py,sha256=Fedf93kc6f9w_V_Ik28gnrUGlB7oD0Yn5cdmHnj9nI4,5719
|
|
57
|
+
unifi_network_maps/cli/render.py,sha256=wuvsC-In-ApbaQxBn8-UofB7jCiqUnjluGXRCRZpg5A,7535
|
|
58
|
+
unifi_network_maps/cli/runtime.py,sha256=cMtIShERup2z2_uCcEGcaJFJptvdI0L3FDWqKFwSevY,4830
|
|
59
59
|
unifi_network_maps/io/__init__.py,sha256=nzx1KsiYalL_YuXKE6acW8Dj5flmMg0-i4gyYO0gV54,22
|
|
60
60
|
unifi_network_maps/io/debug.py,sha256=eUVs6GLfs6VqI6ma8ra7NLhC3q4ek2K8OSRLnpQDF9s,1745
|
|
61
|
-
unifi_network_maps/io/export.py,sha256=
|
|
62
|
-
unifi_network_maps/io/mkdocs_assets.py,sha256=
|
|
63
|
-
unifi_network_maps/io/mock_data.py,sha256=
|
|
61
|
+
unifi_network_maps/io/export.py,sha256=1_RcXpPqoerCt7WsiheV4dwKgY2fFJlpjUGRs-EkHE4,1013
|
|
62
|
+
unifi_network_maps/io/mkdocs_assets.py,sha256=pszCivIql3maaLw7KFcAioQaVcyLltXgg-fPXPBvvEk,716
|
|
63
|
+
unifi_network_maps/io/mock_data.py,sha256=3nbg83APrdhmNW2w0Ha3FWoguMNfJy1IMtolinUPu0E,774
|
|
64
64
|
unifi_network_maps/io/mock_generate.py,sha256=xiJz_qtNW7iVj7dezLNyVXAHtCHAi8U5CSXK5mrJE4U,233
|
|
65
|
+
unifi_network_maps/io/paths.py,sha256=zE_kYkRXgHmPaRgzE45angYQk59aMC84JW7kcH11Qk0,5729
|
|
65
66
|
unifi_network_maps/model/__init__.py,sha256=nzx1KsiYalL_YuXKE6acW8Dj5flmMg0-i4gyYO0gV54,22
|
|
66
67
|
unifi_network_maps/model/labels.py,sha256=m_k8mbzWtOSDOjjHhLUqwIw93pg98HAtGtHkiERXmek,1135
|
|
67
68
|
unifi_network_maps/model/lldp.py,sha256=SrPW5XC2lfJgaGeVx-KnSFNltyok7gIWWQNg1SkOaj4,3300
|
|
68
69
|
unifi_network_maps/model/mock.py,sha256=kd1MSiIn-7KKu_nMVmheYPfTyAN5DHt4dzRrBifF_lI,8706
|
|
69
70
|
unifi_network_maps/model/ports.py,sha256=o3NBlXcC5VV5iPWJsO4Ll1mRKJZC0f8zTHdlkkE34GU,609
|
|
70
|
-
unifi_network_maps/model/topology.py,sha256=
|
|
71
|
+
unifi_network_maps/model/topology.py,sha256=rC1J1h70JIpkm6eIuFz7AjoNtKobPpVfAbiWgMyow2w,31475
|
|
71
72
|
unifi_network_maps/render/__init__.py,sha256=nzx1KsiYalL_YuXKE6acW8Dj5flmMg0-i4gyYO0gV54,22
|
|
72
|
-
unifi_network_maps/render/device_ports_md.py,sha256=
|
|
73
|
+
unifi_network_maps/render/device_ports_md.py,sha256=PLbP4_EDwLNkUACVJB_uM0ywaQTbEc6OWMv4QBcoJbU,16425
|
|
73
74
|
unifi_network_maps/render/legend.py,sha256=TmZsxgCVOM2CZImI9zgRVyzrcg01HZFDj9F715d4CGo,772
|
|
74
|
-
unifi_network_maps/render/lldp_md.py,sha256=
|
|
75
|
+
unifi_network_maps/render/lldp_md.py,sha256=oHPAYAfyCPJCuAJM4NJ4RIqgf52ihh555oNAlMB0DFg,14873
|
|
75
76
|
unifi_network_maps/render/markdown_tables.py,sha256=VvM0fSnSmpeeDPcD5pXaL_j_PTF0STrMCaqnr2BVHn4,547
|
|
76
77
|
unifi_network_maps/render/mermaid.py,sha256=xsC57Xg-nKhmlVATzEbwLkMM2BOeDYlBjZuxBIPhHeI,8324
|
|
77
78
|
unifi_network_maps/render/mermaid_theme.py,sha256=7nqLlvhaUA4z0YOs0ByEx_yHWcQD_hJJjhDtRcbSpg4,1781
|
|
@@ -79,7 +80,7 @@ unifi_network_maps/render/mkdocs.py,sha256=EOST9_eP1ZoZQax-p-2fjlelrl3AKEJ9Gn-KX
|
|
|
79
80
|
unifi_network_maps/render/svg.py,sha256=Zd6TFaBhqa92O2Z7G_k3LnRXIbuaOAj-dYu2qzEwWvI,41171
|
|
80
81
|
unifi_network_maps/render/svg_theme.py,sha256=Si1ArM3v_-wAvHZyLFPiOZ0ohQRd6ezIckwC3_b-WIw,2684
|
|
81
82
|
unifi_network_maps/render/templating.py,sha256=VJbXzZFBPjL8LFFPcLf_EU5Eu53GN9_vpten2Mf9A-k,576
|
|
82
|
-
unifi_network_maps/render/theme.py,sha256=
|
|
83
|
+
unifi_network_maps/render/theme.py,sha256=LI-2dWaR0cRapvYBqeudGCT3tnT8B-nt-5h0yxkU8vU,4379
|
|
83
84
|
unifi_network_maps/render/templates/device_port_block.md.j2,sha256=ZfyE_lEHz6ZyYRxAYhAhwpxlLczn_U9eTkRLUrwU5Io,50
|
|
84
85
|
unifi_network_maps/render/templates/legend_compact.html.j2,sha256=DaY2m6N6Yd7qNKLuB5Bc395MHfrKOU9myAZvgcJrhpE,738
|
|
85
86
|
unifi_network_maps/render/templates/lldp_device_section.md.j2,sha256=ewX-SBrL5Hn1kuwDKo4fqqetj9tTpR7LQavSn6No_yY,206
|
|
@@ -91,9 +92,9 @@ unifi_network_maps/render/templates/mkdocs_html_block.html.j2,sha256=5l5-BbNujOc
|
|
|
91
92
|
unifi_network_maps/render/templates/mkdocs_legend.css.j2,sha256=tkTI-RagBSgdjUygVenlTsQFenU09ePbXOfDt_Q7YRM,612
|
|
92
93
|
unifi_network_maps/render/templates/mkdocs_legend.js.j2,sha256=qMYyCKsJ84uXf1wGgzbc7Bc49RU4oyuaGK9KrgQDQEI,685
|
|
93
94
|
unifi_network_maps/render/templates/mkdocs_mermaid_block.md.j2,sha256=9IncllWQpoI8BN3A7b2zOQ5cksj97ddsjHJ-aBhpw7o,66
|
|
94
|
-
unifi_network_maps-1.4.
|
|
95
|
-
unifi_network_maps-1.4.
|
|
96
|
-
unifi_network_maps-1.4.
|
|
97
|
-
unifi_network_maps-1.4.
|
|
98
|
-
unifi_network_maps-1.4.
|
|
99
|
-
unifi_network_maps-1.4.
|
|
95
|
+
unifi_network_maps-1.4.13.dist-info/licenses/LICENSE,sha256=mYo1siIIfIwyfdOuK2-Zt0ij2xBTii2hnpeTu79nD80,1074
|
|
96
|
+
unifi_network_maps-1.4.13.dist-info/METADATA,sha256=HzxFdG6F9RwTXSAqF7o-Zl3a3a1dofXewvXZGYtKXlM,9952
|
|
97
|
+
unifi_network_maps-1.4.13.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
|
|
98
|
+
unifi_network_maps-1.4.13.dist-info/entry_points.txt,sha256=cdJ7jsBgNgHxSflYUOqgz5BbvuM0Nnh-x8_Hbyh_LFg,67
|
|
99
|
+
unifi_network_maps-1.4.13.dist-info/top_level.txt,sha256=G0rUX1PNfVCn1u-KtB6QjFQHopCOVLnPMczvPOoraHg,19
|
|
100
|
+
unifi_network_maps-1.4.13.dist-info/RECORD,,
|
{unifi_network_maps-1.4.11.dist-info → unifi_network_maps-1.4.13.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{unifi_network_maps-1.4.11.dist-info → unifi_network_maps-1.4.13.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
|
File without changes
|