diff --git a/gns3server/api/routes/controller/links.py b/gns3server/api/routes/controller/links.py index 99ff3379c..1bddff2d1 100644 --- a/gns3server/api/routes/controller/links.py +++ b/gns3server/api/routes/controller/links.py @@ -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, diff --git a/gns3server/api/routes/controller/projects.py b/gns3server/api/routes/controller/projects.py index de2e293a1..33ad90902 100644 --- a/gns3server/api/routes/controller/projects.py +++ b/gns3server/api/routes/controller/projects.py @@ -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, ) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index 4cf389c13..3f2425b00 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -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' diff --git a/gns3server/api/routes/mcp/links.py b/gns3server/api/routes/mcp/links.py index 2b854b07f..9ddf2b377 100644 --- a/gns3server/api/routes/mcp/links.py +++ b/gns3server/api/routes/mcp/links.py @@ -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" diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index c75a8c10d..743abc676 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -30,6 +30,13 @@ import logging 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 = [ { "type": "frequency_drop", @@ -346,7 +353,7 @@ class Link: """ 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. diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index 10b94b620..aa1cbd82d 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -37,6 +37,7 @@ from .snapshot import Snapshot from .drawing import Drawing from .topology import project_to_topology, load_topology from .udp_link import UDPLink +from .link import _UNSET from ..config import Config from ..utils.path import check_path_allowed, get_default_project_directory from ..utils.application_id import get_next_application_id @@ -947,7 +948,7 @@ class Project: self.dump() 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. """ @@ -966,8 +967,8 @@ class Project: d["color"] = color if highlight_duration is not None: d["highlight_duration"] = highlight_duration - if direction is not None: - d["direction"] = direction + if direction is not _UNSET: + d["direction"] = direction # None = clear back to both directions # Sync: update every inherited copy across all links. for link in list(self._links.values()): diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index 5e72831a5..e3764e317 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -17,7 +17,7 @@ from .controller_error import ControllerError, ControllerNotFoundError -from .link import Link +from .link import Link, _UNSET from .node_types import BUILTIN_NODE_TYPES 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.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 ``self.update()``; uBridge picks up the new params on the next NIO @@ -470,8 +470,8 @@ class UDPLink(Link): marker_info["color"] = color if highlight_duration is not None: marker_info["highlight_duration"] = highlight_duration - if direction is not None: - marker_info["direction"] = direction + if direction is not _UNSET: + marker_info["direction"] = direction # None = clear back to both directions if self._created: await self.update()