marker: support clearing direction via explicit null / "both"

direction was settable but not clearable: once a marker or project-level
definition had direction=tx/rx, no update path could return it to "both
directions", and the definition-sync fan-out silently kept stale values on
every inherited copy.

Introduce a _UNSET sentinel (link.py) distinct from None so updaters can
tell "caller omitted direction" (preserve) from "caller passed None"
(clear). Thread it through UDPLink.update_marker and
Project.update_marker_definition; the two REST routes use Pydantic v2
model_fields_set to translate an explicit JSON null into the sentinel.

MCP follows with a "both" token: link_marker / marker_definition handlers
map direction="both" to a null in the REST body (tri-state: omit=preserve,
tx/rx=set, both=clear), and the tool descriptions/docstrings document it.

Backward compatible: omitting direction or passing tx/rx behaves exactly
as before; only an explicit null / "both" clears.
This commit is contained in:
YueGuobin committed 2026-08-02 11:37:49 +08:00
1 parent 71fa778d50
commit 37cb9f0a9c
7 files changed
+48 -21

No files matched your search

+2 -2
View File
@@ -32,7 +32,7 @@ from uuid import UUID, uuid4
from gns3server.controller import Controller
from gns3server.controller.controller_error import ControllerError
from gns3server.db.repositories.rbac import RbacRepository
from gns3server.controller.link import Link
from gns3server.controller.link import Link, _UNSET
from gns3server.utils.http_client import HTTPClient
from gns3server.utils.port_allocator import link_id_to_port
from gns3server.utils.websocket_to_websocket import websocket_proxy
@@ -510,7 +510,7 @@ async def update_marker(
name=marker_name,
bpf=marker_data.bpf if marker_data.bpf else None,
tag=marker_data.tag,
direction=marker_data.direction,
direction=marker_data.direction if "direction" in marker_data.model_fields_set else _UNSET,
color=marker_data.color,
enabled=marker_data.enabled,
highlight_duration=marker_data.highlight_duration,
+2 -1
View File
@@ -40,6 +40,7 @@ from uuid import UUID
from gns3server import schemas
from gns3server.controller import Controller
from gns3server.controller.project import Project
from gns3server.controller.link import _UNSET
from gns3server.controller.controller_error import ControllerError, ControllerBadRequestError
from gns3server.controller.import_project import import_project as import_controller_project
from gns3server.controller.export_project import export_project as export_controller_project
@@ -293,7 +294,7 @@ async def update_marker_definition(
name=def_name,
bpf=def_data.bpf if def_data.bpf else None,
tag=def_data.tag,
direction=def_data.direction,
direction=def_data.direction if "direction" in def_data.model_fields_set else _UNSET,
color=def_data.color,
highlight_duration=def_data.highlight_duration,
)
+4 -4
View File
@@ -1015,7 +1015,7 @@ async def link_marker(
name: Annotated[str | None, Field(description="Custom marker name for create action (auto-generated if omitted)")] = None,
tag: Annotated[int | None, Field(description="Numeric tag for packet correlation")] = None,
enabled: Annotated[bool | None, Field(description="Enable or disable the marker (for update action)")] = None,
direction: Annotated[str | None, Field(description="Direction filter: 'tx' for capture node sending only, 'rx' for capture node receiving only (omit for both)")] = None,
direction: Annotated[str | None, Field(description="Direction filter: 'tx' (capture node sending only), 'rx' (receiving only), or 'both' (no filter — on update this clears a previously set direction). Omit to leave unchanged on update.")] = None,
capture_node_id: Annotated[str | None, Field(description="UUID of the endpoint whose uBridge hosts the marker (the observer; tx/rx are from its perspective). Must be a link endpoint and marker-capable. Omit to auto-pick.")] = None,
color: Annotated[str | None, Field(description="Hex color for UI highlight, e.g. '#ff5722'")] = None,
highlight_duration: Annotated[int | None, Field(description="UI highlight duration in milliseconds")] = None,
@@ -1026,7 +1026,7 @@ async def link_marker(
Set action='create' to add a marker, 'update' to modify it, 'delete' to remove.
Create requires: project_id, link_id, action='create', bpf
Update requires: project_id, link_id, action='update', marker_name, and at least one of (bpf, tag, enabled, color, highlight_duration)
Update requires: project_id, link_id, action='update', marker_name, and at least one of (bpf, tag, enabled, direction, color, highlight_duration)
Delete requires: project_id, link_id, action='delete', marker_name
To read current markers, use link_get — the response includes a 'markers' dict.
@@ -1052,7 +1052,7 @@ async def marker_definition(
tag: Annotated[int | None, Field(description="Numeric tag for packet correlation")] = None,
color: Annotated[str | None, Field(description="Hex color for UI highlight, e.g. '#ff5722'")] = None,
highlight_duration: Annotated[int | None, Field(description="UI highlight duration in milliseconds")] = None,
direction: Annotated[str | None, Field(description="Direction filter: 'tx' for capture node sending only, 'rx' for capture node receiving only (omit for both)")] = None,
direction: Annotated[str | None, Field(description="Direction filter: 'tx' (capture node sending only), 'rx' (receiving only), or 'both' (no filter — on update this clears a previously set direction). Omit to leave unchanged on update.")] = None,
) -> list[dict[str, Any]]:
"""Manage project-level marker definitions — traffic-insight rules that apply to ALL links.
@@ -1061,7 +1061,7 @@ async def marker_definition(
On delete, 'global-{name}' is removed from every link.
Create requires: project_id, action='create', bpf
Update requires: project_id, action='update', def_name, and at least one of (bpf, tag, color, highlight_duration)
Update requires: project_id, action='update', def_name, and at least one of (bpf, tag, direction, color, highlight_duration)
Delete requires: project_id, action='delete', def_name
List requires: project_id, action='list'
+24 -6
View File
@@ -395,9 +395,12 @@ def link_marker_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
if not bpf:
return {"error": "bpf is required for create action"}
body: dict[str, Any] = {"bpf": bpf}
for opt in ("name", "tag", "direction", "capture_node_id", "color", "highlight_duration"):
for opt in ("name", "tag", "capture_node_id", "color", "highlight_duration"):
if params.get(opt) is not None:
body[opt] = params[opt]
# direction: "tx"/"rx" set a one-way filter; "both"/omitted = no filter.
if params.get("direction") in ("tx", "rx"):
body["direction"] = params["direction"]
return conn.http_call("post", base, json_data=body).json()
marker_name = params.get("marker_name")
@@ -408,11 +411,17 @@ def link_marker_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
if action == "update":
body = {}
for opt in ("bpf", "tag", "direction", "enabled", "color", "highlight_duration"):
for opt in ("bpf", "tag", "enabled", "color", "highlight_duration"):
if params.get(opt) is not None:
body[opt] = params[opt]
# direction tri-state: omitted=preserve, "tx"/"rx"=set, "both"=clear (→ null).
direction = params.get("direction")
if direction == "both":
body["direction"] = None
elif direction in ("tx", "rx"):
body["direction"] = direction
if not body:
return {"error": "At least one update field is required (bpf, tag, enabled, color, highlight_duration)"}
return {"error": "At least one update field is required (bpf, tag, enabled, direction, color, highlight_duration)"}
return conn.http_call("put", url, json_data=body).json()
# action == "delete"
@@ -448,9 +457,12 @@ def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any])
if not bpf:
return {"error": "bpf is required for create action"}
body: dict[str, Any] = {"bpf": bpf}
for opt in ("name", "tag", "direction", "color", "highlight_duration"):
for opt in ("name", "tag", "color", "highlight_duration"):
if params.get(opt) is not None:
body[opt] = params[opt]
# direction: "tx"/"rx" set a one-way filter; "both"/omitted = no filter.
if params.get("direction") in ("tx", "rx"):
body["direction"] = params["direction"]
return conn.http_call("post", base, json_data=body).json()
def_name = params.get("def_name")
@@ -461,11 +473,17 @@ def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any])
if action == "update":
body = {}
for opt in ("bpf", "tag", "direction", "color", "highlight_duration"):
for opt in ("bpf", "tag", "color", "highlight_duration"):
if params.get(opt) is not None:
body[opt] = params[opt]
# direction tri-state: omitted=preserve, "tx"/"rx"=set, "both"=clear (→ null).
direction = params.get("direction")
if direction == "both":
body["direction"] = None
elif direction in ("tx", "rx"):
body["direction"] = direction
if not body:
return {"error": "At least one update field is required (bpf, tag, color, highlight_duration)"}
return {"error": "At least one update field is required (bpf, tag, direction, color, highlight_duration)"}
return conn.http_call("put", url, json_data=body).json()
# action == "delete"