mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-10-22 19:02:23 +03:00
feat: add response models for endpoints returning untyped objects
This commit is contained in:
parent
0836e01525
commit
db031416a1
@ -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)
|
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(
|
async def endpoints(
|
||||||
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
|
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
|
||||||
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
|
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
|
||||||
|
|||||||
@ -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
|
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(
|
async def create_api_key(
|
||||||
api_key_data: schemas.ApiKeyCreate,
|
api_key_data: schemas.ApiKeyCreate,
|
||||||
current_user: schemas.User = Depends(get_current_active_user),
|
current_user: schemas.User = Depends(get_current_active_user),
|
||||||
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
|
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
|
||||||
) -> dict:
|
) -> schemas.ApiKeyCreated:
|
||||||
"""Create a new API key. The full key is returned only once."""
|
"""Create a new API key. The full key is returned only once."""
|
||||||
|
|
||||||
raw_key, key_hash, key_prefix, new_key_id = _generate_api_key()
|
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_hash=key_hash,
|
||||||
key_prefix=key_prefix,
|
key_prefix=key_prefix,
|
||||||
)
|
)
|
||||||
return {
|
return schemas.ApiKeyCreated(
|
||||||
"api_key_id": str(db_key.api_key_id),
|
api_key_id=db_key.api_key_id,
|
||||||
"api_key": raw_key,
|
api_key=raw_key,
|
||||||
"name": db_key.name,
|
name=db_key.name,
|
||||||
"key_prefix": db_key.key_prefix,
|
key_prefix=db_key.key_prefix,
|
||||||
"created_at": db_key.created_at.isoformat() if db_key.created_at else None,
|
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(
|
async def list_api_keys(
|
||||||
current_user: schemas.User = Depends(get_current_active_user),
|
current_user: schemas.User = Depends(get_current_active_user),
|
||||||
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
|
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
|
||||||
) -> list[dict]:
|
) -> list[schemas.ApiKey]:
|
||||||
"""List all API keys for the current user."""
|
"""List all API keys for the current user."""
|
||||||
|
|
||||||
keys = await api_keys_repo.get_api_keys_by_user(current_user.user_id)
|
keys = await api_keys_repo.get_api_keys_by_user(current_user.user_id)
|
||||||
return [
|
return [
|
||||||
{
|
schemas.ApiKey(
|
||||||
"api_key_id": str(k.api_key_id),
|
api_key_id=k.api_key_id,
|
||||||
"name": k.name,
|
name=k.name,
|
||||||
"key_prefix": k.key_prefix,
|
key_prefix=k.key_prefix,
|
||||||
"created_at": k.created_at.isoformat() if k.created_at else None,
|
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,
|
last_used_at=k.last_used_at.isoformat() if k.last_used_at else None,
|
||||||
"revoked": k.revoked,
|
revoked=k.revoked,
|
||||||
}
|
)
|
||||||
for k in keys
|
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(
|
async def revoke_api_key(
|
||||||
api_key_id: UUID,
|
api_key_id: UUID,
|
||||||
current_user: schemas.User = Depends(get_current_active_user),
|
current_user: schemas.User = Depends(get_current_active_user),
|
||||||
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
|
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
|
||||||
) -> dict:
|
) -> schemas.ApiKeyMessage:
|
||||||
"""Revoke an API key. It will immediately stop working, but can be restored."""
|
"""Revoke an API key. It will immediately stop working, but can be restored."""
|
||||||
|
|
||||||
key = await api_keys_repo.get_api_key(api_key_id)
|
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")
|
raise HTTPException(status_code=403, detail="Cannot modify another user's API key")
|
||||||
|
|
||||||
await api_keys_repo.revoke_api_key(api_key_id)
|
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(
|
async def restore_api_key(
|
||||||
api_key_id: UUID,
|
api_key_id: UUID,
|
||||||
current_user: schemas.User = Depends(get_current_active_user),
|
current_user: schemas.User = Depends(get_current_active_user),
|
||||||
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
|
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
|
||||||
) -> dict:
|
) -> schemas.ApiKeyMessage:
|
||||||
"""Restore a previously revoked API key."""
|
"""Restore a previously revoked API key."""
|
||||||
|
|
||||||
key = await api_keys_repo.get_api_key(api_key_id)
|
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")
|
raise HTTPException(status_code=403, detail="Cannot modify another user's API key")
|
||||||
|
|
||||||
await api_keys_repo.restore_api_key(api_key_id)
|
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)
|
@router.delete("/{api_key_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|||||||
@ -82,6 +82,8 @@ def get_appliance(appliance_id: UUID) -> schemas.Appliance:
|
|||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{appliance_id}/version",
|
"/{appliance_id}/version",
|
||||||
|
response_model=schemas.Appliance,
|
||||||
|
response_model_exclude_unset=True,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
dependencies=[Depends(has_privilege("Appliance.Allocate"))],
|
dependencies=[Depends(has_privilege("Appliance.Allocate"))],
|
||||||
)
|
)
|
||||||
|
|||||||
@ -212,7 +212,7 @@ async def vmware_vms(compute_id: Union[str, UUID]) -> List[schemas.ComputeVMware
|
|||||||
return result
|
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):
|
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.
|
Find a suitable Idle-PC value for a given IOS image. This may take a few minutes.
|
||||||
|
|||||||
@ -161,7 +161,12 @@ async def update_iou_license(iou_license: schemas.IOULicense) -> schemas.IOULice
|
|||||||
return current_iou_license
|
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:
|
async def statistics() -> dict:
|
||||||
"""
|
"""
|
||||||
Return server statistics including compute resources, projects, and nodes.
|
Return server statistics including compute resources, projects, and nodes.
|
||||||
|
|||||||
@ -30,7 +30,7 @@ from gns3server.controller import Controller
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/engines")
|
@router.get("/engines", response_model=List[schemas.GNS3VMEngineInfo])
|
||||||
async def get_engines() -> List[dict]:
|
async def get_engines() -> List[dict]:
|
||||||
"""
|
"""
|
||||||
Return the list of supported engines for the GNS3VM.
|
Return the list of supported engines for the GNS3VM.
|
||||||
@ -40,7 +40,7 @@ async def get_engines() -> List[dict]:
|
|||||||
return gns3_vm.engine_list()
|
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]:
|
async def get_vms(engine: str) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
Return all the available VMs for a specific virtualization engine.
|
Return all the available VMs for a specific virtualization engine.
|
||||||
|
|||||||
@ -327,7 +327,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(
|
async def install_images(
|
||||||
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
|
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
|
||||||
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
|
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
|
||||||
|
|||||||
@ -20,7 +20,7 @@ API routes for links.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import Any, List, Union
|
from typing import Any, Dict, List, Union
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@ -123,7 +123,12 @@ async def create_link(project_id: UUID, link_create: schemas.LinkCreate) -> sche
|
|||||||
return link.asdict()
|
return link.asdict()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{link_id}/available_filters", dependencies=[Depends(has_privilege("Link.Audit"))])
|
@router.get(
|
||||||
|
"/{link_id}/available_filters",
|
||||||
|
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[dict]:
|
async def get_filters(link: Link = Depends(dep_link)) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
Return all filters available for a given link.
|
Return all filters available for a given link.
|
||||||
@ -253,6 +258,7 @@ async def stop_capture(link: Link = Depends(dep_link)) -> None:
|
|||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{link_id}/capture/wireshark/restart",
|
"/{link_id}/capture/wireshark/restart",
|
||||||
|
response_model=schemas.LinkWiresharkRestart,
|
||||||
status_code=status.HTTP_200_OK,
|
status_code=status.HTTP_200_OK,
|
||||||
dependencies=[Depends(has_privilege("Link.Capture"))],
|
dependencies=[Depends(has_privilege("Link.Capture"))],
|
||||||
)
|
)
|
||||||
@ -405,7 +411,12 @@ async def web_wireshark_websocket(
|
|||||||
pass
|
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:
|
async def get_markers(link: Link = Depends(dep_link)) -> dict:
|
||||||
"""
|
"""
|
||||||
Return all traffic-insight markers configured on this link.
|
Return all traffic-insight markers configured on this link.
|
||||||
@ -417,7 +428,11 @@ async def get_markers(link: Link = Depends(dep_link)) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@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:
|
async def create_marker(marker_data: schemas.MarkerCreate, link: Link = Depends(dep_link)) -> dict:
|
||||||
"""
|
"""
|
||||||
@ -462,7 +477,12 @@ async def delete_marker(marker_name: str, link: Link = Depends(dep_link)) -> Non
|
|||||||
await link.stop_marker(marker_name)
|
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:
|
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).
|
Update a traffic-insight marker (change BPF, tag, or enabled).
|
||||||
|
|||||||
@ -447,7 +447,11 @@ async def get_node_links(node: Node = Depends(dep_node)) -> List[schemas.Link]:
|
|||||||
return links
|
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:
|
async def auto_idlepc(node: Node = Depends(dep_node)) -> dict:
|
||||||
"""
|
"""
|
||||||
Compute an Idle-PC value for a Dynamips node
|
Compute an Idle-PC value for a Dynamips node
|
||||||
|
|||||||
@ -31,7 +31,7 @@ import gns3server.utils.zipfile_zstd as zipfile
|
|||||||
|
|
||||||
log = logging.getLogger()
|
log = logging.getLogger()
|
||||||
|
|
||||||
from typing import Any, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, WebSocket, WebSocketDisconnect, status
|
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, WebSocket, WebSocketDisconnect, status
|
||||||
@ -199,7 +199,9 @@ async def delete_project(
|
|||||||
await rbac_repo.delete_all_ace_starting_with_path(f"/projects/{project.id}")
|
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:
|
def get_project_stats(project: Project = Depends(dep_project)) -> dict:
|
||||||
"""
|
"""
|
||||||
Return a project statistics.
|
Return a project statistics.
|
||||||
@ -210,7 +212,12 @@ def get_project_stats(project: Project = Depends(dep_project)) -> dict:
|
|||||||
return project.stats()
|
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:
|
def get_project_markers(project: Project = Depends(dep_project)) -> dict:
|
||||||
"""
|
"""
|
||||||
Return all traffic-insight markers across every link in the project.
|
Return all traffic-insight markers across every link in the project.
|
||||||
@ -241,6 +248,7 @@ async def _replay_response(awaitable):
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{project_id}/markers/tags/{tag}/replay/range",
|
"/{project_id}/markers/tags/{tag}/replay/range",
|
||||||
|
response_model=schemas.ReplayTimeline,
|
||||||
dependencies=[Depends(has_privilege("Project.Audit"))],
|
dependencies=[Depends(has_privilege("Project.Audit"))],
|
||||||
)
|
)
|
||||||
async def replay_tag_range(
|
async def replay_tag_range(
|
||||||
@ -281,6 +289,7 @@ async def replay_tag_range(
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{project_id}/markers/tags/{tag}/replay/frames",
|
"/{project_id}/markers/tags/{tag}/replay/frames",
|
||||||
|
response_model=schemas.ReplayFrames,
|
||||||
dependencies=[Depends(has_privilege("Project.Audit"))],
|
dependencies=[Depends(has_privilege("Project.Audit"))],
|
||||||
)
|
)
|
||||||
async def replay_tag_frames(
|
async def replay_tag_frames(
|
||||||
@ -321,6 +330,8 @@ async def replay_tag_frames(
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{project_id}/markers/tags/{tag}/replay/frame/detail",
|
"/{project_id}/markers/tags/{tag}/replay/frame/detail",
|
||||||
|
response_model=schemas.ReplayFrameDetail,
|
||||||
|
response_model_exclude_unset=True,
|
||||||
dependencies=[Depends(has_privilege("Project.Audit"))],
|
dependencies=[Depends(has_privilege("Project.Audit"))],
|
||||||
)
|
)
|
||||||
async def replay_tag_frame_detail(
|
async def replay_tag_frame_detail(
|
||||||
@ -359,7 +370,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:
|
def get_marker_definitions(project: Project = Depends(dep_project)) -> dict:
|
||||||
"""
|
"""
|
||||||
Return all project-level marker definitions with their bound link IDs.
|
Return all project-level marker definitions with their bound link IDs.
|
||||||
@ -381,6 +397,8 @@ def get_marker_definitions(project: Project = Depends(dep_project)) -> dict:
|
|||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{project_id}/marker-definitions",
|
"/{project_id}/marker-definitions",
|
||||||
|
response_model=schemas.MarkerDefinition,
|
||||||
|
response_model_exclude_unset=True,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
dependencies=[Depends(has_privilege("Project.Modify"))],
|
dependencies=[Depends(has_privilege("Project.Modify"))],
|
||||||
)
|
)
|
||||||
@ -408,7 +426,12 @@ async def create_marker_definition(
|
|||||||
return project.marker_definitions.get(name, {})
|
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(
|
async def update_marker_definition(
|
||||||
def_name: str, def_data: schemas.MarkerDefinitionCreate, project: Project = Depends(dep_project)
|
def_name: str, def_data: schemas.MarkerDefinitionCreate, project: Project = Depends(dep_project)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
|||||||
@ -21,7 +21,7 @@ API routes for symbols.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import List
|
from typing import Dict, List
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request, Response, status
|
from fastapi import APIRouter, Depends, Request, Response, status
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
@ -39,7 +39,7 @@ log = logging.getLogger(__name__)
|
|||||||
router = APIRouter()
|
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]:
|
def get_symbols() -> List[dict]:
|
||||||
"""
|
"""
|
||||||
Return all symbols.
|
Return all symbols.
|
||||||
@ -78,6 +78,7 @@ async def get_symbol(symbol_id: str, request: Request) -> Response:
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{symbol_id:path}/dimensions",
|
"/{symbol_id:path}/dimensions",
|
||||||
|
response_model=schemas.SymbolDimensions,
|
||||||
responses={404: {"model": schemas.ErrorMessage, "description": "Could not find symbol"}},
|
responses={404: {"model": schemas.ErrorMessage, "description": "Could not find symbol"}},
|
||||||
dependencies=[Depends(has_privilege("Symbol.Audit"))],
|
dependencies=[Depends(has_privilege("Symbol.Audit"))],
|
||||||
)
|
)
|
||||||
@ -97,7 +98,11 @@ async def get_symbol_dimensions(symbol_id: str) -> dict:
|
|||||||
raise ControllerNotFoundError(f"Could not get symbol file: {e}")
|
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:
|
def get_default_symbols() -> dict:
|
||||||
"""
|
"""
|
||||||
Return all default symbols.
|
Return all default symbols.
|
||||||
|
|||||||
@ -26,10 +26,20 @@ from .controller.computes import (
|
|||||||
ComputeUpdate,
|
ComputeUpdate,
|
||||||
ComputeVirtualBoxVM,
|
ComputeVirtualBoxVM,
|
||||||
ComputeVMwareVM,
|
ComputeVMwareVM,
|
||||||
|
IdlePC,
|
||||||
)
|
)
|
||||||
from .controller.drawings import Drawing
|
from .controller.drawings import Drawing
|
||||||
from .controller.gns3vm import GNS3VM
|
from .controller.gns3vm import GNS3VM, GNS3VMEngineInfo, GNS3VMInfo
|
||||||
from .controller.images import Image, ImageSyncJob, ImageSyncRequest, ImageType, ImageUpload
|
from .controller.images import (
|
||||||
|
Image,
|
||||||
|
ImageInstallCreated,
|
||||||
|
ImageInstallResult,
|
||||||
|
ImageInstallSkipped,
|
||||||
|
ImageSyncJob,
|
||||||
|
ImageSyncRequest,
|
||||||
|
ImageType,
|
||||||
|
ImageUpload,
|
||||||
|
)
|
||||||
|
|
||||||
# Controller schemas
|
# Controller schemas
|
||||||
from .controller.links import (
|
from .controller.links import (
|
||||||
@ -37,10 +47,17 @@ from .controller.links import (
|
|||||||
Link,
|
Link,
|
||||||
LinkCapture,
|
LinkCapture,
|
||||||
LinkCreate,
|
LinkCreate,
|
||||||
|
LinkFilterInfo,
|
||||||
|
LinkFilterParameter,
|
||||||
LinkUpdate,
|
LinkUpdate,
|
||||||
|
LinkWiresharkRestart,
|
||||||
|
Marker,
|
||||||
MarkerCreate,
|
MarkerCreate,
|
||||||
|
MarkerDefinition,
|
||||||
MarkerDefinitionCreate,
|
MarkerDefinitionCreate,
|
||||||
|
MarkerDefinitionWithLinks,
|
||||||
MarkerUpdate,
|
MarkerUpdate,
|
||||||
|
ProjectMarker,
|
||||||
UDPPortInfo,
|
UDPPortInfo,
|
||||||
)
|
)
|
||||||
from .controller.nodes import Node, NodeCapture, NodeCreate, NodeDuplicate, NodeUpdate
|
from .controller.nodes import Node, NodeCapture, NodeCreate, NodeDuplicate, NodeUpdate
|
||||||
@ -51,6 +68,7 @@ from .controller.projects import (
|
|||||||
ProjectCreate,
|
ProjectCreate,
|
||||||
ProjectDuplicate,
|
ProjectDuplicate,
|
||||||
ProjectFile,
|
ProjectFile,
|
||||||
|
ProjectStats,
|
||||||
ProjectUpdate,
|
ProjectUpdate,
|
||||||
)
|
)
|
||||||
from .controller.templates import Template, TemplateCreate, TemplateUpdate, TemplateUsage
|
from .controller.templates import Template, TemplateCreate, TemplateUpdate, TemplateUsage
|
||||||
@ -108,11 +126,31 @@ from .compute.vmware_nodes import VMware, VMwareCreate, VMwareUpdate
|
|||||||
from .compute.vpcs_nodes import VPCS, VPCSCreate, VPCSUpdate
|
from .compute.vpcs_nodes import VPCS, VPCSCreate, VPCSUpdate
|
||||||
from .controller.capabilities import Capabilities
|
from .controller.capabilities import Capabilities
|
||||||
from .controller.iou_license import IOULicense
|
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.netmiko import NetmikoDeviceType, NetmikoDeviceTypeList
|
||||||
from .controller.pools import Resource, ResourceCreate, ResourcePool, ResourcePoolCreate, ResourcePoolUpdate
|
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.settings import SettingsResponse, SettingsUpdate, SettingsUpdateResponse
|
||||||
from .controller.snapshots import Snapshot, SnapshotCreate
|
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.cloud_templates import CloudTemplate, CloudTemplateUpdate
|
||||||
from .controller.templates.docker_templates import DockerTemplate, DockerTemplateUpdate
|
from .controller.templates.docker_templates import DockerTemplate, DockerTemplateUpdate
|
||||||
from .controller.templates.dynamips_templates import (
|
from .controller.templates.dynamips_templates import (
|
||||||
@ -141,7 +179,7 @@ from .controller.templates.vmware_templates import VMwareTemplate, VMwareTemplat
|
|||||||
|
|
||||||
# Controller template schemas
|
# Controller template schemas
|
||||||
from .controller.templates.vpcs_templates import VPCSTemplate, VPCSTemplateUpdate
|
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
|
# Schemas for both controller and compute
|
||||||
from .qemu_disk_image import QemuDiskImageCreate, QemuDiskImageFormat, QemuDiskImageUpdate
|
from .qemu_disk_image import QemuDiskImageCreate, QemuDiskImageFormat, QemuDiskImageUpdate
|
||||||
@ -155,11 +193,15 @@ __all__ = [
|
|||||||
"UDPNIO",
|
"UDPNIO",
|
||||||
"VPCS",
|
"VPCS",
|
||||||
"ACECreate",
|
"ACECreate",
|
||||||
|
"ACEEndpoint",
|
||||||
"ACEUpdate",
|
"ACEUpdate",
|
||||||
"ATMSwitch",
|
"ATMSwitch",
|
||||||
"ATMSwitchCreate",
|
"ATMSwitchCreate",
|
||||||
"ATMSwitchUpdate",
|
"ATMSwitchUpdate",
|
||||||
|
"ApiKey",
|
||||||
"ApiKeyCreate",
|
"ApiKeyCreate",
|
||||||
|
"ApiKeyCreated",
|
||||||
|
"ApiKeyMessage",
|
||||||
"Appliance",
|
"Appliance",
|
||||||
"ApplianceVersion",
|
"ApplianceVersion",
|
||||||
"ApplianceVersionV8",
|
"ApplianceVersionV8",
|
||||||
@ -192,6 +234,8 @@ __all__ = [
|
|||||||
"Compute",
|
"Compute",
|
||||||
"ComputeCreate",
|
"ComputeCreate",
|
||||||
"ComputeDockerImage",
|
"ComputeDockerImage",
|
||||||
|
"ComputeStatistics",
|
||||||
|
"ComputeStatisticsEntry",
|
||||||
"ComputeUpdate",
|
"ComputeUpdate",
|
||||||
"ComputeVMwareVM",
|
"ComputeVMwareVM",
|
||||||
"ComputeVirtualBoxVM",
|
"ComputeVirtualBoxVM",
|
||||||
@ -223,13 +267,19 @@ __all__ = [
|
|||||||
"FrameRelaySwitch",
|
"FrameRelaySwitch",
|
||||||
"FrameRelaySwitchCreate",
|
"FrameRelaySwitchCreate",
|
||||||
"FrameRelaySwitchUpdate",
|
"FrameRelaySwitchUpdate",
|
||||||
|
"GNS3VMEngineInfo",
|
||||||
|
"GNS3VMInfo",
|
||||||
"IOUCreate",
|
"IOUCreate",
|
||||||
"IOULicense",
|
"IOULicense",
|
||||||
"IOUStart",
|
"IOUStart",
|
||||||
"IOUTemplate",
|
"IOUTemplate",
|
||||||
"IOUTemplateUpdate",
|
"IOUTemplateUpdate",
|
||||||
"IOUUpdate",
|
"IOUUpdate",
|
||||||
|
"IdlePC",
|
||||||
"Image",
|
"Image",
|
||||||
|
"ImageInstallCreated",
|
||||||
|
"ImageInstallResult",
|
||||||
|
"ImageInstallSkipped",
|
||||||
"ImageSyncJob",
|
"ImageSyncJob",
|
||||||
"ImageSyncRequest",
|
"ImageSyncRequest",
|
||||||
"ImageType",
|
"ImageType",
|
||||||
@ -244,10 +294,17 @@ __all__ = [
|
|||||||
"Link",
|
"Link",
|
||||||
"LinkCapture",
|
"LinkCapture",
|
||||||
"LinkCreate",
|
"LinkCreate",
|
||||||
|
"LinkFilterInfo",
|
||||||
|
"LinkFilterParameter",
|
||||||
|
"LinkStatistics",
|
||||||
"LinkUpdate",
|
"LinkUpdate",
|
||||||
|
"LinkWiresharkRestart",
|
||||||
"LoggedInUserUpdate",
|
"LoggedInUserUpdate",
|
||||||
|
"Marker",
|
||||||
"MarkerCreate",
|
"MarkerCreate",
|
||||||
|
"MarkerDefinition",
|
||||||
"MarkerDefinitionCreate",
|
"MarkerDefinitionCreate",
|
||||||
|
"MarkerDefinitionWithLinks",
|
||||||
"MarkerRebuild",
|
"MarkerRebuild",
|
||||||
"MarkerToggle",
|
"MarkerToggle",
|
||||||
"MarkerUpdate",
|
"MarkerUpdate",
|
||||||
@ -260,15 +317,19 @@ __all__ = [
|
|||||||
"NodeCreate",
|
"NodeCreate",
|
||||||
"NodeDuplicate",
|
"NodeDuplicate",
|
||||||
"NodeFile",
|
"NodeFile",
|
||||||
|
"NodeStatistics",
|
||||||
"NodeUpdate",
|
"NodeUpdate",
|
||||||
"OpenAIMessage",
|
"OpenAIMessage",
|
||||||
"OpenAIToolCall",
|
"OpenAIToolCall",
|
||||||
"Privilege",
|
"Privilege",
|
||||||
"Project",
|
"Project",
|
||||||
"ProjectCompression",
|
"ProjectCompression",
|
||||||
|
"ProjectCountStatistics",
|
||||||
"ProjectCreate",
|
"ProjectCreate",
|
||||||
"ProjectDuplicate",
|
"ProjectDuplicate",
|
||||||
"ProjectFile",
|
"ProjectFile",
|
||||||
|
"ProjectMarker",
|
||||||
|
"ProjectStats",
|
||||||
"ProjectUpdate",
|
"ProjectUpdate",
|
||||||
"Qemu",
|
"Qemu",
|
||||||
"QemuCreate",
|
"QemuCreate",
|
||||||
@ -280,6 +341,13 @@ __all__ = [
|
|||||||
"QemuUpdate",
|
"QemuUpdate",
|
||||||
"RefreshTokenRequest",
|
"RefreshTokenRequest",
|
||||||
"RenameSession",
|
"RenameSession",
|
||||||
|
"ReplayFrame",
|
||||||
|
"ReplayFrameDetail",
|
||||||
|
"ReplayFrameSource",
|
||||||
|
"ReplayFrames",
|
||||||
|
"ReplaySource",
|
||||||
|
"ReplayTimeline",
|
||||||
|
"ReplayTreeNode",
|
||||||
"Resource",
|
"Resource",
|
||||||
"ResourceCreate",
|
"ResourceCreate",
|
||||||
"ResourcePool",
|
"ResourcePool",
|
||||||
@ -289,11 +357,14 @@ __all__ = [
|
|||||||
"RoleCreate",
|
"RoleCreate",
|
||||||
"RoleUpdate",
|
"RoleUpdate",
|
||||||
"ServerConfig",
|
"ServerConfig",
|
||||||
|
"ServerStatistics",
|
||||||
"SettingsResponse",
|
"SettingsResponse",
|
||||||
"SettingsUpdate",
|
"SettingsUpdate",
|
||||||
"SettingsUpdateResponse",
|
"SettingsUpdateResponse",
|
||||||
"Snapshot",
|
"Snapshot",
|
||||||
"SnapshotCreate",
|
"SnapshotCreate",
|
||||||
|
"SymbolDimensions",
|
||||||
|
"SymbolInfo",
|
||||||
"Template",
|
"Template",
|
||||||
"TemplateCreate",
|
"TemplateCreate",
|
||||||
"TemplateUpdate",
|
"TemplateUpdate",
|
||||||
@ -321,4 +392,6 @@ __all__ = [
|
|||||||
"VirtualBoxTemplate",
|
"VirtualBoxTemplate",
|
||||||
"VirtualBoxTemplateUpdate",
|
"VirtualBoxTemplateUpdate",
|
||||||
"VirtualBoxUpdate",
|
"VirtualBoxUpdate",
|
||||||
|
"WebWiresharkContainer",
|
||||||
|
"WebWiresharkStatistics",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -161,3 +161,11 @@ class AutoIdlePC(BaseModel):
|
|||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
json_schema_extra={"example": {"platform": "c7200", "image": "/path/to/c7200_image.bin", "ram": 256}}
|
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")
|
||||||
|
|||||||
@ -55,3 +55,24 @@ class GNS3VM(BaseModel):
|
|||||||
vcpus: Optional[int] = Field(None, description="Number of CPUs to allocate for the GNS3 VM")
|
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")
|
ram: Optional[int] = Field(None, description="Amount of memory to allocate for the GNS3 VM")
|
||||||
port: Optional[int] = Field(None, gt=0, le=65535)
|
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")
|
||||||
|
|||||||
@ -16,7 +16,8 @@
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import Enum
|
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
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
@ -100,3 +101,32 @@ class ImageCompatibility(BaseModel):
|
|||||||
class ImageCompatibilityCatalog(BaseModel):
|
class ImageCompatibilityCatalog(BaseModel):
|
||||||
image_sizes: list[int]
|
image_sizes: list[int]
|
||||||
has_unknown_sizes: bool
|
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]
|
||||||
|
|||||||
@ -274,3 +274,81 @@ class MarkerDefinitionCreate(BaseModel):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def _both_to_none(cls, v):
|
def _both_to_none(cls, v):
|
||||||
return None if v == "both" else 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]
|
||||||
|
|||||||
114
gns3server/schemas/controller/marker_replay.py
Normal file
114
gns3server/schemas/controller/marker_replay.py
Normal file
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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]
|
||||||
@ -126,3 +126,15 @@ class ProjectCompression(str, Enum):
|
|||||||
bzip2 = "bzip2"
|
bzip2 = "bzip2"
|
||||||
lzma = "lzma"
|
lzma = "lzma"
|
||||||
zstd = "zstd"
|
zstd = "zstd"
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectStats(BaseModel):
|
||||||
|
"""
|
||||||
|
Project object counts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
nodes: int
|
||||||
|
links: int
|
||||||
|
drawings: int
|
||||||
|
snapshots: int
|
||||||
|
markers: int
|
||||||
|
|||||||
@ -109,3 +109,13 @@ class Role(DateTimeModelMixin, RoleBase):
|
|||||||
is_builtin: bool
|
is_builtin: bool
|
||||||
privileges: List[Privilege]
|
privileges: List[Privilege]
|
||||||
model_config = ConfigDict(from_attributes=True)
|
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")
|
||||||
|
|||||||
131
gns3server/schemas/controller/statistics.py
Normal file
131
gns3server/schemas/controller/statistics.py
Normal file
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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
|
||||||
37
gns3server/schemas/controller/symbols.py
Normal file
37
gns3server/schemas/controller/symbols.py
Normal file
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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
|
||||||
@ -15,6 +15,7 @@
|
|||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
@ -41,3 +42,30 @@ class ApiKeyCreate(BaseModel):
|
|||||||
"""Schema for creating a new API key."""
|
"""Schema for creating a new API key."""
|
||||||
|
|
||||||
name: str
|
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
|
||||||
|
|||||||
@ -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.
|
project-level definitions, and the project-wide aggregation view.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import DEFAULT
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import FastAPI, status
|
from fastapi import FastAPI, status
|
||||||
from httpx import AsyncClient
|
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:
|
class TestMarkerRoutes:
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# Per-link markers
|
# Per-link markers
|
||||||
@ -53,13 +74,25 @@ class TestMarkerRoutes:
|
|||||||
link = UDPLink(project)
|
link = UDPLink(project)
|
||||||
project._links = {link.id: link}
|
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(
|
response = await client.post(
|
||||||
app.url_path_for("create_marker", project_id=project.id, link_id=link.id),
|
app.url_path_for("create_marker", project_id=project.id, link_id=link.id),
|
||||||
json={"bpf": "icmp", "tag": 3, "color": "#ff5722", "highlight_duration": 800},
|
json={"bpf": "icmp", "tag": 3, "color": "#ff5722", "highlight_duration": 800},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_201_CREATED
|
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()
|
mock.assert_called_once()
|
||||||
_, kwargs = mock.call_args
|
_, kwargs = mock.call_args
|
||||||
assert kwargs["bpf"] == "icmp"
|
assert kwargs["bpf"] == "icmp"
|
||||||
@ -73,12 +106,15 @@ class TestMarkerRoutes:
|
|||||||
link = UDPLink(project)
|
link = UDPLink(project)
|
||||||
project._links = {link.id: link}
|
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(
|
response = await client.post(
|
||||||
app.url_path_for("create_marker", project_id=project.id, link_id=link.id),
|
app.url_path_for("create_marker", project_id=project.id, link_id=link.id),
|
||||||
json={"name": "web", "bpf": "tcp port 80"},
|
json={"name": "web", "bpf": "tcp port 80"},
|
||||||
)
|
)
|
||||||
assert response.status_code == status.HTTP_201_CREATED
|
assert response.status_code == status.HTTP_201_CREATED
|
||||||
|
assert response.json()["bpf"] == "tcp port 80"
|
||||||
_, kwargs = mock.call_args
|
_, kwargs = mock.call_args
|
||||||
assert kwargs["name"] == "web"
|
assert kwargs["name"] == "web"
|
||||||
|
|
||||||
@ -141,6 +177,16 @@ class TestMarkerRoutes:
|
|||||||
async def test_update_marker(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
|
async def test_update_marker(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
|
||||||
|
|
||||||
link = UDPLink(project)
|
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}
|
project._links = {link.id: link}
|
||||||
|
|
||||||
with asyncio_patch("gns3server.controller.udp_link.UDPLink.update_marker") as mock:
|
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},
|
json={"bpf": "udp port 53", "highlight_duration": 1500},
|
||||||
)
|
)
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.json() == link._markers["web"]
|
||||||
_, kwargs = mock.call_args
|
_, kwargs = mock.call_args
|
||||||
assert kwargs["bpf"] == "udp port 53"
|
assert kwargs["bpf"] == "udp port 53"
|
||||||
assert kwargs["highlight_duration"] == 1500
|
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:
|
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(
|
response = await client.post(
|
||||||
app.url_path_for("create_marker_definition", project_id=project.id),
|
app.url_path_for("create_marker_definition", project_id=project.id),
|
||||||
json={"name": "arp", "bpf": "arp", "highlight_duration": 1200},
|
json={"name": "arp", "bpf": "arp", "highlight_duration": 1200},
|
||||||
)
|
)
|
||||||
assert response.status_code == status.HTTP_201_CREATED
|
assert response.status_code == status.HTTP_201_CREATED
|
||||||
|
assert response.json() == project._marker_definitions["arp"]
|
||||||
_, kwargs = mock.call_args
|
_, kwargs = mock.call_args
|
||||||
assert kwargs["name"] == "arp"
|
assert kwargs["name"] == "arp"
|
||||||
assert kwargs["bpf"] == "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:
|
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:
|
with asyncio_patch("gns3server.controller.project.Project.update_marker_definition") as mock:
|
||||||
response = await client.put(
|
response = await client.put(
|
||||||
app.url_path_for("update_marker_definition", project_id=project.id, def_name="arp"),
|
app.url_path_for("update_marker_definition", project_id=project.id, def_name="arp"),
|
||||||
json={"bpf": "arp or rarp", "highlight_duration": 900},
|
json={"bpf": "arp or rarp", "highlight_duration": 900},
|
||||||
)
|
)
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.json() == {"bpf": "arp or rarp", "tag": None, "highlight_duration": 900}
|
||||||
_, kwargs = mock.call_args
|
_, kwargs = mock.call_args
|
||||||
assert kwargs["bpf"] == "arp or rarp"
|
assert kwargs["bpf"] == "arp or rarp"
|
||||||
assert kwargs["highlight_duration"] == 900
|
assert kwargs["highlight_duration"] == 900
|
||||||
|
|||||||
464
tests/api/routes/controller/test_response_models.py
Normal file
464
tests/api/routes/controller/test_response_models.py
Normal file
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""
|
||||||
|
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": [],
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user