iflow-mcp_enuno-unifi-mcp-server 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/METADATA +1282 -0
  2. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/RECORD +81 -0
  3. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/WHEEL +4 -0
  4. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/entry_points.txt +2 -0
  5. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/licenses/LICENSE +201 -0
  6. src/__init__.py +3 -0
  7. src/__main__.py +6 -0
  8. src/api/__init__.py +5 -0
  9. src/api/client.py +727 -0
  10. src/api/site_manager_client.py +176 -0
  11. src/cache.py +483 -0
  12. src/config/__init__.py +5 -0
  13. src/config/config.py +321 -0
  14. src/main.py +2234 -0
  15. src/models/__init__.py +126 -0
  16. src/models/acl.py +41 -0
  17. src/models/backup.py +272 -0
  18. src/models/client.py +74 -0
  19. src/models/device.py +53 -0
  20. src/models/dpi.py +50 -0
  21. src/models/firewall_policy.py +123 -0
  22. src/models/firewall_zone.py +28 -0
  23. src/models/network.py +62 -0
  24. src/models/qos_profile.py +458 -0
  25. src/models/radius.py +141 -0
  26. src/models/reference_data.py +34 -0
  27. src/models/site.py +59 -0
  28. src/models/site_manager.py +120 -0
  29. src/models/topology.py +138 -0
  30. src/models/traffic_flow.py +137 -0
  31. src/models/traffic_matching_list.py +56 -0
  32. src/models/voucher.py +42 -0
  33. src/models/vpn.py +73 -0
  34. src/models/wan.py +48 -0
  35. src/models/zbf_matrix.py +49 -0
  36. src/resources/__init__.py +8 -0
  37. src/resources/clients.py +111 -0
  38. src/resources/devices.py +102 -0
  39. src/resources/networks.py +93 -0
  40. src/resources/site_manager.py +64 -0
  41. src/resources/sites.py +86 -0
  42. src/tools/__init__.py +25 -0
  43. src/tools/acls.py +328 -0
  44. src/tools/application.py +42 -0
  45. src/tools/backups.py +1173 -0
  46. src/tools/client_management.py +505 -0
  47. src/tools/clients.py +203 -0
  48. src/tools/device_control.py +325 -0
  49. src/tools/devices.py +354 -0
  50. src/tools/dpi.py +241 -0
  51. src/tools/dpi_tools.py +89 -0
  52. src/tools/firewall.py +417 -0
  53. src/tools/firewall_policies.py +430 -0
  54. src/tools/firewall_zones.py +515 -0
  55. src/tools/network_config.py +388 -0
  56. src/tools/networks.py +190 -0
  57. src/tools/port_forwarding.py +263 -0
  58. src/tools/qos.py +1070 -0
  59. src/tools/radius.py +763 -0
  60. src/tools/reference_data.py +107 -0
  61. src/tools/site_manager.py +466 -0
  62. src/tools/site_vpn.py +95 -0
  63. src/tools/sites.py +187 -0
  64. src/tools/topology.py +406 -0
  65. src/tools/traffic_flows.py +1062 -0
  66. src/tools/traffic_matching_lists.py +371 -0
  67. src/tools/vouchers.py +249 -0
  68. src/tools/vpn.py +76 -0
  69. src/tools/wans.py +30 -0
  70. src/tools/wifi.py +498 -0
  71. src/tools/zbf_matrix.py +326 -0
  72. src/utils/__init__.py +88 -0
  73. src/utils/audit.py +213 -0
  74. src/utils/exceptions.py +114 -0
  75. src/utils/helpers.py +159 -0
  76. src/utils/logger.py +105 -0
  77. src/utils/sanitize.py +244 -0
  78. src/utils/validators.py +160 -0
  79. src/webhooks/__init__.py +6 -0
  80. src/webhooks/handlers.py +196 -0
  81. src/webhooks/receiver.py +290 -0
src/tools/sites.py ADDED
@@ -0,0 +1,187 @@
1
+ """Site management MCP tools."""
2
+
3
+ from typing import Any
4
+
5
+ from ..api import UniFiClient
6
+ from ..config import Settings
7
+ from ..models import Site
8
+ from ..utils import ResourceNotFoundError, get_logger, validate_limit_offset, validate_site_id
9
+
10
+
11
+ async def get_site_details(site_id: str, settings: Settings) -> dict[str, Any]:
12
+ """Get detailed site information.
13
+
14
+ Args:
15
+ site_id: Site identifier
16
+ settings: Application settings
17
+
18
+ Returns:
19
+ Site details dictionary
20
+
21
+ Raises:
22
+ ResourceNotFoundError: If site not found
23
+ """
24
+ site_id = validate_site_id(site_id)
25
+ logger = get_logger(__name__, settings.log_level)
26
+
27
+ async with UniFiClient(settings) as client:
28
+ await client.authenticate()
29
+
30
+ response = await client.get("/ea/sites")
31
+
32
+ # Handle both local and cloud API response formats
33
+ if isinstance(response, list):
34
+ sites_data = response
35
+ else:
36
+ sites_data = response.get("data", [])
37
+
38
+ for site_data in sites_data:
39
+ if site_data.get("_id") == site_id or site_data.get("name") == site_id:
40
+ site = Site(**site_data)
41
+ logger.info(f"Retrieved site details for {site_id}")
42
+ return site.model_dump() # type: ignore[no-any-return]
43
+
44
+ raise ResourceNotFoundError("site", site_id)
45
+
46
+
47
+ async def list_sites(
48
+ settings: Settings, limit: int | None = None, offset: int | None = None
49
+ ) -> list[dict[str, Any]]:
50
+ """List all accessible sites.
51
+
52
+ Args:
53
+ settings: Application settings
54
+ limit: Maximum number of sites to return
55
+ offset: Number of sites to skip
56
+
57
+ Returns:
58
+ List of site dictionaries
59
+ """
60
+ limit, offset = validate_limit_offset(limit, offset)
61
+ logger = get_logger(__name__, settings.log_level)
62
+
63
+ try:
64
+ async with UniFiClient(settings) as client:
65
+ await client.authenticate()
66
+
67
+ # Use correct endpoint based on API type
68
+ if settings.api_type.value == "local":
69
+ endpoint = settings.get_integration_path("sites")
70
+ else:
71
+ endpoint = "/ea/sites"
72
+
73
+ logger.debug(f"Fetching sites from endpoint: {endpoint}")
74
+ response = await client.get(endpoint)
75
+ logger.debug(f"Raw response: {response}")
76
+
77
+ # Handle both local and cloud API response formats
78
+ if isinstance(response, list):
79
+ sites_data = response
80
+ else:
81
+ sites_data = response.get("data", [])
82
+
83
+ logger.debug(f"Extracted {len(sites_data)} sites from response")
84
+
85
+ # Apply pagination
86
+ paginated = sites_data[offset : offset + limit]
87
+ logger.debug(f"Paginated to {len(paginated)} sites")
88
+
89
+ # Parse into Site models
90
+ sites = []
91
+ for idx, s in enumerate(paginated):
92
+ try:
93
+ logger.debug(f"Parsing site {idx}: {s}")
94
+ site_obj = Site(**s)
95
+ sites.append(site_obj.model_dump())
96
+ except Exception as e:
97
+ logger.error(f"Failed to parse site {idx} ({s}): {e}", exc_info=True)
98
+ raise
99
+
100
+ logger.info(f"Retrieved {len(sites)} sites (offset={offset}, limit={limit})")
101
+ return sites
102
+ except Exception as e:
103
+ logger.error(f"Error listing sites: {e}", exc_info=True)
104
+ raise
105
+
106
+
107
+ async def get_site_statistics(site_id: str, settings: Settings) -> dict[str, Any]:
108
+ """Retrieve site-wide statistics.
109
+
110
+ Args:
111
+ site_id: Site identifier
112
+ settings: Application settings
113
+
114
+ Returns:
115
+ Site statistics dictionary
116
+ """
117
+ site_id = validate_site_id(site_id)
118
+ logger = get_logger(__name__, settings.log_level)
119
+
120
+ async with UniFiClient(settings) as client:
121
+ await client.authenticate()
122
+
123
+ # Gather statistics from multiple endpoints
124
+ devices_response = await client.get(f"/ea/sites/{site_id}/devices")
125
+ clients_response = await client.get(f"/ea/sites/{site_id}/sta")
126
+ networks_response = await client.get(f"/ea/sites/{site_id}/rest/networkconf")
127
+
128
+ devices_data = (
129
+ devices_response.get("data", [])
130
+ if isinstance(devices_response, dict)
131
+ else devices_response
132
+ )
133
+ clients_data = (
134
+ clients_response.get("data", [])
135
+ if isinstance(clients_response, dict)
136
+ else clients_response
137
+ )
138
+ networks_data = (
139
+ networks_response.get("data", [])
140
+ if isinstance(networks_response, dict)
141
+ else networks_response
142
+ )
143
+
144
+ # Count device types
145
+ ap_count = sum(1 for d in devices_data if d.get("type") == "uap")
146
+ switch_count = sum(1 for d in devices_data if d.get("type") == "usw")
147
+ gateway_count = sum(1 for d in devices_data if d.get("type") in ["ugw", "udm", "uxg"])
148
+
149
+ # Count online/offline devices
150
+ online_devices = sum(1 for d in devices_data if d.get("state") == 1)
151
+ offline_devices = len(devices_data) - online_devices
152
+
153
+ # Count wired vs wireless clients
154
+ wired_clients = sum(1 for c in clients_data if c.get("is_wired") is True)
155
+ wireless_clients = len(clients_data) - wired_clients
156
+
157
+ # Calculate total bandwidth
158
+ total_tx = sum(c.get("tx_bytes", 0) for c in clients_data)
159
+ total_rx = sum(c.get("rx_bytes", 0) for c in clients_data)
160
+
161
+ statistics = {
162
+ "site_id": site_id,
163
+ "devices": {
164
+ "total": len(devices_data),
165
+ "online": online_devices,
166
+ "offline": offline_devices,
167
+ "access_points": ap_count,
168
+ "switches": switch_count,
169
+ "gateways": gateway_count,
170
+ },
171
+ "clients": {
172
+ "total": len(clients_data),
173
+ "wired": wired_clients,
174
+ "wireless": wireless_clients,
175
+ },
176
+ "networks": {
177
+ "total": len(networks_data),
178
+ },
179
+ "bandwidth": {
180
+ "total_tx_bytes": total_tx,
181
+ "total_rx_bytes": total_rx,
182
+ "total_bytes": total_tx + total_rx,
183
+ },
184
+ }
185
+
186
+ logger.info(f"Retrieved statistics for site '{site_id}'")
187
+ return statistics
src/tools/topology.py ADDED
@@ -0,0 +1,406 @@
1
+ """Network topology tools for UniFi MCP Server."""
2
+
3
+ import json
4
+ from datetime import datetime, timezone
5
+ from typing import Literal
6
+
7
+ from src.api.client import UniFiClient
8
+ from src.config import Settings
9
+ from src.models.topology import NetworkDiagram, TopologyConnection, TopologyNode
10
+ from src.utils.exceptions import ValidationError
11
+
12
+
13
+ async def get_network_topology(
14
+ site_id: str,
15
+ settings: Settings,
16
+ include_coordinates: bool = False,
17
+ ) -> dict:
18
+ """
19
+ Retrieve complete network topology graph.
20
+
21
+ Fetches the network topology including all devices, clients, and their
22
+ interconnections. Optionally includes position coordinates for visualization.
23
+
24
+ Args:
25
+ site_id: Site identifier ("default" for default site)
26
+ settings: Application settings with UniFi controller connection info
27
+ include_coordinates: Whether to calculate node position coordinates
28
+
29
+ Returns:
30
+ Network diagram dictionary with nodes, connections, and statistics
31
+
32
+ Example:
33
+ ```python
34
+ topology = await get_network_topology("default", settings, include_coordinates=True)
35
+ print(f"Total devices: {topology['total_devices']}")
36
+ print(f"Total clients: {topology['total_clients']}")
37
+ ```
38
+ """
39
+ async with UniFiClient(settings) as client:
40
+ if not client.is_authenticated:
41
+ await client.authenticate()
42
+
43
+ actual_site_id = await client.resolve_site_id(site_id)
44
+
45
+ # Fetch devices and clients from UniFi Integration API
46
+ devices_endpoint = client.settings.get_integration_path(f"sites/{actual_site_id}/devices")
47
+ clients_endpoint = client.settings.get_integration_path(f"sites/{actual_site_id}/clients")
48
+
49
+ # Fetch all devices and clients (handle pagination)
50
+ device_nodes = []
51
+ offset = 0
52
+ while True:
53
+ response = await client.get(f"{devices_endpoint}?offset={offset}&limit=100")
54
+ data = response if isinstance(response, list) else response.get("data", [])
55
+ if not data:
56
+ break
57
+ device_nodes.extend(data)
58
+ offset += len(data)
59
+ if len(data) < 100:
60
+ break
61
+
62
+ client_nodes = []
63
+ offset = 0
64
+ while True:
65
+ response = await client.get(f"{clients_endpoint}?offset={offset}&limit=100")
66
+ data = response if isinstance(response, list) else response.get("data", [])
67
+ if not data:
68
+ break
69
+ client_nodes.extend(data)
70
+ offset += len(data)
71
+ if len(data) < 100:
72
+ break
73
+
74
+ # Convert devices to topology nodes
75
+ nodes = []
76
+ connections = []
77
+ depth_map = {} # Track network depth for each device
78
+
79
+ # First pass: Create all device nodes and calculate depth
80
+ for device in device_nodes:
81
+ device_id = device.get("id", "")
82
+ uplink_info = device.get("uplink", {})
83
+ uplink_device_id = uplink_info.get("deviceId")
84
+
85
+ # Calculate depth (distance from gateway)
86
+ if uplink_device_id:
87
+ parent_depth = depth_map.get(uplink_device_id, 0)
88
+ depth_map[device_id] = parent_depth + 1
89
+ else:
90
+ depth_map[device_id] = 0 # Gateway device
91
+
92
+ node = TopologyNode(
93
+ node_id=device_id,
94
+ node_type="device",
95
+ name=device.get("name"),
96
+ mac=device.get("macAddress"),
97
+ ip=device.get("ipAddress"),
98
+ model=device.get("model"),
99
+ type_detail=device.get("model"),
100
+ uplink_device_id=uplink_device_id,
101
+ uplink_port=uplink_info.get("portIndex"),
102
+ uplink_depth=depth_map.get(device_id, 0),
103
+ state=1 if device.get("state") == "CONNECTED" else 0,
104
+ adopted=True, # All returned devices are adopted
105
+ )
106
+ nodes.append(node)
107
+
108
+ # Create connection if device has uplink
109
+ if uplink_device_id:
110
+ conn = TopologyConnection(
111
+ connection_id=f"conn_{device_id}_uplink",
112
+ source_node_id=device_id,
113
+ target_node_id=uplink_device_id,
114
+ connection_type="uplink",
115
+ source_port=uplink_info.get("portIndex"),
116
+ speed_mbps=uplink_info.get("speedMbps"),
117
+ is_uplink=True,
118
+ status="up" if device.get("state") == "CONNECTED" else "down",
119
+ )
120
+ connections.append(conn)
121
+
122
+ # Process clients
123
+ for client_data in client_nodes:
124
+ client_id = client_data.get("id", "")
125
+ client_type = client_data.get("type", "WIRED")
126
+ uplink_device_id = client_data.get("uplinkDeviceId")
127
+
128
+ node = TopologyNode(
129
+ node_id=client_id,
130
+ node_type="client",
131
+ name=client_data.get("name"),
132
+ mac=client_data.get("macAddress"),
133
+ ip=client_data.get("ipAddress"),
134
+ state=1, # All returned clients are connected
135
+ )
136
+ nodes.append(node)
137
+
138
+ # Create connection for client
139
+ if uplink_device_id:
140
+ conn_type = "wired" if client_type == "WIRED" else "wireless"
141
+ conn = TopologyConnection(
142
+ connection_id=f"conn_client_{client_id}",
143
+ source_node_id=client_id,
144
+ target_node_id=uplink_device_id,
145
+ connection_type=conn_type,
146
+ is_uplink=False,
147
+ status="up",
148
+ )
149
+ connections.append(conn)
150
+
151
+ # Calculate statistics
152
+ total_devices = len([n for n in nodes if n.node_type == "device"])
153
+ total_clients = len([n for n in nodes if n.node_type == "client"])
154
+ max_depth = max([n.uplink_depth for n in nodes if n.uplink_depth is not None], default=0)
155
+
156
+ # Build network diagram
157
+ diagram = NetworkDiagram(
158
+ site_id=actual_site_id,
159
+ generated_at=datetime.now(timezone.utc).isoformat(),
160
+ nodes=nodes,
161
+ connections=connections,
162
+ total_devices=total_devices,
163
+ total_clients=total_clients,
164
+ total_connections=len(connections),
165
+ max_depth=max_depth,
166
+ has_coordinates=include_coordinates,
167
+ )
168
+
169
+ return diagram.model_dump()
170
+
171
+
172
+ async def get_device_connections(
173
+ site_id: str,
174
+ device_id: str | None,
175
+ settings: Settings,
176
+ ) -> list[dict]:
177
+ """
178
+ Get device interconnection details.
179
+
180
+ Retrieves detailed connection information for a specific device or all devices.
181
+
182
+ Args:
183
+ site_id: Site identifier
184
+ device_id: Specific device ID, or None for all devices
185
+ settings: Application settings
186
+
187
+ Returns:
188
+ List of connection dictionaries
189
+
190
+ Example:
191
+ ```python
192
+ connections = await get_device_connections("default", "switch_001", settings)
193
+ for conn in connections:
194
+ print(f"{conn['source_node_id']} -> {conn['target_node_id']}")
195
+ ```
196
+ """
197
+ topology = await get_network_topology(site_id, settings)
198
+
199
+ connections = topology.get("connections", [])
200
+
201
+ if device_id:
202
+ # Filter connections for specific device
203
+ connections = [
204
+ conn
205
+ for conn in connections
206
+ if conn.get("source_node_id") == device_id or conn.get("target_node_id") == device_id
207
+ ]
208
+
209
+ return connections
210
+
211
+
212
+ async def get_port_mappings(
213
+ site_id: str,
214
+ device_id: str,
215
+ settings: Settings,
216
+ ) -> dict:
217
+ """
218
+ Get port-level connection mappings for a device.
219
+
220
+ Retrieves detailed information about which ports are connected to which devices/clients.
221
+
222
+ Args:
223
+ site_id: Site identifier
224
+ device_id: Device ID
225
+ settings: Application settings
226
+
227
+ Returns:
228
+ Dictionary with device_id and port mapping information
229
+
230
+ Example:
231
+ ```python
232
+ ports = await get_port_mappings("default", "switch_001", settings)
233
+ for port_num, connected_device in ports['ports'].items():
234
+ print(f"Port {port_num}: {connected_device}")
235
+ ```
236
+ """
237
+ topology = await get_network_topology(site_id, settings)
238
+
239
+ connections = topology.get("connections", [])
240
+
241
+ # Build port mapping
242
+ port_map = {}
243
+
244
+ for conn in connections:
245
+ if conn.get("source_node_id") == device_id:
246
+ port_num = conn.get("source_port")
247
+ if port_num is not None:
248
+ port_map[port_num] = {
249
+ "connected_to": conn.get("target_node_id"),
250
+ "connection_type": conn.get("connection_type"),
251
+ "speed_mbps": conn.get("speed_mbps"),
252
+ "status": conn.get("status"),
253
+ }
254
+ elif conn.get("target_node_id") == device_id:
255
+ port_num = conn.get("target_port")
256
+ if port_num is not None:
257
+ port_map[port_num] = {
258
+ "connected_to": conn.get("source_node_id"),
259
+ "connection_type": conn.get("connection_type"),
260
+ "speed_mbps": conn.get("speed_mbps"),
261
+ "status": conn.get("status"),
262
+ }
263
+
264
+ return {"device_id": device_id, "ports": port_map}
265
+
266
+
267
+ async def export_topology(
268
+ site_id: str,
269
+ format: Literal["json", "graphml", "dot"],
270
+ settings: Settings,
271
+ ) -> str:
272
+ """
273
+ Export network topology in various formats.
274
+
275
+ Exports the network topology as JSON, GraphML (XML), or DOT (Graphviz) format.
276
+
277
+ Args:
278
+ site_id: Site identifier
279
+ format: Export format ("json", "graphml", or "dot")
280
+ settings: Application settings
281
+
282
+ Returns:
283
+ Topology data as a formatted string
284
+
285
+ Raises:
286
+ ValidationError: If invalid format is specified
287
+
288
+ Example:
289
+ ```python
290
+ # Export as JSON
291
+ json_data = await export_topology("default", "json", settings)
292
+
293
+ # Export as GraphML for network visualization tools
294
+ graphml_data = await export_topology("default", "graphml", settings)
295
+
296
+ # Export as DOT for Graphviz
297
+ dot_data = await export_topology("default", "dot", settings)
298
+ ```
299
+ """
300
+ if format not in ["json", "graphml", "dot"]:
301
+ raise ValidationError(
302
+ f"Invalid export format: {format}. Must be 'json', 'graphml', or 'dot'"
303
+ )
304
+
305
+ topology = await get_network_topology(site_id, settings)
306
+
307
+ if format == "json":
308
+ return json.dumps(topology, indent=2)
309
+
310
+ elif format == "graphml":
311
+ # Generate GraphML XML
312
+ nodes = topology.get("nodes", [])
313
+ connections = topology.get("connections", [])
314
+
315
+ graphml = ['<?xml version="1.0" encoding="UTF-8"?>']
316
+ graphml.append('<graphml xmlns="http://graphml.graphdrawing.org/xmlns">')
317
+ graphml.append(' <graph id="UniFi Network" edgedefault="directed">')
318
+
319
+ # Add nodes
320
+ for node in nodes:
321
+ node_id = node.get("node_id", "")
322
+ node_type = node.get("node_type", "")
323
+ name = node.get("name", "")
324
+ graphml.append(f' <node id="{node_id}">')
325
+ graphml.append(f' <data key="type">{node_type}</data>')
326
+ graphml.append(f' <data key="name">{name}</data>')
327
+ graphml.append(" </node>")
328
+
329
+ # Add edges
330
+ for conn in connections:
331
+ source = conn.get("source_node_id", "")
332
+ target = conn.get("target_node_id", "")
333
+ conn_type = conn.get("connection_type", "")
334
+ graphml.append(f' <edge source="{source}" target="{target}">')
335
+ graphml.append(f' <data key="type">{conn_type}</data>')
336
+ graphml.append(" </edge>")
337
+
338
+ graphml.append(" </graph>")
339
+ graphml.append("</graphml>")
340
+
341
+ return "\n".join(graphml)
342
+
343
+ elif format == "dot":
344
+ # Generate DOT format
345
+ nodes = topology.get("nodes", [])
346
+ connections = topology.get("connections", [])
347
+
348
+ dot = ["digraph UniFiNetwork {"]
349
+ dot.append(" node [shape=box];")
350
+
351
+ # Add nodes
352
+ for node in nodes:
353
+ node_id = node.get("node_id", "")
354
+ name = node.get("name", node_id)
355
+ node_type = node.get("node_type", "")
356
+ dot.append(f' "{node_id}" [label="{name}\\n({node_type})"];')
357
+
358
+ # Add edges
359
+ for conn in connections:
360
+ source = conn.get("source_node_id", "")
361
+ target = conn.get("target_node_id", "")
362
+ conn_type = conn.get("connection_type", "")
363
+ dot.append(f' "{source}" -> "{target}" [label="{conn_type}"];')
364
+
365
+ dot.append("}")
366
+
367
+ return "\n".join(dot)
368
+
369
+ return ""
370
+
371
+
372
+ async def get_topology_statistics(
373
+ site_id: str,
374
+ settings: Settings,
375
+ ) -> dict:
376
+ """
377
+ Get network topology statistics.
378
+
379
+ Retrieves statistical summary of the network topology including device counts,
380
+ client counts, connection counts, and network depth.
381
+
382
+ Args:
383
+ site_id: Site identifier
384
+ settings: Application settings
385
+
386
+ Returns:
387
+ Dictionary with topology statistics
388
+
389
+ Example:
390
+ ```python
391
+ stats = await get_topology_statistics("default", settings)
392
+ print(f"Devices: {stats['total_devices']}")
393
+ print(f"Clients: {stats['total_clients']}")
394
+ print(f"Max network depth: {stats['max_depth']}")
395
+ ```
396
+ """
397
+ topology = await get_network_topology(site_id, settings)
398
+
399
+ return {
400
+ "site_id": topology.get("site_id"),
401
+ "total_devices": topology.get("total_devices", 0),
402
+ "total_clients": topology.get("total_clients", 0),
403
+ "total_connections": topology.get("total_connections", 0),
404
+ "max_depth": topology.get("max_depth", 0),
405
+ "generated_at": topology.get("generated_at"),
406
+ }