unifi-network-maps 1.2.1__py3-none-any.whl → 1.3.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 -0
- unifi_network_maps/adapters/__init__.py +1 -0
- {unifi_mermaid → unifi_network_maps/adapters}/config.py +7 -1
- {unifi_mermaid → unifi_network_maps/adapters}/unifi.py +1 -1
- unifi_network_maps/assets/themes/dark.yaml +47 -0
- unifi_network_maps/assets/themes/default.yaml +47 -0
- unifi_network_maps/cli/__init__.py +41 -0
- unifi_network_maps/cli/__main__.py +8 -0
- unifi_network_maps/cli/main.py +281 -0
- unifi_network_maps/io/__init__.py +1 -0
- {unifi_mermaid → unifi_network_maps/io}/debug.py +1 -1
- unifi_network_maps/model/__init__.py +1 -0
- unifi_network_maps/model/labels.py +35 -0
- {unifi_mermaid → unifi_network_maps/model}/lldp.py +19 -33
- unifi_network_maps/model/ports.py +23 -0
- {unifi_mermaid → unifi_network_maps/model}/topology.py +216 -89
- unifi_network_maps/render/__init__.py +1 -0
- {unifi_mermaid → unifi_network_maps/render}/mermaid.py +21 -16
- unifi_network_maps/render/mermaid_theme.py +46 -0
- {unifi_mermaid → unifi_network_maps/render}/svg.py +208 -175
- unifi_network_maps/render/svg_theme.py +64 -0
- unifi_network_maps/render/theme.py +90 -0
- {unifi_network_maps-1.2.1.dist-info → unifi_network_maps-1.3.0.dist-info}/METADATA +63 -8
- unifi_network_maps-1.3.0.dist-info/RECORD +75 -0
- unifi_network_maps-1.3.0.dist-info/entry_points.txt +2 -0
- unifi_network_maps-1.3.0.dist-info/licenses/LICENSES.md +10 -0
- unifi_network_maps-1.3.0.dist-info/top_level.txt +1 -0
- unifi_mermaid/__init__.py +0 -1
- unifi_mermaid/cli.py +0 -197
- unifi_mermaid/labels.py +0 -15
- unifi_network_maps-1.2.1.dist-info/RECORD +0 -63
- unifi_network_maps-1.2.1.dist-info/entry_points.txt +0 -2
- unifi_network_maps-1.2.1.dist-info/licenses/LICENSES.md +0 -10
- unifi_network_maps-1.2.1.dist-info/top_level.txt +0 -1
- {unifi_mermaid → unifi_network_maps}/assets/__init__.py +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/__init__.py +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/access-point.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/ISOPACKS_LICENSE +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/block.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/cache.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/cardterminal.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/cloud.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/cronjob.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/cube.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/desktop.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/diamond.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/dns.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/document.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/firewall.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/function-module.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/image.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/laptop.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/loadbalancer.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/lock.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/mail.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/mailmultiple.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/mobiledevice.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/office.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/package-module.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/paymentcard.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/plane.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/printer.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/pyramid.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/queue.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/router.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/server.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/speech.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/sphere.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/storage.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/switch-module.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/tower.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/truck-2.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/truck.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/user.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/vm.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/laptop.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/router-network.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/server-network.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/server.svg +0 -0
- {unifi_mermaid → unifi_network_maps/io}/export.py +0 -0
- {unifi_network_maps-1.2.1.dist-info → unifi_network_maps-1.3.0.dist-info}/WHEEL +0 -0
- {unifi_network_maps-1.2.1.dist-info → unifi_network_maps-1.3.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.3.0"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Package module."""
|
|
@@ -26,7 +26,13 @@ class Config:
|
|
|
26
26
|
verify_ssl: bool
|
|
27
27
|
|
|
28
28
|
@classmethod
|
|
29
|
-
def from_env(cls) -> Config:
|
|
29
|
+
def from_env(cls, *, env_file: str | None = None) -> Config:
|
|
30
|
+
if env_file:
|
|
31
|
+
try:
|
|
32
|
+
from dotenv import load_dotenv
|
|
33
|
+
except ImportError:
|
|
34
|
+
raise ValueError("python-dotenv required for --env-file") from None
|
|
35
|
+
load_dotenv(dotenv_path=env_file)
|
|
30
36
|
url = os.environ.get("UNIFI_URL", "").strip()
|
|
31
37
|
site = os.environ.get("UNIFI_SITE", "default").strip()
|
|
32
38
|
user = os.environ.get("UNIFI_USER", "").strip()
|
|
@@ -20,7 +20,7 @@ logger = logging.getLogger(__name__)
|
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def _cache_dir() -> Path:
|
|
23
|
-
return Path(os.environ.get("UNIFI_CACHE_DIR", ".cache/
|
|
23
|
+
return Path(os.environ.get("UNIFI_CACHE_DIR", ".cache/unifi_network_maps"))
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
def _cache_ttl_seconds() -> int:
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
mermaid:
|
|
2
|
+
nodes:
|
|
3
|
+
gateway:
|
|
4
|
+
fill: "#5c3b00"
|
|
5
|
+
stroke: "#ffb347"
|
|
6
|
+
switch:
|
|
7
|
+
fill: "#0f2b3d"
|
|
8
|
+
stroke: "#5dade2"
|
|
9
|
+
ap:
|
|
10
|
+
fill: "#0f3b2d"
|
|
11
|
+
stroke: "#2ecc71"
|
|
12
|
+
client:
|
|
13
|
+
fill: "#2b1f3b"
|
|
14
|
+
stroke: "#b084ff"
|
|
15
|
+
other:
|
|
16
|
+
fill: "#2a2a2a"
|
|
17
|
+
stroke: "#9e9e9e"
|
|
18
|
+
poe_link: "#64b5f6"
|
|
19
|
+
poe_link_width: 2
|
|
20
|
+
poe_link_arrow: "none"
|
|
21
|
+
standard_link: "#66bb6a"
|
|
22
|
+
standard_link_width: 2
|
|
23
|
+
standard_link_arrow: "none"
|
|
24
|
+
svg:
|
|
25
|
+
links:
|
|
26
|
+
standard:
|
|
27
|
+
from: "#66bb6a"
|
|
28
|
+
to: "#2e7d32"
|
|
29
|
+
poe:
|
|
30
|
+
from: "#64b5f6"
|
|
31
|
+
to: "#1e88e5"
|
|
32
|
+
nodes:
|
|
33
|
+
gateway:
|
|
34
|
+
from: "#5c3b00"
|
|
35
|
+
to: "#3e2a00"
|
|
36
|
+
switch:
|
|
37
|
+
from: "#0f2b3d"
|
|
38
|
+
to: "#0a1f2c"
|
|
39
|
+
ap:
|
|
40
|
+
from: "#0f3b2d"
|
|
41
|
+
to: "#0b2a20"
|
|
42
|
+
client:
|
|
43
|
+
from: "#2b1f3b"
|
|
44
|
+
to: "#20162b"
|
|
45
|
+
other:
|
|
46
|
+
from: "#2a2a2a"
|
|
47
|
+
to: "#1f1f1f"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
mermaid:
|
|
2
|
+
nodes:
|
|
3
|
+
gateway:
|
|
4
|
+
fill: "#ffe3b3"
|
|
5
|
+
stroke: "#d98300"
|
|
6
|
+
switch:
|
|
7
|
+
fill: "#d6ecff"
|
|
8
|
+
stroke: "#3a7bd5"
|
|
9
|
+
ap:
|
|
10
|
+
fill: "#d7f5e7"
|
|
11
|
+
stroke: "#27ae60"
|
|
12
|
+
client:
|
|
13
|
+
fill: "#f2e5ff"
|
|
14
|
+
stroke: "#7f3fbf"
|
|
15
|
+
other:
|
|
16
|
+
fill: "#eeeeee"
|
|
17
|
+
stroke: "#8f8f8f"
|
|
18
|
+
poe_link: "#1e88e5"
|
|
19
|
+
poe_link_width: 2
|
|
20
|
+
poe_link_arrow: "none"
|
|
21
|
+
standard_link: "#2ecc71"
|
|
22
|
+
standard_link_width: 2
|
|
23
|
+
standard_link_arrow: "none"
|
|
24
|
+
svg:
|
|
25
|
+
links:
|
|
26
|
+
standard:
|
|
27
|
+
from: "#2ecc71"
|
|
28
|
+
to: "#1b8f4a"
|
|
29
|
+
poe:
|
|
30
|
+
from: "#1e88e5"
|
|
31
|
+
to: "#0d47a1"
|
|
32
|
+
nodes:
|
|
33
|
+
gateway:
|
|
34
|
+
from: "#ffe3b3"
|
|
35
|
+
to: "#f7c77b"
|
|
36
|
+
switch:
|
|
37
|
+
from: "#d6ecff"
|
|
38
|
+
to: "#b6dcff"
|
|
39
|
+
ap:
|
|
40
|
+
from: "#d7f5e7"
|
|
41
|
+
to: "#b8f0d2"
|
|
42
|
+
client:
|
|
43
|
+
from: "#f2e5ff"
|
|
44
|
+
to: "#e0c9ff"
|
|
45
|
+
other:
|
|
46
|
+
from: "#eeeeee"
|
|
47
|
+
to: "#d7d7d7"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""CLI package facade."""
|
|
2
|
+
|
|
3
|
+
from .main import (
|
|
4
|
+
Config,
|
|
5
|
+
SvgOptions,
|
|
6
|
+
build_client_edges,
|
|
7
|
+
build_device_index,
|
|
8
|
+
build_node_type_map,
|
|
9
|
+
build_topology,
|
|
10
|
+
debug_dump_devices,
|
|
11
|
+
fetch_clients,
|
|
12
|
+
fetch_devices,
|
|
13
|
+
group_devices_by_type,
|
|
14
|
+
main,
|
|
15
|
+
normalize_devices,
|
|
16
|
+
render_legend,
|
|
17
|
+
render_mermaid,
|
|
18
|
+
render_svg,
|
|
19
|
+
resolve_themes,
|
|
20
|
+
write_output,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Config",
|
|
25
|
+
"SvgOptions",
|
|
26
|
+
"build_client_edges",
|
|
27
|
+
"build_device_index",
|
|
28
|
+
"build_node_type_map",
|
|
29
|
+
"build_topology",
|
|
30
|
+
"debug_dump_devices",
|
|
31
|
+
"fetch_clients",
|
|
32
|
+
"fetch_devices",
|
|
33
|
+
"group_devices_by_type",
|
|
34
|
+
"main",
|
|
35
|
+
"normalize_devices",
|
|
36
|
+
"render_legend",
|
|
37
|
+
"render_mermaid",
|
|
38
|
+
"render_svg",
|
|
39
|
+
"resolve_themes",
|
|
40
|
+
"write_output",
|
|
41
|
+
]
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""CLI entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
from ..adapters.config import Config
|
|
9
|
+
from ..adapters.unifi import fetch_clients, fetch_devices
|
|
10
|
+
from ..io.debug import debug_dump_devices
|
|
11
|
+
from ..io.export import write_output
|
|
12
|
+
from ..model.topology import (
|
|
13
|
+
Device,
|
|
14
|
+
build_client_edges,
|
|
15
|
+
build_device_index,
|
|
16
|
+
build_node_type_map,
|
|
17
|
+
build_topology,
|
|
18
|
+
group_devices_by_type,
|
|
19
|
+
normalize_devices,
|
|
20
|
+
)
|
|
21
|
+
from ..render.mermaid import render_legend, render_mermaid
|
|
22
|
+
from ..render.mermaid_theme import MermaidTheme
|
|
23
|
+
from ..render.svg import SvgOptions, render_svg
|
|
24
|
+
from ..render.svg_theme import SvgTheme
|
|
25
|
+
from ..render.theme import resolve_themes
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _load_dotenv(env_file: str | None = None) -> None:
|
|
31
|
+
try:
|
|
32
|
+
from dotenv import load_dotenv
|
|
33
|
+
except ImportError:
|
|
34
|
+
logger.info("python-dotenv not installed; skipping .env loading")
|
|
35
|
+
return
|
|
36
|
+
load_dotenv(dotenv_path=env_file) if env_file else load_dotenv()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
40
|
+
parser = argparse.ArgumentParser(
|
|
41
|
+
description="Generate network maps from UniFi LLDP data, as mermaid or SVG"
|
|
42
|
+
)
|
|
43
|
+
_add_source_args(parser.add_argument_group("Source"))
|
|
44
|
+
_add_functional_args(parser.add_argument_group("Functional"))
|
|
45
|
+
_add_mermaid_args(parser.add_argument_group("Mermaid"))
|
|
46
|
+
_add_svg_args(parser.add_argument_group("SVG"))
|
|
47
|
+
_add_general_render_args(parser.add_argument_group("Output"))
|
|
48
|
+
_add_debug_args(parser.add_argument_group("Debug"))
|
|
49
|
+
return parser
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _add_source_args(parser: argparse._ArgumentGroup) -> None:
|
|
53
|
+
parser.add_argument("--site", default=None, help="UniFi site name (overrides UNIFI_SITE)")
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--env-file",
|
|
56
|
+
default=None,
|
|
57
|
+
help="Path to .env file (overrides default .env discovery)",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _add_functional_args(parser: argparse._ArgumentGroup) -> None:
|
|
62
|
+
parser.add_argument("--include-ports", action="store_true", help="Include port labels in edges")
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"--include-clients",
|
|
65
|
+
action="store_true",
|
|
66
|
+
help="Include active clients as leaf nodes",
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument(
|
|
69
|
+
"--only-unifi", action="store_true", help="Only include neighbors that are UniFi devices"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _add_mermaid_args(parser: argparse._ArgumentGroup) -> None:
|
|
74
|
+
parser.add_argument("--direction", default="TB", choices=["LR", "TB"], help="Mermaid direction")
|
|
75
|
+
parser.add_argument(
|
|
76
|
+
"--group-by-type",
|
|
77
|
+
action="store_true",
|
|
78
|
+
help="Group nodes by gateway/switch/ap in Mermaid subgraphs",
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument(
|
|
81
|
+
"--legend-only",
|
|
82
|
+
action="store_true",
|
|
83
|
+
help="Render only the legend as a separate Mermaid graph",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _add_svg_args(parser: argparse._ArgumentGroup) -> None:
|
|
88
|
+
parser.add_argument("--svg-width", type=int, default=None, help="SVG width override")
|
|
89
|
+
parser.add_argument("--svg-height", type=int, default=None, help="SVG height override")
|
|
90
|
+
parser.add_argument("--theme-file", default=None, help="Path to theme YAML file")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _add_general_render_args(parser: argparse._ArgumentGroup) -> None:
|
|
94
|
+
parser.add_argument(
|
|
95
|
+
"--format",
|
|
96
|
+
default="mermaid",
|
|
97
|
+
choices=["mermaid", "svg", "svg-iso"],
|
|
98
|
+
help="Output format",
|
|
99
|
+
)
|
|
100
|
+
parser.add_argument(
|
|
101
|
+
"--markdown",
|
|
102
|
+
action="store_true",
|
|
103
|
+
help="Wrap output in a Markdown mermaid code fence for notes tools like Obsidian",
|
|
104
|
+
)
|
|
105
|
+
parser.add_argument("--output", default=None, help="Output file path")
|
|
106
|
+
parser.add_argument("--stdout", action="store_true", help="Write output to stdout")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _add_debug_args(parser: argparse._ArgumentGroup) -> None:
|
|
110
|
+
parser.add_argument(
|
|
111
|
+
"--debug-dump",
|
|
112
|
+
action="store_true",
|
|
113
|
+
help="Dump gateway and sample device data to stderr for debugging",
|
|
114
|
+
)
|
|
115
|
+
parser.add_argument(
|
|
116
|
+
"--debug-sample",
|
|
117
|
+
type=int,
|
|
118
|
+
default=2,
|
|
119
|
+
help="Number of non-gateway devices to include in debug dump (default: 2)",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|
124
|
+
parser = _build_parser()
|
|
125
|
+
return parser.parse_args(argv)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _load_config(args: argparse.Namespace) -> Config | None:
|
|
129
|
+
try:
|
|
130
|
+
_load_dotenv(args.env_file)
|
|
131
|
+
return Config.from_env(env_file=args.env_file)
|
|
132
|
+
except ValueError as exc:
|
|
133
|
+
logging.error(str(exc))
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _resolve_site(args: argparse.Namespace, config: Config) -> str:
|
|
138
|
+
return args.site or config.site
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _render_legend_only(args: argparse.Namespace, mermaid_theme: MermaidTheme) -> str:
|
|
142
|
+
content = render_legend(theme=mermaid_theme)
|
|
143
|
+
if args.markdown:
|
|
144
|
+
content = f"""```mermaid
|
|
145
|
+
{content}```
|
|
146
|
+
"""
|
|
147
|
+
return content
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _build_topology_data(
|
|
151
|
+
args: argparse.Namespace, config: Config, site: str
|
|
152
|
+
) -> tuple[list[Device], list[str], object]:
|
|
153
|
+
raw_devices = list(fetch_devices(config, site=site, detailed=True))
|
|
154
|
+
devices = normalize_devices(raw_devices)
|
|
155
|
+
if args.debug_dump:
|
|
156
|
+
debug_dump_devices(raw_devices, devices, sample_count=max(0, args.debug_sample))
|
|
157
|
+
groups_for_rank = group_devices_by_type(devices)
|
|
158
|
+
gateways = groups_for_rank.get("gateway", [])
|
|
159
|
+
topology = build_topology(
|
|
160
|
+
devices,
|
|
161
|
+
include_ports=args.include_ports,
|
|
162
|
+
only_unifi=args.only_unifi,
|
|
163
|
+
gateways=gateways,
|
|
164
|
+
)
|
|
165
|
+
return devices, gateways, topology
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _build_edges_with_clients(
|
|
169
|
+
args: argparse.Namespace,
|
|
170
|
+
edges: list,
|
|
171
|
+
devices: list[Device],
|
|
172
|
+
config: Config,
|
|
173
|
+
site: str,
|
|
174
|
+
) -> tuple[list, list | None]:
|
|
175
|
+
clients = None
|
|
176
|
+
if args.include_clients:
|
|
177
|
+
clients = list(fetch_clients(config, site=site))
|
|
178
|
+
device_index = build_device_index(devices)
|
|
179
|
+
edges = edges + build_client_edges(clients, device_index, include_ports=args.include_ports)
|
|
180
|
+
return edges, clients
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _select_edges(topology: object) -> tuple[list, bool]:
|
|
184
|
+
if topology.tree_edges:
|
|
185
|
+
return topology.tree_edges, True
|
|
186
|
+
logging.warning("No gateway found for hierarchy; rendering raw edges.")
|
|
187
|
+
return topology.raw_edges, False
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _render_mermaid_output(
|
|
191
|
+
args: argparse.Namespace,
|
|
192
|
+
devices: list[Device],
|
|
193
|
+
topology: object,
|
|
194
|
+
config: Config,
|
|
195
|
+
site: str,
|
|
196
|
+
mermaid_theme: MermaidTheme,
|
|
197
|
+
) -> str:
|
|
198
|
+
edges, _has_tree = _select_edges(topology)
|
|
199
|
+
edges, clients = _build_edges_with_clients(args, edges, devices, config, site)
|
|
200
|
+
groups = None
|
|
201
|
+
group_order = None
|
|
202
|
+
if args.group_by_type:
|
|
203
|
+
groups = group_devices_by_type(devices)
|
|
204
|
+
group_order = ["gateway", "switch", "ap", "other"]
|
|
205
|
+
content = render_mermaid(
|
|
206
|
+
edges,
|
|
207
|
+
direction=args.direction,
|
|
208
|
+
groups=groups,
|
|
209
|
+
group_order=group_order,
|
|
210
|
+
node_types=build_node_type_map(devices, clients),
|
|
211
|
+
theme=mermaid_theme,
|
|
212
|
+
)
|
|
213
|
+
if args.markdown:
|
|
214
|
+
content = f"""```mermaid
|
|
215
|
+
{content}```
|
|
216
|
+
"""
|
|
217
|
+
return content
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _render_svg_output(
|
|
221
|
+
args: argparse.Namespace,
|
|
222
|
+
devices: list[Device],
|
|
223
|
+
topology: object,
|
|
224
|
+
config: Config,
|
|
225
|
+
site: str,
|
|
226
|
+
svg_theme: SvgTheme,
|
|
227
|
+
) -> str:
|
|
228
|
+
edges, _has_tree = _select_edges(topology)
|
|
229
|
+
edges, clients = _build_edges_with_clients(args, edges, devices, config, site)
|
|
230
|
+
options = SvgOptions(width=args.svg_width, height=args.svg_height)
|
|
231
|
+
if args.format == "svg-iso":
|
|
232
|
+
from ..render.svg import render_svg_isometric
|
|
233
|
+
|
|
234
|
+
return render_svg_isometric(
|
|
235
|
+
edges,
|
|
236
|
+
node_types=build_node_type_map(devices, clients),
|
|
237
|
+
options=options,
|
|
238
|
+
theme=svg_theme,
|
|
239
|
+
)
|
|
240
|
+
return render_svg(
|
|
241
|
+
edges,
|
|
242
|
+
node_types=build_node_type_map(devices, clients),
|
|
243
|
+
options=options,
|
|
244
|
+
theme=svg_theme,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def main(argv: list[str] | None = None) -> int:
|
|
249
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
|
250
|
+
args = _parse_args(argv)
|
|
251
|
+
config = _load_config(args)
|
|
252
|
+
if config is None:
|
|
253
|
+
return 2
|
|
254
|
+
site = _resolve_site(args, config)
|
|
255
|
+
mermaid_theme, svg_theme = resolve_themes(args.theme_file)
|
|
256
|
+
|
|
257
|
+
if args.legend_only:
|
|
258
|
+
content = _render_legend_only(args, mermaid_theme)
|
|
259
|
+
write_output(content, output_path=args.output, stdout=args.stdout)
|
|
260
|
+
return 0
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
devices, _gateways, topology = _build_topology_data(args, config, site)
|
|
264
|
+
except Exception as exc:
|
|
265
|
+
logging.error("Failed to build topology: %s", exc)
|
|
266
|
+
return 1
|
|
267
|
+
|
|
268
|
+
if args.format == "mermaid":
|
|
269
|
+
content = _render_mermaid_output(args, devices, topology, config, site, mermaid_theme)
|
|
270
|
+
elif args.format in {"svg", "svg-iso"}:
|
|
271
|
+
content = _render_svg_output(args, devices, topology, config, site, svg_theme)
|
|
272
|
+
else:
|
|
273
|
+
logging.error("Unsupported format: %s", args.format)
|
|
274
|
+
return 2
|
|
275
|
+
|
|
276
|
+
write_output(content, output_path=args.output, stdout=args.stdout)
|
|
277
|
+
return 0
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
if __name__ == "__main__":
|
|
281
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Package module."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Package module."""
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Edge label helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def compose_port_label(left: str, right: str, port_map: dict[tuple[str, str], str]) -> str | None:
|
|
9
|
+
left_label = port_map.get((left, right))
|
|
10
|
+
right_label = port_map.get((right, left))
|
|
11
|
+
if left_label and right_label:
|
|
12
|
+
return f"{left}: {left_label} <-> {right}: {right_label}"
|
|
13
|
+
if left_label:
|
|
14
|
+
return f"{left}: {left_label} <-> {right}: ?"
|
|
15
|
+
if right_label:
|
|
16
|
+
return f"{left}: ? <-> {right}: {right_label}"
|
|
17
|
+
return None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def order_edge_names(
|
|
21
|
+
left: str,
|
|
22
|
+
right: str,
|
|
23
|
+
port_map: dict[tuple[str, str], str],
|
|
24
|
+
rank_for_name: Callable[[str], int],
|
|
25
|
+
) -> tuple[str, str]:
|
|
26
|
+
left_label = port_map.get((left, right))
|
|
27
|
+
right_label = port_map.get((right, left))
|
|
28
|
+
if left_label is None and right_label is not None:
|
|
29
|
+
return (right, left)
|
|
30
|
+
if left_label and right_label:
|
|
31
|
+
left_rank = rank_for_name(left)
|
|
32
|
+
right_rank = rank_for_name(right)
|
|
33
|
+
if (left_rank, left.lower()) > (right_rank, right.lower()):
|
|
34
|
+
return (right, left)
|
|
35
|
+
return (left, right)
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import re
|
|
6
5
|
from dataclasses import dataclass
|
|
7
6
|
|
|
7
|
+
from .ports import extract_port_number, normalize_port_label
|
|
8
|
+
|
|
8
9
|
|
|
9
10
|
@dataclass(frozen=True)
|
|
10
11
|
class LLDPEntry:
|
|
@@ -51,43 +52,28 @@ def _looks_like_mac(value: str | None) -> bool:
|
|
|
51
52
|
return False
|
|
52
53
|
|
|
53
54
|
|
|
54
|
-
def
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def _normalize_port_label(label: str) -> str:
|
|
64
|
-
trimmed = label.strip()
|
|
65
|
-
number = _extract_port_number(trimmed)
|
|
66
|
-
if number is not None:
|
|
67
|
-
return f"Port {number}"
|
|
68
|
-
return trimmed
|
|
69
|
-
|
|
55
|
+
def _port_label_parts(entry: LLDPEntry) -> tuple[int | None, str | None, str | None]:
|
|
56
|
+
number = entry.local_port_idx
|
|
57
|
+
name = normalize_port_label(entry.local_port_name) if entry.local_port_name else None
|
|
58
|
+
desc = (
|
|
59
|
+
entry.port_desc.strip()
|
|
60
|
+
if entry.port_desc and not _looks_like_mac(entry.port_desc)
|
|
61
|
+
else None
|
|
62
|
+
)
|
|
70
63
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
name = None
|
|
74
|
-
desc = None
|
|
75
|
-
|
|
76
|
-
if entry.local_port_idx is not None:
|
|
77
|
-
number = entry.local_port_idx
|
|
78
|
-
if entry.local_port_name:
|
|
79
|
-
name = _normalize_port_label(entry.local_port_name)
|
|
80
|
-
if entry.port_desc and not _looks_like_mac(entry.port_desc):
|
|
81
|
-
desc = entry.port_desc.strip()
|
|
82
|
-
if entry.port_id and not _looks_like_mac(entry.port_id):
|
|
83
|
-
if name is None:
|
|
84
|
-
name = _normalize_port_label(entry.port_id)
|
|
64
|
+
if entry.port_id and not _looks_like_mac(entry.port_id) and name is None:
|
|
65
|
+
name = normalize_port_label(entry.port_id)
|
|
85
66
|
|
|
86
67
|
if number is None:
|
|
87
|
-
number =
|
|
68
|
+
number = extract_port_number(name)
|
|
88
69
|
if number is None:
|
|
89
|
-
number =
|
|
70
|
+
number = extract_port_number(desc)
|
|
90
71
|
|
|
72
|
+
return number, name, desc
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def local_port_label(entry: LLDPEntry) -> str | None:
|
|
76
|
+
number, name, desc = _port_label_parts(entry)
|
|
91
77
|
if number is not None and desc:
|
|
92
78
|
return f"Port {number} ({desc})"
|
|
93
79
|
if number is not None:
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Port parsing helpers shared across modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def extract_port_number(label: str | None) -> int | None:
|
|
9
|
+
if not label:
|
|
10
|
+
return None
|
|
11
|
+
# Matches: "Port 3", "eth1"; non-matches: "wan", "portX".
|
|
12
|
+
match = re.search(r"(?:^|[^0-9])(?:port|eth)\s*([0-9]+)", label.strip(), re.IGNORECASE)
|
|
13
|
+
if match:
|
|
14
|
+
return int(match.group(1))
|
|
15
|
+
return None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def normalize_port_label(label: str) -> str:
|
|
19
|
+
trimmed = label.strip()
|
|
20
|
+
number = extract_port_number(trimmed)
|
|
21
|
+
if number is not None:
|
|
22
|
+
return f"Port {number}"
|
|
23
|
+
return trimmed
|