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 2026-08-02 11:37:49 +08:00
parent 71fa778d50
commit 37cb9f0a9c
No known key found for this signature in database
7 changed files with 48 additions and 21 deletions

View File

@ -32,7 +32,7 @@ from uuid import UUID, uuid4
from gns3server.controller import Controller from gns3server.controller import Controller
from gns3server.controller.controller_error import ControllerError from gns3server.controller.controller_error import ControllerError
from gns3server.db.repositories.rbac import RbacRepository 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.http_client import HTTPClient
from gns3server.utils.port_allocator import link_id_to_port from gns3server.utils.port_allocator import link_id_to_port
from gns3server.utils.websocket_to_websocket import websocket_proxy from gns3server.utils.websocket_to_websocket import websocket_proxy
@ -510,7 +510,7 @@ async def update_marker(
name=marker_name, name=marker_name,
bpf=marker_data.bpf if marker_data.bpf else None, bpf=marker_data.bpf if marker_data.bpf else None,
tag=marker_data.tag, 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, color=marker_data.color,
enabled=marker_data.enabled, enabled=marker_data.enabled,
highlight_duration=marker_data.highlight_duration, highlight_duration=marker_data.highlight_duration,

View File

@ -40,6 +40,7 @@ from uuid import UUID
from gns3server import schemas from gns3server import schemas
from gns3server.controller import Controller from gns3server.controller import Controller
from gns3server.controller.project import Project from gns3server.controller.project import Project
from gns3server.controller.link import _UNSET
from gns3server.controller.controller_error import ControllerError, ControllerBadRequestError from gns3server.controller.controller_error import ControllerError, ControllerBadRequestError
from gns3server.controller.import_project import import_project as import_controller_project from gns3server.controller.import_project import import_project as import_controller_project
from gns3server.controller.export_project import export_project as export_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, name=def_name,
bpf=def_data.bpf if def_data.bpf else None, bpf=def_data.bpf if def_data.bpf else None,
tag=def_data.tag, 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, color=def_data.color,
highlight_duration=def_data.highlight_duration, highlight_duration=def_data.highlight_duration,
) )

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, 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, 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, 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, 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, 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, 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. Set action='create' to add a marker, 'update' to modify it, 'delete' to remove.
Create requires: project_id, link_id, action='create', bpf 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 Delete requires: project_id, link_id, action='delete', marker_name
To read current markers, use link_get — the response includes a 'markers' dict. 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, 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, 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, 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]]: ) -> list[dict[str, Any]]:
"""Manage project-level marker definitions — traffic-insight rules that apply to ALL links. """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. On delete, 'global-{name}' is removed from every link.
Create requires: project_id, action='create', bpf 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 Delete requires: project_id, action='delete', def_name
List requires: project_id, action='list' List requires: project_id, action='list'

View File

@ -395,9 +395,12 @@ def link_marker_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
if not bpf: if not bpf:
return {"error": "bpf is required for create action"} return {"error": "bpf is required for create action"}
body: dict[str, Any] = {"bpf": bpf} 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: if params.get(opt) is not None:
body[opt] = params[opt] 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() return conn.http_call("post", base, json_data=body).json()
marker_name = params.get("marker_name") 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": if action == "update":
body = {} 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: if params.get(opt) is not None:
body[opt] = params[opt] 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: 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() return conn.http_call("put", url, json_data=body).json()
# action == "delete" # action == "delete"
@ -448,9 +457,12 @@ def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any])
if not bpf: if not bpf:
return {"error": "bpf is required for create action"} return {"error": "bpf is required for create action"}
body: dict[str, Any] = {"bpf": bpf} 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: if params.get(opt) is not None:
body[opt] = params[opt] 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() return conn.http_call("post", base, json_data=body).json()
def_name = params.get("def_name") 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": if action == "update":
body = {} 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: if params.get(opt) is not None:
body[opt] = params[opt] 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: 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() return conn.http_call("put", url, json_data=body).json()
# action == "delete" # action == "delete"

View File

@ -30,6 +30,13 @@ import logging
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Sentinel for "argument not passed". Distinct from None so marker/definition
# updaters can tell "caller omitted direction" (keep current value) from
# "caller passed direction=None" (clear it back to both directions). See
# UDPLink.update_marker and Project.update_marker_definition.
_UNSET = object()
FILTERS = [ FILTERS = [
{ {
"type": "frequency_drop", "type": "frequency_drop",
@ -346,7 +353,7 @@ class Link:
""" """
raise NotImplementedError raise NotImplementedError
async def update_marker(self, name, bpf=None, tag=None, enabled=None, direction=None): async def update_marker(self, name, bpf=None, tag=None, enabled=None, direction=_UNSET):
""" """
Update an existing marker's BPF, tag, or enabled flag. Update an existing marker's BPF, tag, or enabled flag.

View File

@ -37,6 +37,7 @@ from .snapshot import Snapshot
from .drawing import Drawing from .drawing import Drawing
from .topology import project_to_topology, load_topology from .topology import project_to_topology, load_topology
from .udp_link import UDPLink from .udp_link import UDPLink
from .link import _UNSET
from ..config import Config from ..config import Config
from ..utils.path import check_path_allowed, get_default_project_directory from ..utils.path import check_path_allowed, get_default_project_directory
from ..utils.application_id import get_next_application_id from ..utils.application_id import get_next_application_id
@ -947,7 +948,7 @@ class Project:
self.dump() self.dump()
self.emit_notification("project.updated", self.asdict()) self.emit_notification("project.updated", self.asdict())
async def update_marker_definition(self, name, bpf=None, tag=None, direction=None, color=None, highlight_duration=None): async def update_marker_definition(self, name, bpf=None, tag=None, direction=_UNSET, color=None, highlight_duration=None):
""" """
Update a marker definition and sync every inherited copy on every link. Update a marker definition and sync every inherited copy on every link.
""" """
@ -966,8 +967,8 @@ class Project:
d["color"] = color d["color"] = color
if highlight_duration is not None: if highlight_duration is not None:
d["highlight_duration"] = highlight_duration d["highlight_duration"] = highlight_duration
if direction is not None: if direction is not _UNSET:
d["direction"] = direction d["direction"] = direction # None = clear back to both directions
# Sync: update every inherited copy across all links. # Sync: update every inherited copy across all links.
for link in list(self._links.values()): for link in list(self._links.values()):

View File

@ -17,7 +17,7 @@
from .controller_error import ControllerError, ControllerNotFoundError from .controller_error import ControllerError, ControllerNotFoundError
from .link import Link from .link import Link, _UNSET
from .node_types import BUILTIN_NODE_TYPES from .node_types import BUILTIN_NODE_TYPES
from gns3server.utils.packet_filter_validation import validate_bpf_syntax, FilterValidationError from gns3server.utils.packet_filter_validation import validate_bpf_syntax, FilterValidationError
@ -430,7 +430,7 @@ class UDPLink(Link):
self._project.emit_notification("link.updated", self.asdict()) self._project.emit_notification("link.updated", self.asdict())
self._project.dump() self._project.dump()
async def update_marker(self, name, bpf=None, tag=None, enabled=None, direction=None, color=None, highlight_duration=None, inherited=False): async def update_marker(self, name, bpf=None, tag=None, enabled=None, direction=_UNSET, color=None, highlight_duration=None, inherited=False):
""" """
Update an existing marker's BPF/tag/enabled/color. Any change pushes via Update an existing marker's BPF/tag/enabled/color. Any change pushes via
``self.update()``; uBridge picks up the new params on the next NIO ``self.update()``; uBridge picks up the new params on the next NIO
@ -470,8 +470,8 @@ class UDPLink(Link):
marker_info["color"] = color marker_info["color"] = color
if highlight_duration is not None: if highlight_duration is not None:
marker_info["highlight_duration"] = highlight_duration marker_info["highlight_duration"] = highlight_duration
if direction is not None: if direction is not _UNSET:
marker_info["direction"] = direction marker_info["direction"] = direction # None = clear back to both directions
if self._created: if self._created:
await self.update() await self.update()