From 98bcd5eddd2711540824280ebbfa62c9fec5de79 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 13 Jul 2026 23:32:08 +0800 Subject: [PATCH] feat(marker): add highlight_duration render hint Per-marker UI hint (milliseconds, ge=1) for how long the Web UI keeps a marker highlighted after a match. Mirrors color: stored on the link, persisted in the topology, inherited via project-level definitions, and never sent to uBridge. Omitted = null = frontend uses its own default. The default is intentionally NOT set in the schema: MarkerCreate also backs PUT updates, so a schema default would make every partial update silently reset the value. None means "not provided" (keep existing on update / frontend decides on create). --- gns3server/api/routes/controller/links.py | 2 ++ gns3server/api/routes/controller/projects.py | 2 ++ gns3server/controller/link.py | 1 + gns3server/controller/project.py | 15 +++++++++------ gns3server/controller/udp_link.py | 10 ++++++++-- gns3server/schemas/controller/links.py | 18 ++++++++++++++++++ 6 files changed, 40 insertions(+), 8 deletions(-) diff --git a/gns3server/api/routes/controller/links.py b/gns3server/api/routes/controller/links.py index 12ef9eeff..5a51905e0 100644 --- a/gns3server/api/routes/controller/links.py +++ b/gns3server/api/routes/controller/links.py @@ -464,6 +464,7 @@ async def create_marker( bpf=marker_data.bpf, tag=marker_data.tag, color=marker_data.color, + highlight_duration=marker_data.highlight_duration, ) return link.markers.get(name, {}) @@ -507,6 +508,7 @@ async def update_marker( tag=marker_data.tag, color=marker_data.color, enabled=marker_data.enabled, + highlight_duration=marker_data.highlight_duration, ) return link.markers.get(marker_name, {}) diff --git a/gns3server/api/routes/controller/projects.py b/gns3server/api/routes/controller/projects.py index 6bfb84ba6..103c7d310 100644 --- a/gns3server/api/routes/controller/projects.py +++ b/gns3server/api/routes/controller/projects.py @@ -266,6 +266,7 @@ async def create_marker_definition( bpf=def_data.bpf, tag=def_data.tag, color=def_data.color, + highlight_duration=def_data.highlight_duration, ) return project.marker_definitions.get(name, {}) @@ -290,6 +291,7 @@ async def update_marker_definition( bpf=def_data.bpf if def_data.bpf else None, tag=def_data.tag, color=def_data.color, + highlight_duration=def_data.highlight_duration, ) return project.marker_definitions.get(def_name, {}) diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index 8ac755ba6..008deda4d 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -122,6 +122,7 @@ class Link: bpf=marker_def["bpf"], tag=marker_def.get("tag"), color=marker_def.get("color"), + highlight_duration=marker_def.get("highlight_duration"), inherited_from=def_name, ) diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index c8c9be93f..2d1671f31 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -212,7 +212,7 @@ class Project: self._allocated_node_names = set() self._nodes = {} self._links = {} - self._marker_definitions = {} # name → {bpf, tag, color} + self._marker_definitions = {} # name → {bpf, tag, color, highlight_duration} self._drawings = {} self._snapshots = {} self._computes = [] @@ -926,11 +926,11 @@ class Project: @property def marker_definitions(self): """ - :returns: dict of project-level marker definitions (name → {bpf, tag, color}) + :returns: dict of project-level marker definitions (name → {bpf, tag, color, highlight_duration}) """ return self._marker_definitions - async def create_marker_definition(self, name, bpf, tag=None, color=None): + async def create_marker_definition(self, name, bpf, tag=None, color=None, highlight_duration=None): """ Create a project-level marker definition and fan out to every existing link that has a capable node. Links without a capable node are silently @@ -942,12 +942,12 @@ class Project: f"Marker definition '{name}' already exists in this project" ) - self._marker_definitions[name] = {"bpf": bpf, "tag": tag, "color": color} + self._marker_definitions[name] = {"bpf": bpf, "tag": tag, "color": color, "highlight_duration": highlight_duration} await self._apply_def_to_all_links(name) self.dump() self.emit_notification("project.updated", self.asdict()) - async def update_marker_definition(self, name, bpf=None, tag=None, color=None): + async def update_marker_definition(self, name, bpf=None, tag=None, color=None, highlight_duration=None): """ Update a marker definition and sync every inherited copy on every link. """ @@ -964,13 +964,16 @@ class Project: d["tag"] = tag if color is not None: d["color"] = color + if highlight_duration is not None: + d["highlight_duration"] = highlight_duration # Sync: update every inherited copy across all links. for link in list(self._links.values()): marker_name = f"global-{name}" if marker_name in link.markers and link.markers[marker_name].get("inherited_from") == name: await link.update_marker( - marker_name, bpf=d["bpf"], tag=d.get("tag"), color=d.get("color"), inherited=True + marker_name, bpf=d["bpf"], tag=d.get("tag"), color=d.get("color"), + highlight_duration=d.get("highlight_duration"), inherited=True ) self.dump() self.emit_notification("project.updated", self.asdict()) diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index 58f357960..de797b77f 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -322,7 +322,7 @@ class UDPLink(Link): # explicitly deletes a marker via the REST API, and a marker is torn # down automatically only when its link is deleted. - async def start_marker(self, name, bpf, tag=None, color=None, inherited_from=None): + async def start_marker(self, name, bpf, tag=None, color=None, highlight_duration=None, inherited_from=None): """ Attach a traffic-insight marker to this link. @@ -337,6 +337,8 @@ class UDPLink(Link): :param tag: optional correlation id :param color: optional hex color for the Web UI (e.g. '#ff5722'); stored with the link and persisted in the topology, never sent to uBridge + :param highlight_duration: optional UI-only hint (milliseconds) for how + long a match keeps the marker highlighted; stored, never sent to uBridge :param inherited_from: def name when this marker is a project-level inheritance copy; set automatically, never exposed to REST callers """ @@ -354,6 +356,7 @@ class UDPLink(Link): "tag": tag, "enabled": True, "color": color, + "highlight_duration": highlight_duration, "capture_node_id": marker_side["node"].id, } if inherited_from: @@ -391,7 +394,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, color=None, inherited=False): + async def update_marker(self, name, bpf=None, tag=None, enabled=None, 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 @@ -402,6 +405,7 @@ class UDPLink(Link): :param tag: new tag id (None = keep existing) :param enabled: toggle (None = keep existing) :param color: new hex color (None = keep existing) + :param highlight_duration: new UI highlight duration in ms (None = keep existing) :param inherited: set by project-level sync to bypass the inheritance guard (the project layer is the legitimate editor) """ @@ -428,6 +432,8 @@ class UDPLink(Link): marker_info["enabled"] = enabled if color is not None: marker_info["color"] = color + if highlight_duration is not None: + marker_info["highlight_duration"] = highlight_duration if self._created: await self.update() diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py index 21de66add..82bfc48b9 100644 --- a/gns3server/schemas/controller/links.py +++ b/gns3server/schemas/controller/links.py @@ -162,6 +162,15 @@ class MarkerCreate(BaseModel): None, description="User-chosen hex color for this marker in the Web UI, e.g. '#ff5722'", ) + highlight_duration: Optional[int] = Field( + None, + ge=1, + description=( + "How long (milliseconds) the Web UI keeps this marker highlighted " + "after a match. Omitted = use the UI default. Pure render hint — " + "stored on the link, never sent to uBridge." + ), + ) enabled: Optional[bool] = Field( None, description="Whether the marker is active. Defaults to true on creation.", @@ -196,6 +205,15 @@ class MarkerDefinitionCreate(BaseModel): None, description="User-chosen hex color for the marker in the Web UI, e.g. '#ff5722'", ) + highlight_duration: Optional[int] = Field( + None, + ge=1, + description=( + "How long (milliseconds) the Web UI keeps this marker highlighted " + "after a match. Omitted = use the UI default. Pure render hint — " + "stored with the definition, never sent to uBridge." + ), + ) @field_validator("name") @classmethod