From a3616e6d33cf9ae2598b1b2a1759e5f178f71191 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 22 Feb 2026 22:52:28 +0800 Subject: [PATCH] feat(api): add tag filtering to nodes and templates endpoints - Add optional `tags` query parameter to `/nodes` and `/templates` endpoints - Support filtering by tags in format "key:value" with multiple tags ANDed together - Example: `?tags=vendor:cisco&tags=model:7200` filters nodes/templates with both tags - Maintain backward compatibility for existing API usage without tags parameter --- gns3server/api/routes/controller/nodes.py | 43 ++++++++++++++++--- gns3server/api/routes/controller/templates.py | 32 +++++++++++++- gns3server/controller/node.py | 13 ++++++ gns3server/db/models/templates.py | 6 +-- gns3server/schemas/controller/nodes.py | 6 ++- .../schemas/controller/templates/__init__.py | 9 ++-- 6 files changed, 93 insertions(+), 16 deletions(-) diff --git a/gns3server/api/routes/controller/nodes.py b/gns3server/api/routes/controller/nodes.py index f9cac15b1..c7d8668a5 100644 --- a/gns3server/api/routes/controller/nodes.py +++ b/gns3server/api/routes/controller/nodes.py @@ -22,10 +22,10 @@ import aiohttp import asyncio import ipaddress -from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect, Request, Response, status +from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect, Request, Response, status, Query from fastapi.encoders import jsonable_encoder from fastapi.routing import APIRoute -from typing import List, Callable +from typing import List, Callable, Optional from uuid import UUID from gns3server.controller import Controller @@ -135,17 +135,50 @@ async def create_node(node_data: schemas.NodeCreate, project: Project = Depends( response_model_exclude_unset=True, dependencies=[Depends(has_privilege("Node.Audit"))] ) -def get_nodes(project: Project = Depends(dep_project)) -> List[schemas.Node]: +def get_nodes( + project: Project = Depends(dep_project), + tags: Optional[List[str]] = Query(None, description="Filter by tags (e.g., tags=vendor:cisco&tags=model:7200)") +) -> List[schemas.Node]: """ Return all nodes belonging to a given project. Required privilege: Node.Audit + + Query Parameters: + - tags: Filter by tags in format "key:value". Multiple tags are ANDed together. + Example: ?tags=vendor:cisco&tags=model:7200 """ if project.status == "closed": # allow to retrieve nodes from a closed project - return project.nodes.values() - return [v.asdict() for v in project.nodes.values()] + nodes = list(project.nodes.values()) + else: + nodes = [v.asdict() for v in project.nodes.values()] + + # Filter by tags if provided + if tags: + filtered_nodes = [] + for node in nodes: + node_dict = node.asdict() if hasattr(node, 'asdict') else node + node_tags = node_dict.get("tags") or {} + # Check if all tag filters match + match = True + for tag_filter in tags: + if ":" in tag_filter: + key, value = tag_filter.split(":", 1) + if node_tags.get(key) != value: + match = False + break + else: + # Check if key exists + if tag_filter not in node_tags: + match = False + break + if match: + filtered_nodes.append(node) + nodes = filtered_nodes + + return nodes @router.post("/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Node.PowerMgmt"))]) diff --git a/gns3server/api/routes/controller/templates.py b/gns3server/api/routes/controller/templates.py index 801ea2975..4594fd635 100644 --- a/gns3server/api/routes/controller/templates.py +++ b/gns3server/api/routes/controller/templates.py @@ -26,7 +26,7 @@ import logging log = logging.getLogger(__name__) -from fastapi import APIRouter, Request, HTTPException, Depends, Response, status +from fastapi import APIRouter, Request, HTTPException, Depends, Response, status, Query from typing import List, Optional from uuid import UUID @@ -169,15 +169,43 @@ async def delete_template( async def get_templates( templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)), current_user: schemas.User = Depends(get_current_active_user), - rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) + rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), + tags: Optional[List[str]] = Query(None, description="Filter by tags (e.g., tags=vendor:cisco&tags=model:7200)") ) -> List[schemas.Template]: """ Return all templates. Required privilege: Template.Audit + + Query Parameters: + - tags: Filter by tags in format "key:value". Multiple tags are ANDed together. + Example: ?tags=vendor:cisco&tags=model:7200 """ templates = await TemplatesService(templates_repo).get_templates() + + # Filter by tags if provided + if tags: + filtered_templates = [] + for template in templates: + template_tags = template.get("tags") or {} + # Check if all tag filters match + match = True + for tag_filter in tags: + if ":" in tag_filter: + key, value = tag_filter.split(":", 1) + if template_tags.get(key) != value: + match = False + break + else: + # Check if key exists + if tag_filter not in template_tags: + match = False + break + if match: + filtered_templates.append(template) + templates = filtered_templates + if current_user.is_superadmin: return templates else: diff --git a/gns3server/controller/node.py b/gns3server/controller/node.py index b3d77aa54..3fa2fad50 100644 --- a/gns3server/controller/node.py +++ b/gns3server/controller/node.py @@ -97,6 +97,7 @@ class Node: self._y = 0 self._z = 1 # default z value is 1 self._locked = False + self._tags = {} self._ports = None self._symbol = None self._custom_adapters = [] @@ -218,6 +219,17 @@ class Node: def properties(self, val): self._properties = val + @property + def tags(self): + return self._tags + + @tags.setter + def tags(self, val): + if isinstance(val, dict): + self._tags = val + else: + self._tags = {} + def _base_config_file_content(self, path): if not os.path.isabs(path): path = os.path.join(self.project.controller.configs_path(), path) @@ -823,6 +835,7 @@ class Node: "port_segment_size": self._port_segment_size, "first_port_name": self._first_port_name, "custom_adapters": self._custom_adapters, + "tags": self._tags, } if topology_dump: diff --git a/gns3server/db/models/templates.py b/gns3server/db/models/templates.py index a7ababe13..699ef1c0e 100644 --- a/gns3server/db/models/templates.py +++ b/gns3server/db/models/templates.py @@ -16,7 +16,7 @@ # along with this program. If not, see . -from sqlalchemy import Boolean, Column, String, Integer, Float, ForeignKey, PickleType +from sqlalchemy import Boolean, Column, String, Integer, Float, ForeignKey, PickleType, JSON from sqlalchemy.orm import relationship from .base import BaseTable, generate_uuid, GUID @@ -36,9 +36,7 @@ class Template(BaseTable): builtin = Column(Boolean, default=False) usage = Column(String) template_type = Column(String) - vendor = Column(String) - model = Column(String) - netmiko_device_type = Column(String) + tags = Column(JSON, default='{}') compute_id = Column(String) images = relationship("Image", secondary=image_template_map, back_populates="templates") diff --git a/gns3server/schemas/controller/nodes.py b/gns3server/schemas/controller/nodes.py index d732c80a9..52d76595b 100644 --- a/gns3server/schemas/controller/nodes.py +++ b/gns3server/schemas/controller/nodes.py @@ -15,7 +15,7 @@ # along with this program. If not, see . from pydantic import BaseModel, Field, model_validator -from typing import List, Optional, Union, Any +from typing import List, Optional, Union, Any, Dict from enum import Enum from uuid import UUID, uuid4 @@ -133,6 +133,10 @@ class NodeBase(BaseModel): port_segment_size: Optional[int] = Field(None, description="Size of the port segment") first_port_name: Optional[str] = Field(None, description="Name of the first port") custom_adapters: Optional[List[CustomAdapter]] = None + tags: Optional[Dict[str, str]] = Field( + default_factory=dict, + description="User-defined metadata tags inherited from template or custom" + ) class NodeCreate(NodeBase): diff --git a/gns3server/schemas/controller/templates/__init__.py b/gns3server/schemas/controller/templates/__init__.py index 04b4b9bf8..ef1e2bae7 100644 --- a/gns3server/schemas/controller/templates/__init__.py +++ b/gns3server/schemas/controller/templates/__init__.py @@ -15,7 +15,7 @@ # along with this program. If not, see . from pydantic import ConfigDict, BaseModel, Field -from typing import Optional, Union +from typing import Optional, Union, Dict from enum import Enum from uuid import UUID @@ -48,9 +48,10 @@ class TemplateBase(BaseModel): template_type: Optional[NodeType] = None compute_id: Optional[str] = None usage: Optional[str] = "" - vendor: Optional[str] = Field(None, description="Device vendor (e.g., Cisco, Juniper, Huawei)") - model: Optional[str] = Field(None, description="Device model (e.g., ISR4451-X, MX204, NE40E)") - netmiko_device_type: Optional[str] = Field(None, description="Netmiko device type for automation (e.g., cisco_ios, juniper_junos, huawei)") + tags: Optional[Dict[str, str]] = Field( + default_factory=dict, + description="User-defined metadata tags as key-value pairs (e.g., {'vendor': 'cisco', 'model': '7200', 'netmiko_device_type': 'cisco_ios'})" + ) class TemplateCreate(TemplateBase):