diff --git a/gns3server/api/routes/controller/acl.py b/gns3server/api/routes/controller/acl.py
index a454a1d61..49f814966 100644
--- a/gns3server/api/routes/controller/acl.py
+++ b/gns3server/api/routes/controller/acl.py
@@ -80,7 +80,11 @@ def _iter_route_paths(routes: Sequence[BaseRoute], prefix: str = "", include_mou
yield from _iter_route_paths(mounted_routes, _join_paths(prefix, route.path), include_mounted_routes)
-@router.get("/endpoints", dependencies=[Depends(has_privilege("ACE.Audit"))])
+@router.get(
+ "/endpoints",
+ response_model=List[schemas.ACEEndpoint],
+ dependencies=[Depends(has_privilege("ACE.Audit"))],
+)
async def endpoints(
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
diff --git a/gns3server/api/routes/controller/api_keys.py b/gns3server/api/routes/controller/api_keys.py
index 42f1d66c2..c3297e612 100644
--- a/gns3server/api/routes/controller/api_keys.py
+++ b/gns3server/api/routes/controller/api_keys.py
@@ -50,12 +50,12 @@ def _generate_api_key(api_key_id: UUID | None = None) -> tuple[str, str, str, UU
return raw_key, key_hash, key_prefix, api_key_id
-@router.post("", status_code=status.HTTP_201_CREATED)
+@router.post("", response_model=schemas.ApiKeyCreated, status_code=status.HTTP_201_CREATED)
async def create_api_key(
api_key_data: schemas.ApiKeyCreate,
current_user: schemas.User = Depends(get_current_active_user),
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
-) -> dict:
+) -> schemas.ApiKeyCreated:
"""Create a new API key. The full key is returned only once."""
raw_key, key_hash, key_prefix, new_key_id = _generate_api_key()
@@ -66,42 +66,42 @@ async def create_api_key(
key_hash=key_hash,
key_prefix=key_prefix,
)
- return {
- "api_key_id": str(db_key.api_key_id),
- "api_key": raw_key,
- "name": db_key.name,
- "key_prefix": db_key.key_prefix,
- "created_at": db_key.created_at.isoformat() if db_key.created_at else None,
- }
+ return schemas.ApiKeyCreated(
+ api_key_id=db_key.api_key_id,
+ api_key=raw_key,
+ name=db_key.name,
+ key_prefix=db_key.key_prefix,
+ created_at=db_key.created_at.isoformat() if db_key.created_at else None,
+ )
-@router.get("")
+@router.get("", response_model=list[schemas.ApiKey])
async def list_api_keys(
current_user: schemas.User = Depends(get_current_active_user),
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
-) -> list[dict]:
+) -> list[schemas.ApiKey]:
"""List all API keys for the current user."""
keys = await api_keys_repo.get_api_keys_by_user(current_user.user_id)
return [
- {
- "api_key_id": str(k.api_key_id),
- "name": k.name,
- "key_prefix": k.key_prefix,
- "created_at": k.created_at.isoformat() if k.created_at else None,
- "last_used_at": k.last_used_at.isoformat() if k.last_used_at else None,
- "revoked": k.revoked,
- }
+ schemas.ApiKey(
+ api_key_id=k.api_key_id,
+ name=k.name,
+ key_prefix=k.key_prefix,
+ created_at=k.created_at.isoformat() if k.created_at else None,
+ last_used_at=k.last_used_at.isoformat() if k.last_used_at else None,
+ revoked=k.revoked,
+ )
for k in keys
]
-@router.post("/{api_key_id}/revoke", status_code=status.HTTP_200_OK)
+@router.post("/{api_key_id}/revoke", response_model=schemas.ApiKeyMessage, status_code=status.HTTP_200_OK)
async def revoke_api_key(
api_key_id: UUID,
current_user: schemas.User = Depends(get_current_active_user),
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
-) -> dict:
+) -> schemas.ApiKeyMessage:
"""Revoke an API key. It will immediately stop working, but can be restored."""
key = await api_keys_repo.get_api_key(api_key_id)
@@ -111,15 +111,15 @@ async def revoke_api_key(
raise HTTPException(status_code=403, detail="Cannot modify another user's API key")
await api_keys_repo.revoke_api_key(api_key_id)
- return {"message": f"API key '{key.name}' revoked"}
+ return schemas.ApiKeyMessage(message=f"API key '{key.name}' revoked")
-@router.post("/{api_key_id}/restore", status_code=status.HTTP_200_OK)
+@router.post("/{api_key_id}/restore", response_model=schemas.ApiKeyMessage, status_code=status.HTTP_200_OK)
async def restore_api_key(
api_key_id: UUID,
current_user: schemas.User = Depends(get_current_active_user),
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
-) -> dict:
+) -> schemas.ApiKeyMessage:
"""Restore a previously revoked API key."""
key = await api_keys_repo.get_api_key(api_key_id)
@@ -129,7 +129,7 @@ async def restore_api_key(
raise HTTPException(status_code=403, detail="Cannot modify another user's API key")
await api_keys_repo.restore_api_key(api_key_id)
- return {"message": f"API key '{key.name}' restored"}
+ return schemas.ApiKeyMessage(message=f"API key '{key.name}' restored")
@router.delete("/{api_key_id}", status_code=status.HTTP_204_NO_CONTENT)
diff --git a/gns3server/api/routes/controller/appliances.py b/gns3server/api/routes/controller/appliances.py
index 1d3f0e413..c20554a9f 100644
--- a/gns3server/api/routes/controller/appliances.py
+++ b/gns3server/api/routes/controller/appliances.py
@@ -82,6 +82,8 @@ def get_appliance(appliance_id: UUID) -> schemas.Appliance:
@router.post(
"/{appliance_id}/version",
+ response_model=schemas.Appliance,
+ response_model_exclude_unset=True,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(has_privilege("Appliance.Allocate"))],
)
diff --git a/gns3server/api/routes/controller/computes.py b/gns3server/api/routes/controller/computes.py
index a33ebc9f6..796a72063 100644
--- a/gns3server/api/routes/controller/computes.py
+++ b/gns3server/api/routes/controller/computes.py
@@ -212,7 +212,7 @@ async def vmware_vms(compute_id: Union[str, UUID]) -> List[schemas.ComputeVMware
return result
-@router.post("/{compute_id}/dynamips/auto_idlepc")
+@router.post("/{compute_id}/dynamips/auto_idlepc", response_model=schemas.IdlePC)
async def dynamips_autoidlepc(compute_id: Union[str, UUID], auto_idle_pc: schemas.AutoIdlePC):
"""
Find a suitable Idle-PC value for a given IOS image. This may take a few minutes.
diff --git a/gns3server/api/routes/controller/controller.py b/gns3server/api/routes/controller/controller.py
index cef3ee77e..a6a499372 100644
--- a/gns3server/api/routes/controller/controller.py
+++ b/gns3server/api/routes/controller/controller.py
@@ -162,7 +162,12 @@ async def update_iou_license(iou_license: schemas.IOULicense) -> schemas.IOULice
return current_iou_license
-@router.get("/statistics", dependencies=[Depends(get_current_active_user)])
+@router.get(
+ "/statistics",
+ response_model=schemas.ServerStatistics,
+ response_model_exclude_unset=True,
+ dependencies=[Depends(get_current_active_user)],
+)
async def statistics() -> dict:
"""
Return server statistics including compute resources, projects, and nodes.
diff --git a/gns3server/api/routes/controller/gns3vm.py b/gns3server/api/routes/controller/gns3vm.py
index e0ae90eba..21d2cdf49 100644
--- a/gns3server/api/routes/controller/gns3vm.py
+++ b/gns3server/api/routes/controller/gns3vm.py
@@ -30,7 +30,7 @@ from gns3server.controller import Controller
router = APIRouter()
-@router.get("/engines")
+@router.get("/engines", response_model=List[schemas.GNS3VMEngineInfo])
async def get_engines() -> List[dict]:
"""
Return the list of supported engines for the GNS3VM.
@@ -40,7 +40,7 @@ async def get_engines() -> List[dict]:
return gns3_vm.engine_list()
-@router.get("/engines/{engine}/vms")
+@router.get("/engines/{engine}/vms", response_model=List[schemas.GNS3VMInfo])
async def get_vms(engine: str) -> List[dict]:
"""
Return all the available VMs for a specific virtualization engine.
diff --git a/gns3server/api/routes/controller/images.py b/gns3server/api/routes/controller/images.py
index c82f2464a..4593a4f97 100644
--- a/gns3server/api/routes/controller/images.py
+++ b/gns3server/api/routes/controller/images.py
@@ -329,7 +329,12 @@ async def prune_images(
)
-@router.post("/install", status_code=status.HTTP_200_OK, dependencies=[Depends(has_privilege("Image.Allocate"))])
+@router.post(
+ "/install",
+ response_model=schemas.ImageInstallResult,
+ status_code=status.HTTP_200_OK,
+ dependencies=[Depends(has_privilege("Image.Allocate"))],
+)
async def install_images(
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
diff --git a/gns3server/api/routes/controller/links.py b/gns3server/api/routes/controller/links.py
index 83c0899fd..d17119624 100644
--- a/gns3server/api/routes/controller/links.py
+++ b/gns3server/api/routes/controller/links.py
@@ -20,7 +20,7 @@ API routes for links.
import logging
import os
-from typing import Any, List, Optional
+from typing import Any, Dict, List, Optional
from uuid import UUID, uuid4
import aiohttp
@@ -205,11 +205,11 @@ async def create_links(project_id: UUID, links_create: List[schemas.LinkCreate])
@router.get(
"/{link_id}/available_filters",
- response_model=List[schemas.LinkFilterDefinition],
+ response_model=List[schemas.LinkFilterInfo],
response_model_exclude_unset=True,
dependencies=[Depends(has_privilege("Link.Audit"))],
)
-async def get_filters(link: Link = Depends(dep_link)) -> List[schemas.LinkFilterDefinition]:
+async def get_filters(link: Link = Depends(dep_link)) -> List[dict]:
"""
Return all filters available for a given link.
@@ -353,6 +353,7 @@ async def stop_capture(link: Link = Depends(dep_link)) -> None:
@router.post(
"/{link_id}/capture/wireshark/restart",
+ response_model=schemas.LinkWiresharkRestart,
status_code=status.HTTP_200_OK,
dependencies=[Depends(has_privilege("Link.Capture"))],
)
@@ -510,7 +511,12 @@ async def web_wireshark_websocket(
pass
-@router.get("/{link_id}/markers", dependencies=[Depends(has_privilege("Link.Audit"))])
+@router.get(
+ "/{link_id}/markers",
+ response_model=Dict[str, schemas.Marker],
+ response_model_exclude_unset=True,
+ dependencies=[Depends(has_privilege("Link.Audit"))],
+)
async def get_markers(link: Link = Depends(dep_link)) -> dict:
"""
Return all traffic-insight markers configured on this link.
@@ -522,7 +528,11 @@ async def get_markers(link: Link = Depends(dep_link)) -> dict:
@router.post(
- "/{link_id}/markers", status_code=status.HTTP_201_CREATED, dependencies=[Depends(has_privilege("Link.Modify"))]
+ "/{link_id}/markers",
+ response_model=schemas.Marker,
+ response_model_exclude_unset=True,
+ status_code=status.HTTP_201_CREATED,
+ dependencies=[Depends(has_privilege("Link.Modify"))],
)
async def create_marker(marker_data: schemas.MarkerCreate, link: Link = Depends(dep_link)) -> dict:
"""
@@ -567,7 +577,12 @@ async def delete_marker(marker_name: str, link: Link = Depends(dep_link)) -> Non
await link.stop_marker(marker_name)
-@router.put("/{link_id}/markers/{marker_name}", dependencies=[Depends(has_privilege("Link.Modify"))])
+@router.put(
+ "/{link_id}/markers/{marker_name}",
+ response_model=schemas.Marker,
+ response_model_exclude_unset=True,
+ dependencies=[Depends(has_privilege("Link.Modify"))],
+)
async def update_marker(marker_name: str, marker_data: schemas.MarkerUpdate, link: Link = Depends(dep_link)) -> dict:
"""
Update a traffic-insight marker (change BPF, tag, or enabled).
diff --git a/gns3server/api/routes/controller/nodes.py b/gns3server/api/routes/controller/nodes.py
index 04dbd332a..988524835 100644
--- a/gns3server/api/routes/controller/nodes.py
+++ b/gns3server/api/routes/controller/nodes.py
@@ -573,7 +573,11 @@ async def get_node_links(node: Node = Depends(dep_node)) -> List[schemas.Link]:
return links
-@router.get("/{node_id}/dynamips/auto_idlepc", dependencies=[Depends(has_privilege("Node.Audit"))])
+@router.get(
+ "/{node_id}/dynamips/auto_idlepc",
+ response_model=schemas.IdlePC,
+ dependencies=[Depends(has_privilege("Node.Audit"))],
+)
async def auto_idlepc(node: Node = Depends(dep_node)) -> dict:
"""
Compute an Idle-PC value for a Dynamips node
diff --git a/gns3server/api/routes/controller/projects.py b/gns3server/api/routes/controller/projects.py
index aceb39811..3736aba39 100644
--- a/gns3server/api/routes/controller/projects.py
+++ b/gns3server/api/routes/controller/projects.py
@@ -31,7 +31,7 @@ import gns3server.utils.zipfile_zstd as zipfile
log = logging.getLogger()
-from typing import Any, List, Optional
+from typing import Any, Dict, List, Optional
from uuid import UUID
from fastapi import (
@@ -237,7 +237,9 @@ async def delete_project(
await rbac_repo.delete_all_ace_starting_with_path(f"/projects/{project.id}")
-@router.get("/{project_id}/stats", dependencies=[Depends(has_privilege("Project.Audit"))])
+@router.get(
+ "/{project_id}/stats", response_model=schemas.ProjectStats, dependencies=[Depends(has_privilege("Project.Audit"))]
+)
def get_project_stats(project: Project = Depends(dep_project)) -> dict:
"""
Return a project statistics.
@@ -248,7 +250,12 @@ def get_project_stats(project: Project = Depends(dep_project)) -> dict:
return project.stats()
-@router.get("/{project_id}/markers", dependencies=[Depends(has_privilege("Project.Audit"))])
+@router.get(
+ "/{project_id}/markers",
+ response_model=Dict[str, schemas.ProjectMarker],
+ response_model_exclude_unset=True,
+ dependencies=[Depends(has_privilege("Project.Audit"))],
+)
def get_project_markers(project: Project = Depends(dep_project)) -> dict:
"""
Return all traffic-insight markers across every link in the project.
@@ -279,6 +286,7 @@ async def _replay_response(awaitable):
@router.get(
"/{project_id}/markers/tags/{tag}/replay/range",
+ response_model=schemas.ReplayTimeline,
dependencies=[Depends(has_privilege("Project.Audit"))],
)
async def replay_tag_range(
@@ -319,6 +327,7 @@ async def replay_tag_range(
@router.get(
"/{project_id}/markers/tags/{tag}/replay/frames",
+ response_model=schemas.ReplayFrames,
dependencies=[Depends(has_privilege("Project.Audit"))],
)
async def replay_tag_frames(
@@ -359,6 +368,8 @@ async def replay_tag_frames(
@router.get(
"/{project_id}/markers/tags/{tag}/replay/frame/detail",
+ response_model=schemas.ReplayFrameDetail,
+ response_model_exclude_unset=True,
dependencies=[Depends(has_privilege("Project.Audit"))],
)
async def replay_tag_frame_detail(
@@ -397,7 +408,12 @@ async def replay_tag_frame_detail(
# ---------------------------------------------------------------------------
-@router.get("/{project_id}/marker-definitions", dependencies=[Depends(has_privilege("Project.Audit"))])
+@router.get(
+ "/{project_id}/marker-definitions",
+ response_model=Dict[str, schemas.MarkerDefinitionWithLinks],
+ response_model_exclude_unset=True,
+ dependencies=[Depends(has_privilege("Project.Audit"))],
+)
def get_marker_definitions(project: Project = Depends(dep_project)) -> dict:
"""
Return all project-level marker definitions with their bound link IDs.
@@ -419,6 +435,8 @@ def get_marker_definitions(project: Project = Depends(dep_project)) -> dict:
@router.post(
"/{project_id}/marker-definitions",
+ response_model=schemas.MarkerDefinition,
+ response_model_exclude_unset=True,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(has_privilege("Project.Modify"))],
)
@@ -446,7 +464,12 @@ async def create_marker_definition(
return project.marker_definitions.get(name, {})
-@router.put("/{project_id}/marker-definitions/{def_name}", dependencies=[Depends(has_privilege("Project.Modify"))])
+@router.put(
+ "/{project_id}/marker-definitions/{def_name}",
+ response_model=schemas.MarkerDefinition,
+ response_model_exclude_unset=True,
+ dependencies=[Depends(has_privilege("Project.Modify"))],
+)
async def update_marker_definition(
def_name: str, def_data: schemas.MarkerDefinitionCreate, project: Project = Depends(dep_project)
) -> dict:
diff --git a/gns3server/api/routes/controller/symbols.py b/gns3server/api/routes/controller/symbols.py
index a9c80741a..afa004398 100644
--- a/gns3server/api/routes/controller/symbols.py
+++ b/gns3server/api/routes/controller/symbols.py
@@ -21,7 +21,7 @@ API routes for symbols.
import logging
import os
-from typing import List
+from typing import Dict, List
from fastapi import APIRouter, Depends, Request, Response, status
from fastapi.responses import FileResponse
@@ -40,7 +40,7 @@ log = logging.getLogger(__name__)
router = APIRouter()
-@router.get("", dependencies=[Depends(has_privilege("Symbol.Audit"))])
+@router.get("", response_model=List[schemas.SymbolInfo], dependencies=[Depends(has_privilege("Symbol.Audit"))])
def get_symbols() -> List[dict]:
"""
Return all symbols.
@@ -83,6 +83,7 @@ async def get_symbol(symbol_id: str, request: Request) -> Response:
@router.get(
"/{symbol_id:path}/dimensions",
+ response_model=schemas.SymbolDimensions,
responses={404: {"model": schemas.ErrorMessage, "description": "Could not find symbol"}},
dependencies=[Depends(has_privilege("Symbol.Audit"))],
)
@@ -102,7 +103,11 @@ async def get_symbol_dimensions(symbol_id: str) -> dict:
raise ControllerNotFoundError(f"Could not get symbol file: {e}")
-@router.get("/default_symbols", dependencies=[Depends(has_privilege("Symbol.Audit"))])
+@router.get(
+ "/default_symbols",
+ response_model=Dict[str, Dict[str, str]],
+ dependencies=[Depends(has_privilege("Symbol.Audit"))],
+)
def get_default_symbols() -> dict:
"""
Return all default symbols.
diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py
index 7419f911c..74ddfe4af 100644
--- a/gns3server/schemas/__init__.py
+++ b/gns3server/schemas/__init__.py
@@ -26,10 +26,20 @@ from .controller.computes import (
ComputeUpdate,
ComputeVirtualBoxVM,
ComputeVMwareVM,
+ IdlePC,
)
from .controller.drawings import Drawing
-from .controller.gns3vm import GNS3VM
-from .controller.images import Image, ImageSyncJob, ImageSyncRequest, ImageType, ImageUpload
+from .controller.gns3vm import GNS3VM, GNS3VMEngineInfo, GNS3VMInfo
+from .controller.images import (
+ Image,
+ ImageInstallCreated,
+ ImageInstallResult,
+ ImageInstallSkipped,
+ ImageSyncJob,
+ ImageSyncRequest,
+ ImageType,
+ ImageUpload,
+)
# Controller schemas
from .controller.links import (
@@ -38,15 +48,20 @@ from .controller.links import (
LinkBatchResult,
LinkCapture,
LinkCreate,
- LinkFilterDefinition,
+ LinkFilterInfo,
LinkFilterParameter,
LinkFilters,
LinkFilterType,
LinkIfaceInfo,
LinkUpdate,
+ LinkWiresharkRestart,
+ Marker,
MarkerCreate,
+ MarkerDefinition,
MarkerDefinitionCreate,
+ MarkerDefinitionWithLinks,
MarkerUpdate,
+ ProjectMarker,
UDPPortInfo,
)
from .controller.nodes import Node, NodeBatchResult, NodeCapture, NodeCreate, NodeDuplicate, NodeUpdate
@@ -57,6 +72,7 @@ from .controller.projects import (
ProjectCreate,
ProjectDuplicate,
ProjectFile,
+ ProjectStats,
ProjectUpdate,
)
from .controller.templates import Template, TemplateCreate, TemplateUpdate, TemplateUsage
@@ -124,12 +140,32 @@ from .compute.vmware_nodes import VMware, VMwareCreate, VMwareUpdate
from .compute.vpcs_nodes import VPCS, VPCSCreate, VPCSUpdate
from .controller.capabilities import Capabilities
from .controller.iou_license import IOULicense
+from .controller.marker_replay import (
+ ReplayFrame,
+ ReplayFrameDetail,
+ ReplayFrames,
+ ReplayFrameSource,
+ ReplaySource,
+ ReplayTimeline,
+ ReplayTreeNode,
+)
from .controller.netmiko import NetmikoDeviceType, NetmikoDeviceTypeList
from .controller.notifications import Notification, NotificationAction
from .controller.pools import Resource, ResourceCreate, ResourcePool, ResourcePoolCreate, ResourcePoolUpdate
-from .controller.rbac import ACE, ACECreate, ACEUpdate, Privilege, Role, RoleCreate, RoleUpdate
+from .controller.rbac import ACE, ACECreate, ACEEndpoint, ACEUpdate, Privilege, Role, RoleCreate, RoleUpdate
from .controller.settings import SettingsResponse, SettingsUpdate, SettingsUpdateResponse
from .controller.snapshots import Snapshot, SnapshotCreate
+from .controller.statistics import (
+ ComputeStatistics,
+ ComputeStatisticsEntry,
+ LinkStatistics,
+ NodeStatistics,
+ ProjectCountStatistics,
+ ServerStatistics,
+ WebWiresharkContainer,
+ WebWiresharkStatistics,
+)
+from .controller.symbols import SymbolDimensions, SymbolInfo
from .controller.templates.cloud_templates import CloudTemplate, CloudTemplateUpdate
from .controller.templates.docker_templates import DockerTemplate, DockerTemplateUpdate
from .controller.templates.dynamips_templates import (
@@ -158,7 +194,7 @@ from .controller.templates.vmware_templates import VMwareTemplate, VMwareTemplat
# Controller template schemas
from .controller.templates.vpcs_templates import VPCSTemplate, VPCSTemplateUpdate
-from .controller.tokens import ApiKeyCreate, RefreshTokenRequest, Token
+from .controller.tokens import ApiKey, ApiKeyCreate, ApiKeyCreated, ApiKeyMessage, RefreshTokenRequest, Token
# Schemas for both controller and compute
from .qemu_disk_image import QemuDiskImageCreate, QemuDiskImageFormat, QemuDiskImageUpdate
@@ -172,12 +208,16 @@ __all__ = [
"UDPNIO",
"VPCS",
"ACECreate",
+ "ACEEndpoint",
"ACEUpdate",
"ATMSwitch",
"ATMSwitchCreate",
"ATMSwitchUpdate",
"AnchorNIO",
+ "ApiKey",
"ApiKeyCreate",
+ "ApiKeyCreated",
+ "ApiKeyMessage",
"Appliance",
"ApplianceVersion",
"ApplianceVersionCreate",
@@ -212,6 +252,8 @@ __all__ = [
"Compute",
"ComputeCreate",
"ComputeDockerImage",
+ "ComputeStatistics",
+ "ComputeStatisticsEntry",
"ComputeUpdate",
"ComputeVMwareVM",
"ComputeVirtualBoxVM",
@@ -243,13 +285,19 @@ __all__ = [
"FrameRelaySwitch",
"FrameRelaySwitchCreate",
"FrameRelaySwitchUpdate",
+ "GNS3VMEngineInfo",
+ "GNS3VMInfo",
"IOUCreate",
"IOULicense",
"IOUStart",
"IOUTemplate",
"IOUTemplateUpdate",
"IOUUpdate",
+ "IdlePC",
"Image",
+ "ImageInstallCreated",
+ "ImageInstallResult",
+ "ImageInstallSkipped",
"ImageSyncJob",
"ImageSyncRequest",
"ImageType",
@@ -265,15 +313,20 @@ __all__ = [
"LinkBatchResult",
"LinkCapture",
"LinkCreate",
- "LinkFilterDefinition",
+ "LinkFilterInfo",
"LinkFilterParameter",
"LinkFilterType",
"LinkFilters",
"LinkIfaceInfo",
+ "LinkStatistics",
"LinkUpdate",
+ "LinkWiresharkRestart",
"LoggedInUserUpdate",
+ "Marker",
"MarkerCreate",
+ "MarkerDefinition",
"MarkerDefinitionCreate",
+ "MarkerDefinitionWithLinks",
"MarkerRebuild",
"MarkerToggle",
"MarkerUpdate",
@@ -287,6 +340,7 @@ __all__ = [
"NodeCreate",
"NodeDuplicate",
"NodeFile",
+ "NodeStatistics",
"NodeUpdate",
"Notification",
"NotificationAction",
@@ -295,9 +349,12 @@ __all__ = [
"Privilege",
"Project",
"ProjectCompression",
+ "ProjectCountStatistics",
"ProjectCreate",
"ProjectDuplicate",
"ProjectFile",
+ "ProjectMarker",
+ "ProjectStats",
"ProjectUpdate",
"Qemu",
"QemuCreate",
@@ -309,6 +366,13 @@ __all__ = [
"QemuUpdate",
"RefreshTokenRequest",
"RenameSession",
+ "ReplayFrame",
+ "ReplayFrameDetail",
+ "ReplayFrameSource",
+ "ReplayFrames",
+ "ReplaySource",
+ "ReplayTimeline",
+ "ReplayTreeNode",
"Resource",
"ResourceCreate",
"ResourcePool",
@@ -318,11 +382,14 @@ __all__ = [
"RoleCreate",
"RoleUpdate",
"ServerConfig",
+ "ServerStatistics",
"SettingsResponse",
"SettingsUpdate",
"SettingsUpdateResponse",
"Snapshot",
"SnapshotCreate",
+ "SymbolDimensions",
+ "SymbolInfo",
"Template",
"TemplateCreate",
"TemplateUpdate",
@@ -350,4 +417,6 @@ __all__ = [
"VirtualBoxTemplate",
"VirtualBoxTemplateUpdate",
"VirtualBoxUpdate",
+ "WebWiresharkContainer",
+ "WebWiresharkStatistics",
]
diff --git a/gns3server/schemas/controller/computes.py b/gns3server/schemas/controller/computes.py
index 5742e4391..229a97660 100644
--- a/gns3server/schemas/controller/computes.py
+++ b/gns3server/schemas/controller/computes.py
@@ -153,3 +153,11 @@ class AutoIdlePC(BaseModel):
model_config = ConfigDict(
json_schema_extra={"example": {"platform": "c7200", "image": "/path/to/c7200_image.bin", "ram": 256}}
)
+
+
+class IdlePC(BaseModel):
+ """
+ Result of an Idle-PC computation.
+ """
+
+ idlepc: Optional[str] = Field(None, description="Idle-PC value, null when none was found")
diff --git a/gns3server/schemas/controller/gns3vm.py b/gns3server/schemas/controller/gns3vm.py
index 71a975d43..2c39a5657 100644
--- a/gns3server/schemas/controller/gns3vm.py
+++ b/gns3server/schemas/controller/gns3vm.py
@@ -55,3 +55,24 @@ class GNS3VM(BaseModel):
vcpus: Optional[int] = Field(None, description="Number of CPUs to allocate for the GNS3 VM")
ram: Optional[int] = Field(None, description="Amount of memory to allocate for the GNS3 VM")
port: Optional[int] = Field(None, gt=0, le=65535)
+
+
+class GNS3VMEngineInfo(BaseModel):
+ """
+ A virtualization engine supported by the GNS3 VM.
+ """
+
+ engine_id: str = Field(..., description="Engine identifier")
+ name: str = Field(..., description="Engine display name")
+ description: str = Field(..., description="Engine description (may contain HTML)")
+ support_when_exit: bool = Field(..., description="The engine supports the 'when exit' action")
+ support_headless: bool = Field(..., description="The engine supports headless mode")
+ support_ram: bool = Field(..., description="The engine supports RAM and vCPUs allocation")
+
+
+class GNS3VMInfo(BaseModel):
+ """
+ A virtual machine available on a virtualization engine.
+ """
+
+ vmname: str = Field(..., description="Virtual machine name")
diff --git a/gns3server/schemas/controller/images.py b/gns3server/schemas/controller/images.py
index 92ba88775..17eb73ad3 100644
--- a/gns3server/schemas/controller/images.py
+++ b/gns3server/schemas/controller/images.py
@@ -16,7 +16,8 @@
from datetime import datetime
from enum import Enum
-from typing import Annotated, Literal, Optional
+from typing import Annotated, List, Literal, Optional
+from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
@@ -100,3 +101,32 @@ class ImageCompatibility(BaseModel):
class ImageCompatibilityCatalog(BaseModel):
image_sizes: list[int]
has_unknown_sizes: bool
+
+
+class ImageInstallCreated(BaseModel):
+ """
+ A template created from an image by the automatic installation.
+ """
+
+ template_id: UUID
+ name: Optional[str] = None
+ version: Optional[str] = None
+ template_type: Optional[str] = None
+
+
+class ImageInstallSkipped(BaseModel):
+ """
+ An image or template that the automatic installation skipped.
+ """
+
+ name: Optional[str] = None
+ reason: str
+
+
+class ImageInstallResult(BaseModel):
+ """
+ Result of the automatic template installation from images.
+ """
+
+ created: List[ImageInstallCreated]
+ skipped: List[ImageInstallSkipped]
diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py
index c18547c5e..e1ac4993c 100644
--- a/gns3server/schemas/controller/links.py
+++ b/gns3server/schemas/controller/links.py
@@ -139,29 +139,6 @@ class LinkFilters(BaseModel):
)
-class LinkFilterParameter(BaseModel):
- """
- Parameter of a packet filter.
- """
-
- name: str
- type: Literal["int", "text", "str"]
- minimum: Optional[int] = None
- maximum: Optional[int] = None
- unit: Optional[str] = None
-
-
-class LinkFilterDefinition(BaseModel):
- """
- Packet filter available on a link.
- """
-
- type: LinkFilterType
- name: str
- description: str
- parameters: List[LinkFilterParameter]
-
-
class LinkBase(BaseModel):
"""
Link data.
@@ -409,3 +386,81 @@ class MarkerDefinitionCreate(BaseModel):
@classmethod
def _both_to_none(cls, v):
return None if v == "both" else v
+
+
+class Marker(BaseModel):
+ """
+ A traffic-insight marker attached to a link.
+ """
+
+ bpf: str = Field(..., description="libpcap BPF expression")
+ tag: Optional[int] = Field(None, description="Correlation id shared by markers replayed together")
+ enabled: bool = Field(True, description="Whether the marker is capturing")
+ color: Optional[str] = Field(None, description="Hex color used by the Web UI")
+ highlight_duration: Optional[int] = Field(None, description="Web UI highlight duration in milliseconds")
+ capture_node_id: Optional[str] = Field(None, description="Node whose uBridge hosts the marker")
+ direction: Optional[str] = Field(None, description="'tx' or 'rx', null for both directions")
+ data_link_type: Optional[str] = Field(None, description="pcap link-layer type of the capture file")
+ inherited_from: Optional[str] = Field(None, description="Project marker definition this marker was copied from")
+
+
+class ProjectMarker(Marker):
+ """
+ A marker in the project-wide aggregation, with the link and node it belongs to.
+ """
+
+ link_id: str
+ node_id: Optional[str] = None
+
+
+class MarkerDefinition(BaseModel):
+ """
+ A project-level marker definition inherited by every link.
+ """
+
+ bpf: str = Field(..., description="libpcap BPF expression")
+ tag: Optional[int] = Field(None, description="Correlation id shared by markers replayed together")
+ direction: Optional[str] = Field(None, description="'tx' or 'rx', null for both directions")
+ color: Optional[str] = Field(None, description="Hex color used by the Web UI")
+ highlight_duration: Optional[int] = Field(None, description="Web UI highlight duration in milliseconds")
+ data_link_type: Optional[str] = Field(None, description="pcap link-layer type of inherited markers")
+ paused: bool = Field(False, description="Whether the inherited markers are paused")
+
+
+class MarkerDefinitionWithLinks(MarkerDefinition):
+ """
+ A project-level marker definition with the links currently carrying a copy of it.
+ """
+
+ link_ids: List[str] = Field(..., description="Links carrying an inherited copy of the definition")
+
+
+class LinkWiresharkRestart(BaseModel):
+ """
+ Result of restarting the Wireshark window of a link capture.
+ """
+
+ status: str
+
+
+class LinkFilterParameter(BaseModel):
+ """
+ A parameter of a packet filter.
+ """
+
+ name: str
+ type: str
+ minimum: Optional[int] = None
+ maximum: Optional[int] = None
+ unit: Optional[str] = None
+
+
+class LinkFilterInfo(BaseModel):
+ """
+ A packet filter that can be applied to a link.
+ """
+
+ type: str = Field(..., description="Filter identifier used as key in the link 'filters' property")
+ name: str
+ description: str
+ parameters: List[LinkFilterParameter]
diff --git a/gns3server/schemas/controller/marker_replay.py b/gns3server/schemas/controller/marker_replay.py
new file mode 100644
index 000000000..a07d31cd3
--- /dev/null
+++ b/gns3server/schemas/controller/marker_replay.py
@@ -0,0 +1,114 @@
+#
+# Copyright (C) 2026 GNS3 Technologies Inc.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+from typing import List, Optional
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class ReplaySource(BaseModel):
+ """
+ A capture source (one marker pcap) of a replay tag.
+ """
+
+ node_id: str
+ link_id: str
+ marker: str
+ data_link_type: str
+ count: int = Field(..., description="Total frames in the capture, unaffected by filters")
+
+
+class ReplayFrame(BaseModel):
+ """
+ One frame of the merged replay timeline.
+ """
+
+ ts: str = Field(..., description="Timestamp string to send back unchanged")
+ len: int = Field(..., description="Captured length in bytes")
+ node_id: str
+ link_id: str
+ marker: str
+ frame_number: int = Field(..., description="1-based frame number in the source pcap")
+ src: Optional[str] = None
+ dst: Optional[str] = None
+ proto: Optional[str] = None
+ info: Optional[str] = None
+ bg: Optional[str] = Field(None, description="Wireshark coloring rule background")
+ fg: Optional[str] = Field(None, description="Wireshark coloring rule foreground")
+
+
+class ReplayTimeline(BaseModel):
+ """
+ Replay timeline of a tag.
+ """
+
+ tag: int
+ start: Optional[str] = Field(None, description="Timestamp of the first matching frame")
+ end: Optional[str] = Field(None, description="Timestamp of the last matching frame")
+ frame_count: int
+ sources: List[ReplaySource]
+ frames: List[ReplayFrame]
+
+
+class ReplayFrames(BaseModel):
+ """
+ Frames of a replay time window.
+ """
+
+ frames: List[ReplayFrame]
+
+
+class ReplayFrameSource(BaseModel):
+ """
+ The source pcap and frame a decoded frame comes from.
+ """
+
+ node_id: str
+ link_id: str
+ marker: str
+ frame_number: int
+
+
+class ReplayTreeNode(BaseModel):
+ """
+ A node of the decoded protocol tree.
+
+ Keys sharkd adds in the future are passed through unchanged.
+ """
+
+ model_config = ConfigDict(extra="allow")
+
+ element: Optional[str] = Field(None, description="Node type")
+ label: Optional[str] = Field(None, description="Display text")
+ name: Optional[str] = Field(None, description="Field name, e.g. 'ip.ttl'")
+ filter_expr: Optional[str] = Field(None, description="Display filter with the value baked in")
+ pos: Optional[int] = Field(None, description="Byte offset for hex highlighting")
+ size: Optional[int] = Field(None, description="Byte length for hex highlighting")
+ expert: Optional[str] = Field(None, description="Expert severity")
+ generated: Optional[bool] = Field(None, description="Generated by Wireshark")
+ children: Optional[List["ReplayTreeNode"]] = Field(None, description="Nested fields")
+
+
+class ReplayFrameDetail(BaseModel):
+ """
+ One decoded frame.
+ """
+
+ ts: str
+ source: ReplayFrameSource
+ field_count: int
+ hex: str = Field(..., description="Raw frame bytes as hex")
+ tree: List[ReplayTreeNode]
diff --git a/gns3server/schemas/controller/projects.py b/gns3server/schemas/controller/projects.py
index 1b84b5586..138fd71ea 100644
--- a/gns3server/schemas/controller/projects.py
+++ b/gns3server/schemas/controller/projects.py
@@ -127,3 +127,15 @@ class ProjectCompression(str, Enum):
bzip2 = "bzip2"
lzma = "lzma"
zstd = "zstd"
+
+
+class ProjectStats(BaseModel):
+ """
+ Project object counts.
+ """
+
+ nodes: int
+ links: int
+ drawings: int
+ snapshots: int
+ markers: int
diff --git a/gns3server/schemas/controller/rbac.py b/gns3server/schemas/controller/rbac.py
index e02a46180..60ff78e96 100644
--- a/gns3server/schemas/controller/rbac.py
+++ b/gns3server/schemas/controller/rbac.py
@@ -109,3 +109,13 @@ class Role(DateTimeModelMixin, RoleBase):
is_builtin: bool
privileges: List[Privilege]
model_config = ConfigDict(from_attributes=True)
+
+
+class ACEEndpoint(BaseModel):
+ """
+ An endpoint that can be used in an ACL entry.
+ """
+
+ endpoint: str = Field(..., description="Endpoint path to use as the ACE path")
+ name: str = Field(..., description="Human readable name of the endpoint")
+ endpoint_type: str = Field(..., description="Kind of resource the endpoint refers to")
diff --git a/gns3server/schemas/controller/statistics.py b/gns3server/schemas/controller/statistics.py
new file mode 100644
index 000000000..14ec8bc6d
--- /dev/null
+++ b/gns3server/schemas/controller/statistics.py
@@ -0,0 +1,131 @@
+#
+# Copyright (C) 2026 GNS3 Technologies Inc.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+from typing import Dict, List, Optional, Union
+
+from pydantic import BaseModel, Field
+
+
+class ComputeStatistics(BaseModel):
+ """
+ Resource usage reported by a compute.
+
+ Fields come from the compute itself, so a compute running another build
+ may omit some of them.
+ """
+
+ memory_total: Optional[int] = None
+ memory_free: Optional[int] = None
+ memory_used: Optional[int] = None
+ swap_total: Optional[int] = None
+ swap_free: Optional[int] = None
+ swap_used: Optional[int] = None
+ cpu_usage_percent: Optional[int] = None
+ cpu_count: Optional[int] = None
+ cpu_count_physical: Optional[int] = None
+ cpu_model: Optional[str] = None
+ memory_usage_percent: Optional[int] = None
+ swap_usage_percent: Optional[int] = None
+ disk_usage_percent: Optional[int] = None
+ disk_total: Optional[int] = None
+ disk_used: Optional[int] = None
+ disk_free: Optional[int] = None
+ load_average: Optional[List[float]] = None
+ load_average_percent: Optional[List[float]] = None
+
+
+class ComputeStatisticsEntry(BaseModel):
+ """
+ Statistics of one compute.
+ """
+
+ compute_id: str
+ compute_name: str
+ statistics: ComputeStatistics
+
+
+class ProjectCountStatistics(BaseModel):
+ """
+ Project counts by status.
+ """
+
+ total: int
+ opened: int
+ closed: int
+
+
+class NodeStatistics(BaseModel):
+ """
+ Node counts across all projects.
+ """
+
+ total: int
+ open_project_nodes: int
+ closed_project_nodes: int
+ by_type: Dict[str, int] = Field(..., description="Node count by node type")
+ by_status: Dict[str, int] = Field(..., description="Node count by status, open projects only")
+
+
+class LinkStatistics(BaseModel):
+ """
+ Link counts across all projects.
+ """
+
+ total: int
+ capturing: int
+
+
+class WebWiresharkContainer(BaseModel):
+ """
+ A Web Wireshark container.
+ """
+
+ project_id: str
+ project_name: str
+ container_id: str
+ status: str
+ running: bool
+ active_sessions: Optional[int] = None
+ memory_limit: Optional[str] = None
+ cpu_limit: Optional[str] = None
+ pids_limit: Optional[Union[int, str]] = Field(None, description="Process limit, or 'unlimited'")
+ memory: Optional[str] = Field(None, description="Current memory usage")
+ cpu: Optional[str] = Field(None, description="Current CPU usage")
+ pids: Optional[int] = Field(None, description="Current process count")
+
+
+class WebWiresharkStatistics(BaseModel):
+ """
+ Web Wireshark container statistics across all opened projects.
+ """
+
+ total_containers: int
+ running_containers: int
+ active_sessions: int
+ containers: List[WebWiresharkContainer]
+
+
+class ServerStatistics(BaseModel):
+ """
+ Server statistics.
+ """
+
+ uptime_seconds: int
+ computes: List[ComputeStatisticsEntry]
+ projects: ProjectCountStatistics
+ nodes: NodeStatistics
+ links: LinkStatistics
+ webwireshark: WebWiresharkStatistics
diff --git a/gns3server/schemas/controller/symbols.py b/gns3server/schemas/controller/symbols.py
new file mode 100644
index 000000000..75f9e9f31
--- /dev/null
+++ b/gns3server/schemas/controller/symbols.py
@@ -0,0 +1,37 @@
+#
+# Copyright (C) 2026 GNS3 Technologies Inc.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+from pydantic import BaseModel
+
+
+class SymbolInfo(BaseModel):
+ """
+ A symbol available on the controller.
+ """
+
+ symbol_id: str
+ filename: str
+ theme: str
+ builtin: bool
+
+
+class SymbolDimensions(BaseModel):
+ """
+ Symbol dimensions in pixels.
+ """
+
+ width: int
+ height: int
diff --git a/gns3server/schemas/controller/tokens.py b/gns3server/schemas/controller/tokens.py
index 1ecd6545a..7b8841b55 100644
--- a/gns3server/schemas/controller/tokens.py
+++ b/gns3server/schemas/controller/tokens.py
@@ -15,6 +15,7 @@
# along with this program. If not, see .
from typing import Optional
+from uuid import UUID
from pydantic import BaseModel
@@ -43,3 +44,30 @@ class ApiKeyCreate(BaseModel):
"""Schema for creating a new API key."""
name: str
+
+
+class ApiKey(BaseModel):
+ """API key metadata. The secret is never returned after creation."""
+
+ api_key_id: UUID
+ name: str
+ key_prefix: str
+ created_at: Optional[str] = None
+ last_used_at: Optional[str] = None
+ revoked: bool
+
+
+class ApiKeyCreated(BaseModel):
+ """A newly created API key. The full key is returned only once."""
+
+ api_key_id: UUID
+ api_key: str
+ name: str
+ key_prefix: str
+ created_at: Optional[str] = None
+
+
+class ApiKeyMessage(BaseModel):
+ """Confirmation message returned when an API key is revoked or restored."""
+
+ message: str
diff --git a/tests/api/routes/controller/test_appliances.py b/tests/api/routes/controller/test_appliances.py
index 193557e35..e2b853f5f 100644
--- a/tests/api/routes/controller/test_appliances.py
+++ b/tests/api/routes/controller/test_appliances.py
@@ -105,7 +105,7 @@ class TestApplianceRoutes:
async def test_add_v8_version_appliance(self, app: FastAPI, client: AsyncClient) -> None:
appliance_id = "1cfdf900-7c30-4cb7-8f03-3f61d2581633" # Empty VM appliance
- new_version = {"name": "v8-99G", "category": "guest", "images": {"hda_disk_image": "empty99G.qcow2"}}
+ new_version = {"name": "v8-99G", "images": {"hda_disk_image": "empty99G.qcow2"}}
response = await client.post(
app.url_path_for("add_appliance_version", appliance_id=appliance_id), json=new_version
)
diff --git a/tests/api/routes/controller/test_links.py b/tests/api/routes/controller/test_links.py
index 54830923b..14e5b9699 100644
--- a/tests/api/routes/controller/test_links.py
+++ b/tests/api/routes/controller/test_links.py
@@ -648,12 +648,12 @@ class TestLinkRoutes:
assert "LinkFilters" not in schemas
filters_property = schemas["Link"]["properties"]["filters"]
assert filters_property["anyOf"][0] == {"additionalProperties": True, "type": "object"}
- assert schemas["LinkFilterDefinition"]["properties"]["parameters"]["items"] == {
+ assert schemas["LinkFilterInfo"]["properties"]["parameters"]["items"] == {
"$ref": "#/components/schemas/LinkFilterParameter"
}
path = "/v3/projects/{project_id}/links/{link_id}/available_filters"
responses = app.openapi()["paths"][path]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
- assert responses["items"] == {"$ref": "#/components/schemas/LinkFilterDefinition"}
+ assert responses["items"] == {"$ref": "#/components/schemas/LinkFilterInfo"}
async def test_get_udp_interface(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
"""
diff --git a/tests/api/routes/controller/test_markers.py b/tests/api/routes/controller/test_markers.py
index b53ec8533..bc4fecf30 100644
--- a/tests/api/routes/controller/test_markers.py
+++ b/tests/api/routes/controller/test_markers.py
@@ -19,6 +19,8 @@ HTTP-route tests for the traffic-insight marker endpoints: per-link markers,
project-level definitions, and the project-wide aggregation view.
"""
+from unittest.mock import DEFAULT
+
import pytest
from fastapi import FastAPI, status
from httpx import AsyncClient
@@ -43,6 +45,25 @@ def _inherited(link, name="arp"):
}
+def _record_marker(link):
+ """Side effect for a mocked ``start_marker`` that stores the marker like the real one does."""
+
+ def side_effect(*, name, bpf, tag=None, color=None, highlight_duration=None, direction=None, **kwargs):
+ link._markers[name] = {
+ "bpf": bpf,
+ "tag": tag,
+ "enabled": True,
+ "color": color,
+ "highlight_duration": highlight_duration,
+ "capture_node_id": "node-id",
+ "direction": direction,
+ "data_link_type": kwargs.get("data_link_type", "DLT_EN10MB"),
+ }
+ return DEFAULT
+
+ return side_effect
+
+
class TestMarkerRoutes:
# -----------------------------------------------------------------------
# Per-link markers
@@ -53,13 +74,25 @@ class TestMarkerRoutes:
link = UDPLink(project)
project._links = {link.id: link}
- with asyncio_patch("gns3server.controller.udp_link.UDPLink.start_marker") as mock:
+ with asyncio_patch(
+ "gns3server.controller.udp_link.UDPLink.start_marker", side_effect=_record_marker(link)
+ ) as mock:
response = await client.post(
app.url_path_for("create_marker", project_id=project.id, link_id=link.id),
json={"bpf": "icmp", "tag": 3, "color": "#ff5722", "highlight_duration": 800},
)
assert response.status_code == status.HTTP_201_CREATED
+ assert response.json() == {
+ "bpf": "icmp",
+ "tag": 3,
+ "enabled": True,
+ "color": "#ff5722",
+ "highlight_duration": 800,
+ "capture_node_id": "node-id",
+ "direction": None,
+ "data_link_type": "DLT_EN10MB",
+ }
mock.assert_called_once()
_, kwargs = mock.call_args
assert kwargs["bpf"] == "icmp"
@@ -73,12 +106,15 @@ class TestMarkerRoutes:
link = UDPLink(project)
project._links = {link.id: link}
- with asyncio_patch("gns3server.controller.udp_link.UDPLink.start_marker") as mock:
+ with asyncio_patch(
+ "gns3server.controller.udp_link.UDPLink.start_marker", side_effect=_record_marker(link)
+ ) as mock:
response = await client.post(
app.url_path_for("create_marker", project_id=project.id, link_id=link.id),
json={"name": "web", "bpf": "tcp port 80"},
)
assert response.status_code == status.HTTP_201_CREATED
+ assert response.json()["bpf"] == "tcp port 80"
_, kwargs = mock.call_args
assert kwargs["name"] == "web"
@@ -141,6 +177,16 @@ class TestMarkerRoutes:
async def test_update_marker(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = UDPLink(project)
+ link._markers["web"] = {
+ "bpf": "udp port 53",
+ "tag": None,
+ "enabled": True,
+ "color": None,
+ "highlight_duration": 1500,
+ "capture_node_id": "node-id",
+ "direction": None,
+ "data_link_type": "DLT_EN10MB",
+ }
project._links = {link.id: link}
with asyncio_patch("gns3server.controller.udp_link.UDPLink.update_marker") as mock:
@@ -149,6 +195,7 @@ class TestMarkerRoutes:
json={"bpf": "udp port 53", "highlight_duration": 1500},
)
assert response.status_code == status.HTTP_200_OK
+ assert response.json() == link._markers["web"]
_, kwargs = mock.call_args
assert kwargs["bpf"] == "udp port 53"
assert kwargs["highlight_duration"] == 1500
@@ -195,12 +242,27 @@ class TestMarkerRoutes:
async def test_create_marker_definition(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
- with asyncio_patch("gns3server.controller.project.Project.create_marker_definition") as mock:
+ def store_definition(*, name, bpf, highlight_duration=None, **kwargs):
+ project._marker_definitions[name] = {
+ "bpf": bpf,
+ "tag": None,
+ "direction": None,
+ "color": None,
+ "highlight_duration": highlight_duration,
+ "data_link_type": "DLT_EN10MB",
+ "paused": False,
+ }
+ return DEFAULT
+
+ with asyncio_patch(
+ "gns3server.controller.project.Project.create_marker_definition", side_effect=store_definition
+ ) as mock:
response = await client.post(
app.url_path_for("create_marker_definition", project_id=project.id),
json={"name": "arp", "bpf": "arp", "highlight_duration": 1200},
)
assert response.status_code == status.HTTP_201_CREATED
+ assert response.json() == project._marker_definitions["arp"]
_, kwargs = mock.call_args
assert kwargs["name"] == "arp"
assert kwargs["bpf"] == "arp"
@@ -220,12 +282,15 @@ class TestMarkerRoutes:
async def test_update_marker_definition(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
+ project._marker_definitions["arp"] = {"bpf": "arp or rarp", "tag": None, "highlight_duration": 900}
+
with asyncio_patch("gns3server.controller.project.Project.update_marker_definition") as mock:
response = await client.put(
app.url_path_for("update_marker_definition", project_id=project.id, def_name="arp"),
json={"bpf": "arp or rarp", "highlight_duration": 900},
)
assert response.status_code == status.HTTP_200_OK
+ assert response.json() == {"bpf": "arp or rarp", "tag": None, "highlight_duration": 900}
_, kwargs = mock.call_args
assert kwargs["bpf"] == "arp or rarp"
assert kwargs["highlight_duration"] == 900
diff --git a/tests/api/routes/controller/test_response_models.py b/tests/api/routes/controller/test_response_models.py
new file mode 100644
index 000000000..9ea0f6dda
--- /dev/null
+++ b/tests/api/routes/controller/test_response_models.py
@@ -0,0 +1,464 @@
+#
+# Copyright (C) 2026 GNS3 Technologies Inc.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+"""
+Runtime JSON shape of the endpoints that used to return untyped objects, and
+the OpenAPI contract that now describes them.
+"""
+
+import uuid
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from fastapi import FastAPI, status
+from httpx import AsyncClient
+
+from gns3server.controller import Controller, marker_replay
+from gns3server.controller.link import Link
+from gns3server.controller.project import Project
+from gns3server.controller.udp_link import UDPLink
+from tests.controller.test_marker_replay import _write_pcap
+
+pytestmark = pytest.mark.asyncio
+
+TYPED_OPERATIONS = [
+ ("post", "/v3/access/api-keys"),
+ ("get", "/v3/access/api-keys"),
+ ("post", "/v3/access/api-keys/{api_key_id}/revoke"),
+ ("post", "/v3/access/api-keys/{api_key_id}/restore"),
+ ("get", "/v3/statistics"),
+ ("get", "/v3/projects/{project_id}/stats"),
+ ("post", "/v3/images/install"),
+ ("post", "/v3/appliances/{appliance_id}/version"),
+ ("get", "/v3/projects/{project_id}/markers"),
+ ("get", "/v3/projects/{project_id}/marker-definitions"),
+ ("post", "/v3/projects/{project_id}/marker-definitions"),
+ ("put", "/v3/projects/{project_id}/marker-definitions/{def_name}"),
+ ("get", "/v3/projects/{project_id}/markers/tags/{tag}/replay/range"),
+ ("get", "/v3/projects/{project_id}/markers/tags/{tag}/replay/frames"),
+ ("get", "/v3/projects/{project_id}/markers/tags/{tag}/replay/frame/detail"),
+ ("get", "/v3/projects/{project_id}/links/{link_id}/markers"),
+ ("post", "/v3/projects/{project_id}/links/{link_id}/markers"),
+ ("put", "/v3/projects/{project_id}/links/{link_id}/markers/{marker_name}"),
+ ("get", "/v3/symbols"),
+ ("get", "/v3/symbols/{symbol_id}/dimensions"),
+ ("get", "/v3/symbols/default_symbols"),
+ ("get", "/v3/projects/{project_id}/links/{link_id}/available_filters"),
+ ("post", "/v3/projects/{project_id}/links/{link_id}/capture/wireshark/restart"),
+ ("get", "/v3/projects/{project_id}/nodes/{node_id}/dynamips/auto_idlepc"),
+ ("post", "/v3/computes/{compute_id}/dynamips/auto_idlepc"),
+ ("get", "/v3/gns3vm/engines"),
+ ("get", "/v3/gns3vm/engines/{engine}/vms"),
+ ("get", "/v3/access/acl/endpoints"),
+]
+
+
+def _is_untyped(schema: dict) -> bool:
+ if not schema:
+ return True
+ if "$ref" in schema:
+ return False
+ if schema.get("type") == "array":
+ return _is_untyped(schema.get("items", {}))
+ if schema.get("type") == "object":
+ extra = schema.get("additionalProperties")
+ if "properties" in schema:
+ return False
+ return extra in (None, True, {})
+ for key in ("anyOf", "oneOf"):
+ if key in schema:
+ return any(_is_untyped(s) for s in schema[key] if s.get("type") != "null")
+ return False
+
+
+class TestOpenAPIContract:
+ async def test_no_untyped_success_response(self, app: FastAPI, client: AsyncClient) -> None:
+
+ response = await client.get("/openapi.json")
+ assert response.status_code == status.HTTP_200_OK
+ paths = response.json()["paths"]
+
+ untyped = []
+ for method, path in TYPED_OPERATIONS:
+ operation = paths[path][method]
+ success = [r for code, r in operation["responses"].items() if code.startswith("2") and code != "204"]
+ assert success, f"{method.upper()} {path} declares no success response"
+ for r in success:
+ schema = r["content"]["application/json"]["schema"]
+ if _is_untyped(schema):
+ untyped.append(f"{method.upper()} {path}")
+ assert not untyped
+
+
+class TestApiKeys:
+ async def _create(self, app: FastAPI, client: AsyncClient, name: str = "ci") -> dict:
+
+ response = await client.post(app.url_path_for("create_api_key"), json={"name": name})
+ assert response.status_code == status.HTTP_201_CREATED
+ return response.json()
+
+ async def test_create(self, app: FastAPI, client: AsyncClient) -> None:
+
+ body = await self._create(app, client, "created")
+ assert set(body) == {"api_key_id", "api_key", "name", "key_prefix", "created_at"}
+ assert body["name"] == "created"
+ assert body["api_key"].startswith(f"gns3_{body['api_key_id']}_")
+ assert body["key_prefix"] == body["api_key"][:8]
+ assert isinstance(body["created_at"], str)
+ uuid.UUID(body["api_key_id"])
+
+ async def test_list(self, app: FastAPI, client: AsyncClient) -> None:
+
+ created = await self._create(app, client, "listed")
+ response = await client.get(app.url_path_for("list_api_keys"))
+ assert response.status_code == status.HTTP_200_OK
+ entry = next(k for k in response.json() if k["api_key_id"] == created["api_key_id"])
+ assert set(entry) == {"api_key_id", "name", "key_prefix", "created_at", "last_used_at", "revoked"}
+ assert entry["name"] == "listed"
+ assert entry["last_used_at"] is None
+ assert entry["revoked"] is False
+ assert entry["created_at"] == created["created_at"]
+
+ async def test_revoke_and_restore(self, app: FastAPI, client: AsyncClient) -> None:
+
+ created = await self._create(app, client, "toggled")
+ key_id = created["api_key_id"]
+
+ response = await client.post(app.url_path_for("revoke_api_key", api_key_id=key_id))
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json() == {"message": "API key 'toggled' revoked"}
+ keys = (await client.get(app.url_path_for("list_api_keys"))).json()
+ assert next(k for k in keys if k["api_key_id"] == key_id)["revoked"] is True
+
+ response = await client.post(app.url_path_for("restore_api_key", api_key_id=key_id))
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json() == {"message": "API key 'toggled' restored"}
+ keys = (await client.get(app.url_path_for("list_api_keys"))).json()
+ assert next(k for k in keys if k["api_key_id"] == key_id)["revoked"] is False
+
+ async def test_revoke_unknown_key(self, app: FastAPI, client: AsyncClient) -> None:
+
+ response = await client.post(app.url_path_for("revoke_api_key", api_key_id=str(uuid.uuid4())))
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+
+COMPUTE_STATISTICS = {
+ "memory_total": 8000,
+ "memory_free": 4000,
+ "memory_used": 4000,
+ "swap_total": 1000,
+ "swap_free": 900,
+ "swap_used": 100,
+ "cpu_usage_percent": 12,
+ "cpu_count": 4,
+ "cpu_count_physical": None,
+ "cpu_model": "Test CPU",
+ "memory_usage_percent": 50,
+ "swap_usage_percent": 10,
+ "disk_usage_percent": 30,
+ "disk_total": 100000,
+ "disk_used": 30000,
+ "disk_free": 70000,
+ "load_average": [0.5, 0.4, 0.3],
+ "load_average_percent": [12.5, 10.0, 7.5],
+}
+
+
+class TestStatistics:
+ async def test_statistics_shape(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
+
+ webwireshark = {
+ "total_containers": 1,
+ "running_containers": 1,
+ "active_sessions": 0,
+ "containers": [
+ {
+ "project_id": project.id,
+ "project_name": project.name,
+ "container_id": "abcdef123456",
+ "status": "running",
+ "running": True,
+ "active_sessions": 0,
+ "memory_limit": "unlimited",
+ "cpu_limit": "unlimited",
+ "pids_limit": "unlimited",
+ "memory": "10MiB / 1GiB",
+ "cpu": "0.5%",
+ "pids": 3,
+ },
+ {
+ "project_id": project.id,
+ "project_name": project.name,
+ "container_id": "123456abcdef",
+ "status": "exited",
+ "running": False,
+ },
+ ],
+ }
+ with patch(
+ "gns3server.api.routes.controller.controller.collect_webwireshark_stats",
+ new=AsyncMock(return_value=webwireshark),
+ ):
+ response = await client.get(app.url_path_for("statistics"))
+ assert response.status_code == status.HTTP_200_OK
+ body = response.json()
+ assert set(body) == {"uptime_seconds", "computes", "projects", "nodes", "links", "webwireshark"}
+ assert isinstance(body["uptime_seconds"], int)
+ assert body["projects"] == {"total": 1, "opened": 1, "closed": 0}
+ assert body["nodes"] == {
+ "total": 0,
+ "open_project_nodes": 0,
+ "closed_project_nodes": 0,
+ "by_type": {},
+ "by_status": {},
+ }
+ assert body["links"] == {"total": 0, "capturing": 0}
+ assert body["webwireshark"] == webwireshark
+
+ async def test_statistics_compute_entry(
+ self, app: FastAPI, client: AsyncClient, controller: Controller, project: Project
+ ) -> None:
+
+ compute = MagicMock()
+ compute.id = "local"
+ compute.name = "local"
+ reply = MagicMock()
+ reply.json = dict(COMPUTE_STATISTICS)
+ compute.get = AsyncMock(return_value=reply)
+ controller._computes = {"local": compute}
+
+ response = await client.get(app.url_path_for("statistics"))
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json()["computes"] == [
+ {"compute_id": "local", "compute_name": "local", "statistics": COMPUTE_STATISTICS}
+ ]
+
+ async def test_statistics_compute_missing_fields_stay_absent(
+ self, app: FastAPI, client: AsyncClient, controller: Controller, project: Project
+ ) -> None:
+
+ compute = MagicMock()
+ compute.id = "old"
+ compute.name = "old"
+ reply = MagicMock()
+ reply.json = {"memory_total": 8000, "cpu_count": 2}
+ compute.get = AsyncMock(return_value=reply)
+ controller._computes = {"old": compute}
+
+ response = await client.get(app.url_path_for("statistics"))
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json()["computes"][0]["statistics"] == {"memory_total": 8000, "cpu_count": 2}
+
+ async def test_project_stats(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
+
+ link = UDPLink(project)
+ link._markers["a"] = {"bpf": "icmp"}
+ link._markers["b"] = {"bpf": "arp"}
+ project._links = {link.id: link}
+
+ response = await client.get(app.url_path_for("get_project_stats", project_id=project.id))
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json() == {"nodes": 0, "links": 1, "drawings": 0, "snapshots": 0, "markers": 2}
+
+
+class TestSymbols:
+ async def test_list_shape(self, app: FastAPI, client: AsyncClient) -> None:
+
+ response = await client.get(app.url_path_for("get_symbols"))
+ assert response.status_code == status.HTTP_200_OK
+ symbols = response.json()
+ assert symbols
+ for symbol in symbols:
+ assert set(symbol) == {"symbol_id", "filename", "theme", "builtin"}
+ assert isinstance(symbol["builtin"], bool)
+
+ async def test_default_symbols(self, app: FastAPI, client: AsyncClient) -> None:
+
+ response = await client.get(app.url_path_for("get_default_symbols"))
+ assert response.status_code == status.HTTP_200_OK
+ themes = response.json()
+ assert themes["Classic"]["firewall"] == ":/symbols/classic/firewall.svg"
+ assert themes == Controller.instance().symbols.default_symbols()
+
+ async def test_dimensions(self, app: FastAPI, client: AsyncClient) -> None:
+
+ response = await client.get(
+ app.url_path_for("get_symbol_dimensions", symbol_id=":/symbols/classic/firewall.svg")
+ )
+ assert response.status_code == status.HTTP_200_OK
+ body = response.json()
+ assert set(body) == {"width", "height"}
+ assert isinstance(body["width"], int)
+ assert isinstance(body["height"], int)
+
+
+class TestLinkRoutes:
+ async def test_wireshark_restart(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
+
+ link = Link(project)
+ project._links = {link.id: link}
+ with patch("gns3server.controller.link.Link._restart_web_wireshark", new=AsyncMock()):
+ response = await client.post(app.url_path_for("restart_wireshark", project_id=project.id, link_id=link.id))
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json() == {"status": "restarted"}
+
+ async def test_available_filters_keep_optional_parameter_keys_absent(
+ self, app: FastAPI, client: AsyncClient, project: Project
+ ) -> None:
+
+ link = Link(project)
+ project._links = {link.id: link}
+ with patch.object(Link, "_get_filter_node", return_value=object()):
+ response = await client.get(app.url_path_for("get_filters", project_id=project.id, link_id=link.id))
+ assert response.status_code == status.HTTP_200_OK
+ by_type = {f["type"]: f for f in response.json()}
+ assert by_type["bpf"]["parameters"] == [{"name": "Filters", "type": "text"}]
+ assert by_type["delay"]["parameters"][0] == {
+ "name": "Latency",
+ "minimum": 1,
+ "maximum": 32767,
+ "unit": "ms",
+ "type": "int",
+ }
+
+ async def test_link_markers_keep_missing_keys_absent(
+ self, app: FastAPI, client: AsyncClient, project: Project
+ ) -> None:
+
+ link = UDPLink(project)
+ link._markers["legacy"] = {"bpf": "icmp", "tag": 1, "enabled": True}
+ link._markers["global-arp"] = {"bpf": "arp", "inherited_from": "arp", "capture_node_id": "n1"}
+ project._links = {link.id: link}
+
+ response = await client.get(app.url_path_for("get_markers", project_id=project.id, link_id=link.id))
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json() == link._markers
+
+
+class TestMarkerAggregation:
+ async def test_project_markers_and_definitions(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
+
+ link = UDPLink(project)
+ link._markers["web"] = {
+ "bpf": "tcp port 80",
+ "tag": 4,
+ "enabled": False,
+ "color": "#ff5722",
+ "highlight_duration": 800,
+ "capture_node_id": "n1",
+ "direction": "tx",
+ "data_link_type": "DLT_EN10MB",
+ }
+ link._markers["global-arp"] = {
+ "bpf": "arp",
+ "tag": None,
+ "enabled": True,
+ "color": None,
+ "highlight_duration": None,
+ "capture_node_id": "n1",
+ "direction": None,
+ "data_link_type": "DLT_EN10MB",
+ "inherited_from": "arp",
+ }
+ project._links = {link.id: link}
+ project._marker_definitions = {
+ "arp": {
+ "bpf": "arp",
+ "tag": None,
+ "direction": None,
+ "color": None,
+ "highlight_duration": None,
+ "data_link_type": "DLT_EN10MB",
+ "paused": False,
+ },
+ "old": {"bpf": "icmp", "tag": 5},
+ }
+
+ response = await client.get(app.url_path_for("get_project_markers", project_id=project.id))
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json() == {
+ f"{link.id}/web": {**link._markers["web"], "link_id": link.id, "node_id": "n1"},
+ f"{link.id}/global-arp": {**link._markers["global-arp"], "link_id": link.id, "node_id": "n1"},
+ }
+
+ response = await client.get(app.url_path_for("get_marker_definitions", project_id=project.id))
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json() == {
+ "arp": {**project._marker_definitions["arp"], "link_ids": [link.id]},
+ "old": {"bpf": "icmp", "tag": 5, "link_ids": []},
+ }
+
+
+class TestReplayFrameDetail:
+ async def test_frame_detail_passes_unknown_tree_keys_through(
+ self, app: FastAPI, client: AsyncClient, project: Project, monkeypatch
+ ) -> None:
+
+ detail = {
+ "ts": "1693472000.000000",
+ "source": {"node_id": "n1", "link_id": "l1", "marker": "icmp", "frame_number": 1},
+ "field_count": 3,
+ "hex": "0200",
+ "tree": [
+ {
+ "element": "proto",
+ "label": "Internet Protocol",
+ "name": "ip",
+ "filter_expr": "ip",
+ "pos": 14,
+ "size": 20,
+ "children": [{"label": "TTL: 64", "name": "ip.ttl", "expert": "Chat", "generated": True}],
+ "future_key": {"a": 1},
+ }
+ ],
+ }
+ monkeypatch.setattr(marker_replay, "decode_frame", AsyncMock(return_value=detail))
+
+ response = await client.get(
+ app.url_path_for("replay_tag_frame_detail", project_id=project.id, tag=7),
+ params={"ts": "1693472000.000000", "node_id": "n1", "link_id": "l1", "marker": "icmp"},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json() == detail
+
+ async def test_range_empty_timeline(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
+
+ link = UDPLink(project)
+ link._markers["icmp"] = {
+ "bpf": "icmp",
+ "tag": 9,
+ "enabled": False,
+ "color": None,
+ "highlight_duration": None,
+ "capture_node_id": "n1",
+ "direction": None,
+ "data_link_type": "DLT_EN10MB",
+ }
+ project._links = {link.id: link}
+ _write_pcap(f"{project.markers_directory}/n1_{link.id}_icmp.pcap", [])
+
+ response = await client.get(app.url_path_for("replay_tag_range", project_id=project.id, tag=9))
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json() == {
+ "tag": 9,
+ "start": None,
+ "end": None,
+ "frame_count": 0,
+ "sources": [
+ {"node_id": "n1", "link_id": link.id, "marker": "icmp", "data_link_type": "DLT_EN10MB", "count": 0}
+ ],
+ "frames": [],
+ }