unifi-network-maps 1.4.3__py3-none-any.whl → 1.4.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. unifi_network_maps/__init__.py +1 -1
  2. unifi_network_maps/assets/themes/dark.yaml +2 -2
  3. unifi_network_maps/cli/__init__.py +2 -38
  4. unifi_network_maps/cli/args.py +166 -0
  5. unifi_network_maps/cli/main.py +18 -747
  6. unifi_network_maps/cli/render.py +255 -0
  7. unifi_network_maps/cli/runtime.py +157 -0
  8. unifi_network_maps/io/mkdocs_assets.py +21 -0
  9. unifi_network_maps/io/mock_generate.py +2 -294
  10. unifi_network_maps/model/mock.py +307 -0
  11. unifi_network_maps/render/device_ports_md.py +44 -27
  12. unifi_network_maps/render/legend.py +30 -0
  13. unifi_network_maps/render/lldp_md.py +81 -60
  14. unifi_network_maps/render/markdown_tables.py +21 -0
  15. unifi_network_maps/render/mermaid.py +72 -85
  16. unifi_network_maps/render/mkdocs.py +167 -0
  17. unifi_network_maps/render/templates/device_port_block.md.j2 +5 -0
  18. unifi_network_maps/render/templates/legend_compact.html.j2 +14 -0
  19. unifi_network_maps/render/templates/lldp_device_section.md.j2 +15 -0
  20. unifi_network_maps/render/templates/markdown_section.md.j2 +3 -0
  21. unifi_network_maps/render/templates/mermaid_legend.mmd.j2 +30 -0
  22. unifi_network_maps/render/templates/mkdocs_document.md.j2 +23 -0
  23. unifi_network_maps/render/templates/mkdocs_dual_theme_style.html.j2 +8 -0
  24. unifi_network_maps/render/templates/mkdocs_html_block.html.j2 +2 -0
  25. unifi_network_maps/render/templates/mkdocs_legend.css.j2 +29 -0
  26. unifi_network_maps/render/templates/mkdocs_legend.js.j2 +18 -0
  27. unifi_network_maps/render/templates/mkdocs_mermaid_block.md.j2 +4 -0
  28. unifi_network_maps/render/templating.py +19 -0
  29. {unifi_network_maps-1.4.3.dist-info → unifi_network_maps-1.4.5.dist-info}/METADATA +2 -1
  30. {unifi_network_maps-1.4.3.dist-info → unifi_network_maps-1.4.5.dist-info}/RECORD +34 -14
  31. {unifi_network_maps-1.4.3.dist-info → unifi_network_maps-1.4.5.dist-info}/WHEEL +0 -0
  32. {unifi_network_maps-1.4.3.dist-info → unifi_network_maps-1.4.5.dist-info}/entry_points.txt +0 -0
  33. {unifi_network_maps-1.4.3.dist-info → unifi_network_maps-1.4.5.dist-info}/licenses/LICENSE +0 -0
  34. {unifi_network_maps-1.4.3.dist-info → unifi_network_maps-1.4.5.dist-info}/top_level.txt +0 -0
@@ -2,298 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import json
6
- import random
7
- from dataclasses import dataclass, field
8
- from typing import Any
5
+ from ..model.mock import MockOptions, generate_mock_payload, mock_payload_json
9
6
 
10
- from faker import Faker
11
-
12
-
13
- @dataclass(frozen=True)
14
- class MockOptions:
15
- seed: int = 1337
16
- switch_count: int = 1
17
- ap_count: int = 2
18
- wired_client_count: int = 2
19
- wireless_client_count: int = 2
20
-
21
-
22
- @dataclass
23
- class _MockState:
24
- fake: Faker
25
- rng: random.Random
26
- used_macs: set[str] = field(default_factory=set)
27
- used_ips: set[str] = field(default_factory=set)
28
- used_names: set[str] = field(default_factory=set)
29
- used_rooms: set[str] = field(default_factory=set)
30
- core_port_next: int = 2
31
-
32
-
33
- def generate_mock_payload(options: MockOptions) -> dict[str, list[dict[str, Any]]]:
34
- state = _build_state(options.seed)
35
- devices, core_switch, aps = _build_devices(options, state)
36
- clients = _build_clients(options, state, core_switch, aps)
37
- return {"devices": devices, "clients": clients}
38
-
39
-
40
- def mock_payload_json(options: MockOptions) -> str:
41
- payload = generate_mock_payload(options)
42
- return json.dumps(payload, indent=2, sort_keys=True)
43
-
44
-
45
- def _build_state(seed: int) -> _MockState:
46
- fake = Faker("en_US")
47
- fake.seed_instance(seed)
48
- rng = random.Random(seed)
49
- return _MockState(fake=fake, rng=rng)
50
-
51
-
52
- def _build_devices(
53
- options: MockOptions, state: _MockState
54
- ) -> tuple[list[dict[str, Any]], dict[str, Any], list[dict[str, Any]]]:
55
- gateway = _build_gateway(state)
56
- core_switch = _build_core_switch(state)
57
- _link_gateway_to_switch(state, gateway, core_switch)
58
- access_switches = _build_access_switches(options.switch_count - 1, state, core_switch)
59
- aps = _build_aps(options.ap_count, state, core_switch)
60
- devices = [gateway, core_switch] + access_switches + aps
61
- return devices, core_switch, aps
62
-
63
-
64
- def _build_gateway(state: _MockState) -> dict[str, Any]:
65
- device = _device_base(state, "Cloud Gateway", "udm", "UniFi Dream Machine Pro", "UDM-Pro")
66
- _add_port(device, 9, poe_enabled=False, rng=state.rng)
67
- return device
68
-
69
-
70
- def _build_core_switch(state: _MockState) -> dict[str, Any]:
71
- return _device_base(state, "Core Switch", "usw", "UniFi Switch 24 PoE", "USW-24-PoE")
72
-
73
-
74
- def _build_access_switches(
75
- count: int, state: _MockState, core_switch: dict[str, Any]
76
- ) -> list[dict[str, Any]]:
77
- switches = []
78
- for _ in range(max(0, count)):
79
- room = _unique_room(state)
80
- name = _unique_name(state, f"Switch {room}")
81
- device = _device_base(state, name, "usw", "UniFi Switch Lite 8 PoE", "USW-Lite-8-PoE")
82
- _link_core_device(state, core_switch, device, poe_enabled=False)
83
- switches.append(device)
84
- return switches
85
-
86
-
87
- def _build_aps(count: int, state: _MockState, core_switch: dict[str, Any]) -> list[dict[str, Any]]:
88
- aps = []
89
- for _ in range(max(0, count)):
90
- room = _unique_room(state)
91
- name = _unique_name(state, f"AP {room}")
92
- device = _device_base(state, name, "uap", "UniFi AP 6 Lite", "U6-Lite")
93
- _add_port(device, 1, poe_enabled=True, rng=state.rng)
94
- _link_core_device(state, core_switch, device, poe_enabled=True)
95
- aps.append(device)
96
- return aps
97
-
98
-
99
- def _build_clients(
100
- options: MockOptions,
101
- state: _MockState,
102
- core_switch: dict[str, Any],
103
- aps: list[dict[str, Any]],
104
- ) -> list[dict[str, Any]]:
105
- clients = []
106
- clients.extend(_build_wired_clients(options.wired_client_count, state, core_switch))
107
- clients.extend(_build_wireless_clients(options.wireless_client_count, state, aps))
108
- return clients
109
-
110
-
111
- def _build_wired_clients(
112
- count: int, state: _MockState, core_switch: dict[str, Any]
113
- ) -> list[dict[str, Any]]:
114
- clients = []
115
- for _ in range(max(0, count)):
116
- port_idx = _next_core_port(state)
117
- _add_port(core_switch, port_idx, poe_enabled=False, rng=state.rng)
118
- name = _unique_client_name(state)
119
- clients.append(
120
- {
121
- "name": name,
122
- "is_wired": True,
123
- "sw_mac": core_switch["mac"],
124
- "sw_port": port_idx,
125
- }
126
- )
127
- return clients
128
-
129
-
130
- def _build_wireless_clients(
131
- count: int, state: _MockState, aps: list[dict[str, Any]]
132
- ) -> list[dict[str, Any]]:
133
- if not aps:
134
- return []
135
- clients = []
136
- for idx in range(max(0, count)):
137
- ap = aps[idx % len(aps)]
138
- name = _unique_client_name(state)
139
- clients.append(
140
- {
141
- "name": name,
142
- "is_wired": False,
143
- "ap_mac": ap["mac"],
144
- "ap_port": 1,
145
- }
146
- )
147
- return clients
148
-
149
-
150
- def _device_base(
151
- state: _MockState, name: str, dev_type: str, model_name: str, model: str
152
- ) -> dict[str, Any]:
153
- version = _pick_version(state, dev_type)
154
- return {
155
- "name": name,
156
- "model_name": model_name,
157
- "model": model,
158
- "mac": _unique_mac(state),
159
- "ip": _unique_ip(state),
160
- "type": dev_type,
161
- "version": version,
162
- "port_table": [],
163
- "lldp_info": [],
164
- }
165
-
166
-
167
- def _pick_version(state: _MockState, dev_type: str) -> str:
168
- versions = {
169
- "udm": ["3.1.0", "3.1.1"],
170
- "usw": ["7.0.0", "7.1.2"],
171
- "uap": ["6.6.55", "6.7.10"],
172
- }
173
- return state.rng.choice(versions.get(dev_type, ["1.0.0"]))
174
-
175
-
176
- def _link_gateway_to_switch(
177
- state: _MockState, gateway: dict[str, Any], core_switch: dict[str, Any]
178
- ) -> None:
179
- _add_port(core_switch, 1, poe_enabled=False, rng=state.rng)
180
- _add_lldp_link(
181
- gateway,
182
- core_switch,
183
- local_port=9,
184
- remote_port=1,
185
- remote_name=core_switch["name"],
186
- )
187
- _add_lldp_link(
188
- core_switch,
189
- gateway,
190
- local_port=1,
191
- remote_port=9,
192
- remote_name=gateway["name"],
193
- )
194
-
195
-
196
- def _link_core_device(
197
- state: _MockState,
198
- core_switch: dict[str, Any],
199
- device: dict[str, Any],
200
- *,
201
- poe_enabled: bool,
202
- ) -> None:
203
- port_idx = _next_core_port(state)
204
- _add_port(core_switch, port_idx, poe_enabled=poe_enabled, rng=state.rng)
205
- _add_lldp_link(
206
- core_switch,
207
- device,
208
- local_port=port_idx,
209
- remote_port=1,
210
- remote_name=device["name"],
211
- )
212
- _add_lldp_link(
213
- device,
214
- core_switch,
215
- local_port=1,
216
- remote_port=port_idx,
217
- remote_name=core_switch["name"],
218
- )
219
-
220
-
221
- def _add_lldp_link(
222
- source: dict[str, Any],
223
- dest: dict[str, Any],
224
- *,
225
- local_port: int,
226
- remote_port: int,
227
- remote_name: str,
228
- ) -> None:
229
- source["lldp_info"].append(
230
- {
231
- "chassis_id": dest["mac"],
232
- "port_id": f"Port {remote_port}",
233
- "port_desc": remote_name,
234
- "local_port_name": f"Port {local_port}",
235
- "local_port_idx": local_port,
236
- }
237
- )
238
-
239
-
240
- def _add_port(
241
- device: dict[str, Any], port_idx: int, *, poe_enabled: bool, rng: random.Random
242
- ) -> None:
243
- entry = {
244
- "port_idx": port_idx,
245
- "name": f"Port {port_idx}",
246
- "ifname": f"eth{max(0, port_idx - 1)}",
247
- "poe_enable": poe_enabled,
248
- "port_poe": poe_enabled,
249
- }
250
- if poe_enabled:
251
- entry["poe_power"] = round(rng.uniform(4.5, 7.5), 1)
252
- device["port_table"].append(entry)
253
-
254
-
255
- def _unique_mac(state: _MockState) -> str:
256
- return _unique_value(state, state.fake.mac_address, state.used_macs)
257
-
258
-
259
- def _unique_ip(state: _MockState) -> str:
260
- return _unique_value(state, state.fake.ipv4_private, state.used_ips)
261
-
262
-
263
- def _unique_room(state: _MockState) -> str:
264
- def _room() -> str:
265
- return state.fake.word().title()
266
-
267
- return _unique_value(state, _room, state.used_rooms)
268
-
269
-
270
- def _unique_name(state: _MockState, candidate: str) -> str:
271
- if candidate not in state.used_names:
272
- state.used_names.add(candidate)
273
- return candidate
274
- suffix = state.rng.randint(2, 99)
275
- value = f"{candidate} {suffix}"
276
- state.used_names.add(value)
277
- return value
278
-
279
-
280
- def _unique_client_name(state: _MockState) -> str:
281
- def _name() -> str:
282
- return state.fake.hostname()
283
-
284
- return _unique_value(state, _name, state.used_names)
285
-
286
-
287
- def _unique_value(state: _MockState, generator, used: set[str]) -> str:
288
- for _ in range(100):
289
- value = str(generator())
290
- if value not in used:
291
- used.add(value)
292
- return value
293
- raise ValueError("Failed to generate a unique value")
294
-
295
-
296
- def _next_core_port(state: _MockState) -> int:
297
- port_idx = state.core_port_next
298
- state.core_port_next += 1
299
- return port_idx
7
+ __all__ = ["MockOptions", "generate_mock_payload", "mock_payload_json"]
@@ -0,0 +1,307 @@
1
+ """Generate mock UniFi data using Faker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import random
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+ from faker import Faker
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class MockOptions:
15
+ seed: int = 1337
16
+ switch_count: int = 1
17
+ ap_count: int = 2
18
+ wired_client_count: int = 2
19
+ wireless_client_count: int = 2
20
+
21
+
22
+ @dataclass
23
+ class _MockState:
24
+ fake: Faker
25
+ rng: random.Random
26
+ used_macs: set[str] = field(default_factory=set)
27
+ used_ips: set[str] = field(default_factory=set)
28
+ used_names: set[str] = field(default_factory=set)
29
+ used_rooms: set[str] = field(default_factory=set)
30
+ core_port_next: int = 2
31
+
32
+
33
+ def generate_mock_payload(options: MockOptions) -> dict[str, list[dict[str, Any]]]:
34
+ state = _build_state(options.seed)
35
+ devices, core_switch, aps = _build_devices(options, state)
36
+ clients = _build_clients(options, state, core_switch, aps)
37
+ return {"devices": devices, "clients": clients}
38
+
39
+
40
+ def mock_payload_json(options: MockOptions) -> str:
41
+ payload = generate_mock_payload(options)
42
+ return json.dumps(payload, indent=2, sort_keys=True)
43
+
44
+
45
+ def _build_state(seed: int) -> _MockState:
46
+ fake = Faker("en_US")
47
+ fake.seed_instance(seed)
48
+ rng = random.Random(seed)
49
+ return _MockState(fake=fake, rng=rng)
50
+
51
+
52
+ def _build_devices(
53
+ options: MockOptions, state: _MockState
54
+ ) -> tuple[list[dict[str, Any]], dict[str, Any], list[dict[str, Any]]]:
55
+ gateway = _build_gateway(state)
56
+ core_switch = _build_core_switch(state)
57
+ _link_gateway_to_switch(state, gateway, core_switch)
58
+ access_switches = _build_access_switches(options.switch_count - 1, state, core_switch)
59
+ aps = _build_aps(options.ap_count, state, core_switch)
60
+ devices = [gateway, core_switch] + access_switches + aps
61
+ return devices, core_switch, aps
62
+
63
+
64
+ def _build_gateway(state: _MockState) -> dict[str, Any]:
65
+ device = _device_base(state, "Cloud Gateway", "udm", "UniFi Dream Machine Pro", "UDM-Pro")
66
+ _add_port(device, 9, poe_enabled=False, rng=state.rng)
67
+ return device
68
+
69
+
70
+ def _build_core_switch(state: _MockState) -> dict[str, Any]:
71
+ return _device_base(state, "Core Switch", "usw", "UniFi Switch 24 PoE", "USW-24-PoE")
72
+
73
+
74
+ def _build_access_switches(
75
+ count: int, state: _MockState, core_switch: dict[str, Any]
76
+ ) -> list[dict[str, Any]]:
77
+ switches = []
78
+ for _ in range(max(0, count)):
79
+ room = _unique_room(state)
80
+ name = _unique_name(state, f"Switch {room}")
81
+ device = _device_base(state, name, "usw", "UniFi Switch Lite 8 PoE", "USW-Lite-8-PoE")
82
+ _link_core_device(state, core_switch, device, poe_enabled=False)
83
+ switches.append(device)
84
+ return switches
85
+
86
+
87
+ def _build_aps(count: int, state: _MockState, core_switch: dict[str, Any]) -> list[dict[str, Any]]:
88
+ aps = []
89
+ for _ in range(max(0, count)):
90
+ room = _unique_room(state)
91
+ name = _unique_name(state, f"AP {room}")
92
+ device = _device_base(state, name, "uap", "UniFi AP 6 Lite", "U6-Lite")
93
+ _add_port(device, 1, poe_enabled=True, rng=state.rng)
94
+ _link_core_device(state, core_switch, device, poe_enabled=True)
95
+ aps.append(device)
96
+ return aps
97
+
98
+
99
+ def _build_clients(
100
+ options: MockOptions,
101
+ state: _MockState,
102
+ core_switch: dict[str, Any],
103
+ aps: list[dict[str, Any]],
104
+ ) -> list[dict[str, Any]]:
105
+ clients = []
106
+ clients.extend(_build_wired_clients(options.wired_client_count, state, core_switch))
107
+ clients.extend(_build_wireless_clients(options.wireless_client_count, state, aps))
108
+ return clients
109
+
110
+
111
+ def _build_wired_clients(
112
+ count: int, state: _MockState, core_switch: dict[str, Any]
113
+ ) -> list[dict[str, Any]]:
114
+ clients = []
115
+ for _ in range(max(0, count)):
116
+ port_idx = _next_core_port(state)
117
+ _add_port(core_switch, port_idx, poe_enabled=False, rng=state.rng)
118
+ name = _unique_client_name(state)
119
+ clients.append(
120
+ {
121
+ "name": name,
122
+ "is_wired": True,
123
+ "sw_mac": core_switch["mac"],
124
+ "sw_port": port_idx,
125
+ }
126
+ )
127
+ return clients
128
+
129
+
130
+ def _build_wireless_clients(
131
+ count: int, state: _MockState, aps: list[dict[str, Any]]
132
+ ) -> list[dict[str, Any]]:
133
+ if not aps:
134
+ return []
135
+ clients = []
136
+ for idx in range(max(0, count)):
137
+ ap = aps[idx % len(aps)]
138
+ name = _unique_client_name(state)
139
+ clients.append(
140
+ {
141
+ "name": name,
142
+ "is_wired": False,
143
+ "ap_mac": ap["mac"],
144
+ "ap_port": 1,
145
+ }
146
+ )
147
+ return clients
148
+
149
+
150
+ def _device_base(
151
+ state: _MockState, name: str, dev_type: str, model_name: str, model: str
152
+ ) -> dict[str, Any]:
153
+ version = _pick_version(state, dev_type)
154
+ return {
155
+ "name": name,
156
+ "model_name": model_name,
157
+ "model": model,
158
+ "mac": _unique_mac(state),
159
+ "ip": _unique_ip(state),
160
+ "type": dev_type,
161
+ "version": version,
162
+ "port_table": [],
163
+ "lldp_info": [],
164
+ }
165
+
166
+
167
+ def _pick_version(state: _MockState, dev_type: str) -> str:
168
+ versions = {
169
+ "udm": ["3.1.0", "3.1.1"],
170
+ "usw": ["7.0.0", "7.1.2"],
171
+ "uap": ["6.6.55", "6.7.10"],
172
+ }
173
+ return state.rng.choice(versions.get(dev_type, ["1.0.0"]))
174
+
175
+
176
+ def _link_gateway_to_switch(
177
+ state: _MockState, gateway: dict[str, Any], core_switch: dict[str, Any]
178
+ ) -> None:
179
+ _add_port(core_switch, 1, poe_enabled=False, rng=state.rng)
180
+ _add_lldp_link(
181
+ gateway,
182
+ core_switch,
183
+ local_port=9,
184
+ remote_port=1,
185
+ remote_name=core_switch["name"],
186
+ )
187
+ _add_lldp_link(
188
+ core_switch,
189
+ gateway,
190
+ local_port=1,
191
+ remote_port=9,
192
+ remote_name=gateway["name"],
193
+ )
194
+
195
+
196
+ def _link_core_device(
197
+ state: _MockState,
198
+ core_switch: dict[str, Any],
199
+ device: dict[str, Any],
200
+ *,
201
+ poe_enabled: bool,
202
+ ) -> None:
203
+ port_idx = _next_core_port(state)
204
+ _add_port(core_switch, port_idx, poe_enabled=poe_enabled, rng=state.rng)
205
+ _add_lldp_link(
206
+ core_switch,
207
+ device,
208
+ local_port=port_idx,
209
+ remote_port=1,
210
+ remote_name=device["name"],
211
+ )
212
+ _add_lldp_link(
213
+ device,
214
+ core_switch,
215
+ local_port=1,
216
+ remote_port=port_idx,
217
+ remote_name=core_switch["name"],
218
+ )
219
+
220
+
221
+ def _add_lldp_link(
222
+ source: dict[str, Any],
223
+ target: dict[str, Any],
224
+ *,
225
+ local_port: int,
226
+ remote_port: int,
227
+ remote_name: str,
228
+ ) -> None:
229
+ source["lldp_info"].append(
230
+ {
231
+ "chassis_id": target["mac"],
232
+ "port_id": f"Port {remote_port}",
233
+ "port_desc": remote_name,
234
+ "local_port_idx": local_port,
235
+ "local_port_name": f"Port {local_port}",
236
+ }
237
+ )
238
+
239
+
240
+ def _add_port(
241
+ device: dict[str, Any],
242
+ port_idx: int,
243
+ *,
244
+ poe_enabled: bool,
245
+ rng: random.Random,
246
+ ) -> None:
247
+ device["port_table"].append(
248
+ {
249
+ "port_idx": port_idx,
250
+ "name": f"Port {port_idx}",
251
+ "ifname": f"eth{port_idx}",
252
+ "is_uplink": False,
253
+ "poe_enable": poe_enabled,
254
+ "port_poe": poe_enabled,
255
+ "poe_class": 4 if poe_enabled else 0,
256
+ "poe_power": round(rng.uniform(2.0, 6.0), 2) if poe_enabled else 0.0,
257
+ "poe_good": poe_enabled,
258
+ "poe_voltage": round(rng.uniform(44.0, 52.0), 1) if poe_enabled else 0.0,
259
+ "poe_current": round(rng.uniform(0.05, 0.12), 3) if poe_enabled else 0.0,
260
+ }
261
+ )
262
+
263
+
264
+ def _next_core_port(state: _MockState) -> int:
265
+ port_idx = state.core_port_next
266
+ state.core_port_next += 1
267
+ return port_idx
268
+
269
+
270
+ def _unique_name(state: _MockState, prefix: str) -> str:
271
+ name = prefix
272
+ while name in state.used_names:
273
+ name = f"{prefix} {state.rng.randint(2, 9)}"
274
+ state.used_names.add(name)
275
+ return name
276
+
277
+
278
+ def _unique_client_name(state: _MockState) -> str:
279
+ name = state.fake.first_name()
280
+ while name in state.used_names:
281
+ name = state.fake.first_name()
282
+ state.used_names.add(name)
283
+ return name
284
+
285
+
286
+ def _unique_room(state: _MockState) -> str:
287
+ room = state.fake.word().title()
288
+ while room in state.used_rooms:
289
+ room = state.fake.word().title()
290
+ state.used_rooms.add(room)
291
+ return room
292
+
293
+
294
+ def _unique_mac(state: _MockState) -> str:
295
+ mac = state.fake.mac_address()
296
+ while mac in state.used_macs:
297
+ mac = state.fake.mac_address()
298
+ state.used_macs.add(mac)
299
+ return mac
300
+
301
+
302
+ def _unique_ip(state: _MockState) -> str:
303
+ ip = state.fake.ipv4_private()
304
+ while ip in state.used_ips:
305
+ ip = state.fake.ipv4_private()
306
+ state.used_ips.add(ip)
307
+ return ip
@@ -7,6 +7,8 @@ from html import escape as _escape_html
7
7
 
8
8
  from ..model.ports import extract_port_number
9
9
  from ..model.topology import ClientPortMap, Device, PortInfo, PortMap, classify_device_type
10
+ from .markdown_tables import markdown_table_lines
11
+ from .templating import render_template
10
12
 
11
13
 
12
14
  def render_device_port_overview(
@@ -17,18 +19,24 @@ def render_device_port_overview(
17
19
  ) -> str:
18
20
  gateways = _collect_devices_by_type(devices, "gateway")
19
21
  switches = _collect_devices_by_type(devices, "switch")
20
- lines: list[str] = []
22
+ sections: list[str] = []
21
23
  if gateways:
22
- lines.append("## Gateways")
23
- lines.append("")
24
- lines.extend(_render_device_group(gateways, port_map, client_ports))
24
+ sections.append(
25
+ render_template(
26
+ "markdown_section.md.j2",
27
+ title="Gateways",
28
+ body=_render_device_group(gateways, port_map, client_ports),
29
+ ).rstrip()
30
+ )
25
31
  if switches:
26
- if lines:
27
- lines.append("")
28
- lines.append("## Switches")
29
- lines.append("")
30
- lines.extend(_render_device_group(switches, port_map, client_ports))
31
- return "\n".join(lines).rstrip() + "\n"
32
+ sections.append(
33
+ render_template(
34
+ "markdown_section.md.j2",
35
+ title="Switches",
36
+ body=_render_device_group(switches, port_map, client_ports),
37
+ ).rstrip()
38
+ )
39
+ return "\n\n".join(section for section in sections if section).rstrip() + "\n"
32
40
 
33
41
 
34
42
  def _collect_devices_by_type(devices: list[Device], desired_type: str) -> list[Device]:
@@ -42,15 +50,18 @@ def _render_device_group(
42
50
  devices: list[Device],
43
51
  port_map: PortMap,
44
52
  client_ports: ClientPortMap | None,
45
- ) -> list[str]:
46
- lines: list[str] = []
53
+ ) -> str:
54
+ blocks: list[str] = []
47
55
  for device in devices:
48
- lines.append(f"### {device.name}")
49
- lines.append("")
50
- lines.extend(_render_device_details(device))
51
- lines.extend(_render_device_ports(device, port_map, client_ports))
52
- lines.append("")
53
- return lines
56
+ blocks.append(
57
+ render_template(
58
+ "device_port_block.md.j2",
59
+ device_name=device.name,
60
+ details="\n".join(_render_device_details(device)).rstrip(),
61
+ ports="\n".join(_render_device_ports(device, port_map, client_ports)).rstrip(),
62
+ ).rstrip()
63
+ )
64
+ return "\n\n".join(block for block in blocks if block)
54
65
 
55
66
 
56
67
  def render_device_port_details(
@@ -70,17 +81,23 @@ def _render_device_ports(
70
81
  client_ports: ClientPortMap | None,
71
82
  ) -> list[str]:
72
83
  rows = _build_port_rows(device, port_map, client_ports)
73
- lines = [
74
- "#### Ports",
75
- "",
76
- "| Port | Connected | Speed | PoE | Power |",
77
- "| --- | --- | --- | --- | --- |",
84
+ table_rows = [
85
+ [
86
+ _escape_cell(port_label),
87
+ _escape_cell(connected or "-"),
88
+ _escape_cell(speed),
89
+ _escape_cell(poe_state),
90
+ _escape_cell(power),
91
+ ]
92
+ for port_label, connected, speed, poe_state, power in rows
78
93
  ]
79
- for port_label, connected, speed, poe_state, power in rows:
80
- lines.append(
81
- f"| {_escape_cell(port_label)} | {_escape_cell(connected or '-')} | "
82
- f"{_escape_cell(speed)} | {_escape_cell(poe_state)} | {_escape_cell(power)} |"
94
+ lines = ["#### Ports", ""]
95
+ lines.extend(
96
+ markdown_table_lines(
97
+ ["Port", "Connected", "Speed", "PoE", "Power"],
98
+ table_rows,
83
99
  )
100
+ )
84
101
  return lines
85
102
 
86
103