mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-08-27 12:30:13 +03:00
feat: Add batch mode to node_create and link_create (parallel, max 10 workers)
- node_create accepts nodes=[{template_id, x, y, name?}] for batch creation
- link_create accepts links=[{nodes, link_type?, filters?}] for batch creation
- Uses ThreadPoolExecutor for parallel REST API calls
- Max 10 concurrent workers per batch, backward compatible with single mode
This commit is contained in:
parent
16d3f28cf5
commit
2c1a83114d
@ -445,12 +445,21 @@ async def node_suspend(
|
||||
@mcp.tool()
|
||||
async def node_create(
|
||||
project_id: Annotated[str, Field(description="UUID of the project")],
|
||||
template_id: Annotated[str, Field(description="UUID of the template to create the node from")],
|
||||
x: Annotated[int, Field(description="X coordinate on the project canvas")] = 0,
|
||||
y: Annotated[int, Field(description="Y coordinate on the project canvas")] = 0,
|
||||
template_id: Annotated[str | None, Field(description="Template UUID (required for single mode)")] = None,
|
||||
x: Annotated[int, Field(description="X coordinate")] = 0,
|
||||
y: Annotated[int, Field(description="Y coordinate")] = 0,
|
||||
compute_id: Annotated[str, Field(description="Compute ID (default: local)")] = "local",
|
||||
nodes: Annotated[list | None, Field(description="Batch mode: [{template_id, x?, y?, name?, compute_id?}] — creates multiple nodes in parallel")] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Create a new node from a template in a project."""
|
||||
"""Create one or more nodes from templates.
|
||||
|
||||
Single mode: provide template_id, x, y (optional compute_id)
|
||||
Batch mode: provide nodes=[{template_id, x, y, name?, compute_id?}] — creates up to 10 in parallel
|
||||
"""
|
||||
if nodes is not None:
|
||||
return await asyncio.to_thread(_run_handler_sync, create_node_handler, {
|
||||
"project_id": project_id, "nodes": nodes,
|
||||
})
|
||||
return await asyncio.to_thread(_run_handler_sync, create_node_handler, {
|
||||
"project_id": project_id, "template_id": template_id,
|
||||
"x": x, "y": y, "compute_id": compute_id,
|
||||
@ -527,21 +536,20 @@ async def link_get(
|
||||
@mcp.tool()
|
||||
async def link_create(
|
||||
project_id: Annotated[str, Field(description="UUID of the project")],
|
||||
nodes: Annotated[list, Field(description="List of node connections, e.g. [{\"node_id\": \"...\", \"adapter_number\": 0, \"port_number\": 0}]")],
|
||||
nodes: Annotated[list | None, Field(description="Single mode: [{node_id, adapter_number, port_number}]")] = None,
|
||||
link_type: Annotated[str, Field(description="Link type - ethernet or serial")] = "ethernet",
|
||||
filters: Annotated[dict, Field(description="Optional packet filters (must use array format): frequency_drop: [N], packet_loss: [rate], delay: [ms, jitter], corrupt: [rate], bpf: [expression]")] = None,
|
||||
filters: Annotated[dict | None, Field(description="Optional packet filters")] = None,
|
||||
links: Annotated[list | None, Field(description="Batch mode: [{nodes, link_type?, filters?}] — creates multiple links in parallel")] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Create a link between two nodes in a project.
|
||||
"""Create one or more links between nodes.
|
||||
|
||||
Filters must use array format:
|
||||
- frequency_drop: [N] - Drop every Nth packet (N: -1 to 32767)
|
||||
- packet_loss: [rate] - Packet loss percentage (rate: 0 to 100)
|
||||
- delay: [ms, jitter] - Latency and jitter in milliseconds
|
||||
- corrupt: [rate] - Packet corruption percentage (rate: 0 to 100)
|
||||
- bpf: [expression] - Berkeley Packet Filter expression
|
||||
|
||||
Example: {"filters": {"delay": [100, 10], "packet_loss": [5]}}
|
||||
Single mode: provide nodes, link_type (optional filters)
|
||||
Batch mode: provide links=[{nodes, link_type?, filters?}] — up to 10 in parallel
|
||||
"""
|
||||
if links:
|
||||
return await asyncio.to_thread(_run_handler_sync, create_link_handler, {
|
||||
"project_id": project_id, "links": links,
|
||||
})
|
||||
params = {"project_id": project_id, "nodes": nodes, "link_type": link_type}
|
||||
if filters:
|
||||
params["filters"] = filters
|
||||
|
||||
@ -23,11 +23,14 @@ via Gns3Connector (from custom_gns3fy).
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
BATCH_MAX_WORKERS = 10
|
||||
|
||||
|
||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -63,9 +66,42 @@ def get_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[s
|
||||
|
||||
def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
|
||||
links = params.get("links")
|
||||
# Batch mode: links=[{nodes, link_type?, filters?, suspend?}]
|
||||
if links is not None:
|
||||
if not isinstance(links, list) or not links:
|
||||
return {"error": "links must be a non-empty array"}
|
||||
results = []
|
||||
conn = _get_connector(gns3_ctx)
|
||||
def _create_one(link_data):
|
||||
if not link_data.get("nodes"):
|
||||
return {"status": "error", "error": "nodes is required for each link"}
|
||||
try:
|
||||
body = {"nodes": link_data["nodes"]}
|
||||
if link_data.get("link_type"):
|
||||
body["link_type"] = link_data["link_type"]
|
||||
if link_data.get("filters"):
|
||||
body["filters"] = link_data["filters"]
|
||||
if link_data.get("suspend"):
|
||||
body["suspend"] = link_data["suspend"]
|
||||
url = f"{conn.base_url}/projects/{project_id}/links"
|
||||
resp = conn.http_call("post", url, json_data=body).json()
|
||||
return {"status": "success", "link": resp}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
with ThreadPoolExecutor(max_workers=min(len(links), BATCH_MAX_WORKERS)) as pool:
|
||||
futures = {pool.submit(_create_one, l): l for l in links}
|
||||
for future in as_completed(futures):
|
||||
results.append(future.result())
|
||||
return results
|
||||
|
||||
# Single mode
|
||||
nodes = params.get("nodes")
|
||||
if not project_id or not nodes:
|
||||
return {"error": "project_id and nodes are required"}
|
||||
if not nodes:
|
||||
return {"error": "nodes is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
data = {"nodes": nodes}
|
||||
if "link_type" in params:
|
||||
|
||||
@ -23,11 +23,14 @@ via Gns3Connector (from custom_gns3fy).
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
BATCH_MAX_WORKERS = 10
|
||||
|
||||
# ── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
# Maximum bytes to return from get_node_file (safety net).
|
||||
@ -109,9 +112,41 @@ def suspend_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> di
|
||||
|
||||
def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
|
||||
nodes = params.get("nodes")
|
||||
# Batch mode: nodes=[{template_id, x, y, name?, compute_id?}]
|
||||
if nodes is not None:
|
||||
if not isinstance(nodes, list) or not nodes:
|
||||
return {"error": "nodes must be a non-empty array"}
|
||||
results = []
|
||||
conn = _get_connector(gns3_ctx)
|
||||
def _create_one(node_data):
|
||||
tid = node_data.get("template_id")
|
||||
if not tid:
|
||||
return {"template_id": tid, "status": "error", "error": "template_id is required"}
|
||||
try:
|
||||
url = f"{conn.base_url}/projects/{project_id}/templates/{tid}"
|
||||
body = {
|
||||
"x": node_data.get("x", 0),
|
||||
"y": node_data.get("y", 0),
|
||||
"compute_id": node_data.get("compute_id", "local"),
|
||||
}
|
||||
resp = conn.http_call("post", url, json_data=body).json()
|
||||
return {"template_id": tid, "status": "success", "node": resp}
|
||||
except Exception as e:
|
||||
return {"template_id": tid, "status": "error", "error": str(e)}
|
||||
with ThreadPoolExecutor(max_workers=min(len(nodes), BATCH_MAX_WORKERS)) as pool:
|
||||
futures = {pool.submit(_create_one, n): n for n in nodes}
|
||||
for future in as_completed(futures):
|
||||
results.append(future.result())
|
||||
return results
|
||||
|
||||
# Single mode
|
||||
template_id = params.get("template_id")
|
||||
if not project_id or not template_id:
|
||||
return {"error": "project_id and template_id are required"}
|
||||
if not template_id:
|
||||
return {"error": "template_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
data = {
|
||||
"x": params.get("x", 0),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user