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):