mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-03 05:51:15 +02:00
Merge branch '3.1' into mypy-agent-copilot-tools-nornir
This commit is contained in:
24 files changed
+208
-187
No files matched your search
@@ -140,7 +140,7 @@ def estimate_tool_tokens(tools: list[Any]) -> int:
|
||||
for tool in tools:
|
||||
try:
|
||||
# Build tool schema in OpenAI format
|
||||
tool_schema = {
|
||||
tool_schema: dict[str, Any] = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool.name,
|
||||
|
||||
@@ -110,7 +110,7 @@ def create_base_model(
|
||||
|
||||
try:
|
||||
# Prepare parameters for init_chat_model
|
||||
init_params = {
|
||||
init_params: dict[str, Any] = {
|
||||
"model": config_vars["model_name"],
|
||||
"model_provider": config_vars["model_provider"],
|
||||
"api_key": config_vars["api_key"],
|
||||
@@ -177,7 +177,7 @@ def create_title_model(
|
||||
|
||||
try:
|
||||
# Prepare parameters for init_chat_model
|
||||
init_params = {
|
||||
init_params: dict[str, Any] = {
|
||||
"model": config_vars["model_name"],
|
||||
"model_provider": config_vars["model_provider"],
|
||||
"api_key": config_vars["api_key"],
|
||||
|
||||
@@ -256,17 +256,16 @@ class AgentService:
|
||||
)
|
||||
|
||||
# Ensure checkpointer is initialized
|
||||
if not self._checkpointer_conn:
|
||||
await self._get_checkpointer()
|
||||
checkpointer = await self._get_checkpointer()
|
||||
|
||||
# Get or create chat session
|
||||
repo = ChatSessionsRepository(self._checkpointer_conn)
|
||||
repo = ChatSessionsRepository(checkpointer.conn)
|
||||
session = await repo.get_session_by_thread(session_id)
|
||||
is_new_session = session is None
|
||||
|
||||
if is_new_session:
|
||||
# Create new session
|
||||
copilot_mode = llm_config.get("copilot_mode", "teaching_assistant").lower()
|
||||
copilot_mode = (llm_config or {}).get("copilot_mode", "teaching_assistant").lower()
|
||||
session = await repo.create_session(
|
||||
thread_id=session_id,
|
||||
user_id=user_id or "",
|
||||
@@ -290,7 +289,7 @@ class AgentService:
|
||||
|
||||
# Build config - only thread-safe identifiers
|
||||
# Determine recursion_limit based on copilot_mode
|
||||
copilot_mode = llm_config.get("copilot_mode", "teaching_assistant").lower()
|
||||
copilot_mode = (llm_config or {}).get("copilot_mode", "teaching_assistant").lower()
|
||||
if copilot_mode == "troubleshooting_injection":
|
||||
recursion_limit = 100 # Need more recursion depth for fault injection workflow
|
||||
log.debug("Using extended recursion_limit for troubleshooting_injection mode: 100")
|
||||
@@ -453,10 +452,10 @@ class AgentService:
|
||||
yield chunk
|
||||
else:
|
||||
# Use stateless converter for other events
|
||||
chunk = self._convert_event_to_chunk(event, session_id)
|
||||
if chunk:
|
||||
log.debug("Yielding chunk: type=%s", chunk.get("type"))
|
||||
yield chunk
|
||||
converted = self._convert_event_to_chunk(event, session_id)
|
||||
if converted:
|
||||
log.debug("Yielding chunk: type=%s", converted.get("type"))
|
||||
yield converted
|
||||
|
||||
# Check if stream was aborted and yield tool_end events for aborted tools
|
||||
from gns3server.agent.gns3_copilot.agent.gns3_copilot import (
|
||||
@@ -624,10 +623,9 @@ class AgentService:
|
||||
Returns:
|
||||
List of session dictionaries
|
||||
"""
|
||||
if not self._checkpointer_conn:
|
||||
await self._get_checkpointer()
|
||||
checkpointer = await self._get_checkpointer()
|
||||
|
||||
repo = ChatSessionsRepository(self._checkpointer_conn)
|
||||
repo = ChatSessionsRepository(checkpointer.conn)
|
||||
sessions = await repo.list_sessions(user_id=user_id, copilot_mode=copilot_mode, limit=limit)
|
||||
return [s.to_dict() for s in sessions]
|
||||
|
||||
@@ -641,10 +639,9 @@ class AgentService:
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
if not self._checkpointer_conn:
|
||||
await self._get_checkpointer()
|
||||
checkpointer = await self._get_checkpointer()
|
||||
|
||||
repo = ChatSessionsRepository(self._checkpointer_conn)
|
||||
repo = ChatSessionsRepository(checkpointer.conn)
|
||||
return await repo.delete_session(session_id)
|
||||
|
||||
async def rename_session(self, session_id: str, new_title: str) -> Optional[Dict[str, Any]]:
|
||||
@@ -658,10 +655,9 @@ class AgentService:
|
||||
Returns:
|
||||
Updated session dictionary or None
|
||||
"""
|
||||
if not self._checkpointer_conn:
|
||||
await self._get_checkpointer()
|
||||
checkpointer = await self._get_checkpointer()
|
||||
|
||||
repo = ChatSessionsRepository(self._checkpointer_conn)
|
||||
repo = ChatSessionsRepository(checkpointer.conn)
|
||||
session = await repo.update_session(thread_id=session_id, title=new_title)
|
||||
return session.to_dict() if session else None
|
||||
|
||||
@@ -676,10 +672,9 @@ class AgentService:
|
||||
Returns:
|
||||
Updated session dictionary or None
|
||||
"""
|
||||
if not self._checkpointer_conn:
|
||||
await self._get_checkpointer()
|
||||
checkpointer = await self._get_checkpointer()
|
||||
|
||||
repo = ChatSessionsRepository(self._checkpointer_conn)
|
||||
repo = ChatSessionsRepository(checkpointer.conn)
|
||||
session = await repo.pin_session(thread_id=session_id, pinned=pinned)
|
||||
return session.to_dict() if session else None
|
||||
|
||||
|
||||
@@ -347,7 +347,7 @@ def get_gns3_server_host() -> str:
|
||||
host = get_gns3_server_host()
|
||||
print(f"GNS3 server host: {host}")
|
||||
"""
|
||||
url = _detect_url_for_api()
|
||||
url = _detect_url_for_api() or DEFAULT_GNS3_URL
|
||||
|
||||
# Extract host from URL
|
||||
# URL format: protocol://host:port
|
||||
|
||||
@@ -49,7 +49,7 @@ def build_nodes_inventory(nodes: list[dict[str, Any]], server_host: str | None)
|
||||
"""
|
||||
inventory: dict[str, Any] = {}
|
||||
for n in nodes:
|
||||
inventory[n.get("name")] = {
|
||||
inventory[n.get("name") or ""] = {
|
||||
"server": server_host,
|
||||
"name": n.get("name"),
|
||||
"node_id": n.get("node_id"),
|
||||
@@ -68,7 +68,7 @@ def build_nodes_inventory(nodes: list[dict[str, Any]], server_host: str | None)
|
||||
return inventory
|
||||
|
||||
|
||||
def build_links_summary(nodes: list[dict[str, Any]], links: list[dict[str, Any]]) -> list[dict[str, str]]:
|
||||
def build_links_summary(nodes: list[dict[str, Any]], links: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Build a human/LLM-friendly link list resolving node and port names.
|
||||
|
||||
@@ -76,7 +76,7 @@ def build_links_summary(nodes: list[dict[str, Any]], links: list[dict[str, Any]]
|
||||
Links whose endpoints cannot be resolved are skipped, mirroring the
|
||||
original Project.links_summary behavior.
|
||||
"""
|
||||
summary: list[dict[str, str]] = []
|
||||
summary: list[dict[str, Any]] = []
|
||||
for link in links:
|
||||
if not link.get("nodes"):
|
||||
continue
|
||||
|
||||
@@ -248,7 +248,7 @@ class SkillsLoader:
|
||||
continue
|
||||
# Use device_type from YAML content as the key
|
||||
# Fallback to filename stem if device_type not present
|
||||
skill_key = skill_data.get("device_type") if isinstance(skill_data, dict) else None
|
||||
skill_key = skill_data.get("device_type")
|
||||
if not skill_key:
|
||||
skill_key = yaml_file.stem
|
||||
logger.warning(f"No device_type in {yaml_file}, using filename '{skill_key}' as key")
|
||||
|
||||
@@ -37,8 +37,10 @@ from typing import Optional, Dict, Any
|
||||
|
||||
try:
|
||||
import git
|
||||
|
||||
GIT_AVAILABLE = True
|
||||
except ImportError:
|
||||
git = None
|
||||
GIT_AVAILABLE = False
|
||||
|
||||
from gns3server.config import Config
|
||||
from .loader import SkillsLoader
|
||||
@@ -66,7 +68,7 @@ class SkillsManager:
|
||||
- Version tracking
|
||||
"""
|
||||
|
||||
def __init__(self, repo_url: str = None, branch: str = "main", auto_update: bool = False):
|
||||
def __init__(self, repo_url: Optional[str] = None, branch: str = "main", auto_update: bool = False):
|
||||
"""
|
||||
Initialize the skills manager.
|
||||
|
||||
@@ -90,7 +92,7 @@ class SkillsManager:
|
||||
self._repo: Optional["git.Repo"] = None
|
||||
self._prompt_count = 0
|
||||
|
||||
if git is None:
|
||||
if not GIT_AVAILABLE:
|
||||
logger.warning("GitPython is not installed. Skills management features will be limited.")
|
||||
|
||||
def initialize(self) -> bool:
|
||||
@@ -134,7 +136,7 @@ class SkillsManager:
|
||||
- Up to date → nothing
|
||||
- Network error → use existing files, log warning
|
||||
"""
|
||||
if git is None or self._repo is None:
|
||||
if not GIT_AVAILABLE or self._repo is None:
|
||||
return
|
||||
|
||||
# Check for uncommitted changes
|
||||
@@ -173,7 +175,7 @@ class SkillsManager:
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if git is None:
|
||||
if not GIT_AVAILABLE:
|
||||
logger.error("GitPython is not installed. Cannot clone repository.")
|
||||
return False
|
||||
|
||||
@@ -347,7 +349,7 @@ class SkillsManager:
|
||||
Returns:
|
||||
Commit hash as string, or empty string if not available
|
||||
"""
|
||||
if git is None or self._repo is None:
|
||||
if not GIT_AVAILABLE or self._repo is None:
|
||||
try:
|
||||
self._repo = git.Repo(self.local_path)
|
||||
except Exception:
|
||||
@@ -409,7 +411,7 @@ class SkillsManager:
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if git is None:
|
||||
if not GIT_AVAILABLE:
|
||||
logger.error("GitPython is not installed. Cannot rollback.")
|
||||
return False
|
||||
|
||||
@@ -436,7 +438,7 @@ class SkillsManager:
|
||||
Returns:
|
||||
List of commit information dictionaries
|
||||
"""
|
||||
if git is None:
|
||||
if not GIT_AVAILABLE:
|
||||
return []
|
||||
|
||||
try:
|
||||
|
||||
@@ -405,22 +405,24 @@ async def project_stats(
|
||||
@mcp.tool()
|
||||
async def project_update(
|
||||
project_id: Annotated[str, Field(description="UUID of the project to update")],
|
||||
name: Annotated[str, Field(description="New project name")] = None,
|
||||
auto_close: Annotated[bool, Field(description="Close project when last client leaves")] = None,
|
||||
auto_open: Annotated[bool, Field(description="Project opens when GNS3 starts")] = None,
|
||||
auto_start: Annotated[bool, Field(description="Project starts when opened")] = None,
|
||||
scene_width: Annotated[int, Field(description="Width of the drawing area")] = None,
|
||||
scene_height: Annotated[int, Field(description="Height of the drawing area")] = None,
|
||||
zoom: Annotated[int, Field(description="Zoom of the drawing area")] = None,
|
||||
show_layers: Annotated[bool, Field(description="Show layers on the drawing area")] = None,
|
||||
snap_to_grid: Annotated[bool, Field(description="Snap to grid on the drawing area")] = None,
|
||||
show_grid: Annotated[bool, Field(description="Show the grid on the drawing area")] = None,
|
||||
grid_size: Annotated[int, Field(description="Grid size for the drawing area for nodes")] = None,
|
||||
drawing_grid_size: Annotated[int, Field(description="Grid size for the drawing area for drawings")] = None,
|
||||
show_interface_labels: Annotated[bool, Field(description="Show interface labels on the drawing area")] = None,
|
||||
name: Annotated[str | None, Field(description="New project name")] = None,
|
||||
auto_close: Annotated[bool | None, Field(description="Close project when last client leaves")] = None,
|
||||
auto_open: Annotated[bool | None, Field(description="Project opens when GNS3 starts")] = None,
|
||||
auto_start: Annotated[bool | None, Field(description="Project starts when opened")] = None,
|
||||
scene_width: Annotated[int | None, Field(description="Width of the drawing area")] = None,
|
||||
scene_height: Annotated[int | None, Field(description="Height of the drawing area")] = None,
|
||||
zoom: Annotated[int | None, Field(description="Zoom of the drawing area")] = None,
|
||||
show_layers: Annotated[bool | None, Field(description="Show layers on the drawing area")] = None,
|
||||
snap_to_grid: Annotated[bool | None, Field(description="Snap to grid on the drawing area")] = None,
|
||||
show_grid: Annotated[bool | None, Field(description="Show the grid on the drawing area")] = None,
|
||||
grid_size: Annotated[int | None, Field(description="Grid size for the drawing area for nodes")] = None,
|
||||
drawing_grid_size: Annotated[int | None, Field(description="Grid size for the drawing area for drawings")] = None,
|
||||
show_interface_labels: Annotated[
|
||||
bool | None, Field(description="Show interface labels on the drawing area")
|
||||
] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Update a project's properties (name, auto_close, auto_open, etc.)."""
|
||||
params = {"project_id": project_id}
|
||||
params: dict[str, Any] = {"project_id": project_id}
|
||||
local_vars = {
|
||||
"name": name,
|
||||
"auto_close": auto_close,
|
||||
@@ -449,7 +451,7 @@ async def project_duplicate(
|
||||
reset_mac_addresses: Annotated[bool, Field(description="Reset MAC addresses for this project")] = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Duplicate a project."""
|
||||
params = {"project_id": project_id, "name": name}
|
||||
params: dict[str, Any] = {"project_id": project_id, "name": name}
|
||||
if reset_mac_addresses:
|
||||
params["reset_mac_addresses"] = reset_mac_addresses
|
||||
return await asyncio.to_thread(_run_handler_sync, duplicate_project_handler, params)
|
||||
@@ -523,7 +525,7 @@ async def node_start(
|
||||
] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Start one or more nodes. Provide node_id for single, or node_ids for batch."""
|
||||
params = {"project_id": project_id}
|
||||
params: dict[str, Any] = {"project_id": project_id}
|
||||
if node_ids:
|
||||
params["node_ids"] = node_ids
|
||||
else:
|
||||
@@ -540,7 +542,7 @@ async def node_stop(
|
||||
] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Stop one or more nodes. Provide node_id for single, or node_ids for batch."""
|
||||
params = {"project_id": project_id}
|
||||
params: dict[str, Any] = {"project_id": project_id}
|
||||
if node_ids:
|
||||
params["node_ids"] = node_ids
|
||||
else:
|
||||
@@ -557,7 +559,7 @@ async def node_suspend(
|
||||
] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Suspend one or more nodes. Provide node_id for single, or node_ids for batch."""
|
||||
params = {"project_id": project_id}
|
||||
params: dict[str, Any] = {"project_id": project_id}
|
||||
if node_ids:
|
||||
params["node_ids"] = node_ids
|
||||
else:
|
||||
@@ -635,7 +637,7 @@ async def node_delete(
|
||||
] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Delete one or more nodes from a project. Provide node_id for single, or node_ids for batch."""
|
||||
params = {"project_id": project_id}
|
||||
params: dict[str, Any] = {"project_id": project_id}
|
||||
if node_ids:
|
||||
params["node_ids"] = node_ids
|
||||
else:
|
||||
@@ -756,7 +758,7 @@ async def link_create(
|
||||
"fields": fields,
|
||||
},
|
||||
)
|
||||
params = {"project_id": project_id, "nodes": nodes, "link_type": link_type, "fields": fields}
|
||||
params: dict[str, Any] = {"project_id": project_id, "nodes": nodes, "link_type": link_type, "fields": fields}
|
||||
if filters:
|
||||
params["filters"] = filters
|
||||
return await asyncio.to_thread(_run_handler_sync, create_link_handler, params)
|
||||
@@ -771,7 +773,7 @@ async def link_delete(
|
||||
] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Delete one or more links from a project."""
|
||||
params = {"project_id": project_id}
|
||||
params: dict[str, Any] = {"project_id": project_id}
|
||||
if link_ids:
|
||||
params["link_ids"] = link_ids
|
||||
else:
|
||||
@@ -1201,7 +1203,7 @@ async def link_reset(
|
||||
while keeping the filter configuration intact. Filters are preserved but
|
||||
their internal application state resets.
|
||||
"""
|
||||
params = {"project_id": project_id}
|
||||
params: dict[str, Any] = {"project_id": project_id}
|
||||
if link_ids:
|
||||
params["link_ids"] = link_ids
|
||||
else:
|
||||
@@ -1222,7 +1224,7 @@ async def link_capture_start(
|
||||
] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Start packet capture on one or more links."""
|
||||
params = {
|
||||
params: dict[str, Any] = {
|
||||
"project_id": project_id,
|
||||
"data_link_type": data_link_type,
|
||||
"capture_file_name": capture_file_name,
|
||||
@@ -1245,7 +1247,7 @@ async def link_capture_stop(
|
||||
] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Stop packet capture on one or more links."""
|
||||
params = {"project_id": project_id}
|
||||
params: dict[str, Any] = {"project_id": project_id}
|
||||
if link_ids:
|
||||
params["link_ids"] = link_ids
|
||||
else:
|
||||
@@ -1273,7 +1275,7 @@ async def link_capture_download(
|
||||
- The ticket expires after 10 minutes: call this tool again to get a
|
||||
fresh one; do not reuse an old URL.
|
||||
"""
|
||||
params = {"project_id": project_id}
|
||||
params: dict[str, Any] = {"project_id": project_id}
|
||||
if link_ids:
|
||||
params["link_ids"] = link_ids
|
||||
else:
|
||||
@@ -1555,7 +1557,7 @@ async def drawing_update(
|
||||
rotation: Annotated[int | None, Field(description="Rotation angle in degrees, -359 to 359")] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Update a drawing's properties (svg, position, lock state, rotation, etc.)."""
|
||||
params = {"project_id": project_id, "drawing_id": drawing_id}
|
||||
params: dict[str, Any] = {"project_id": project_id, "drawing_id": drawing_id}
|
||||
local_vars = {"svg": svg, "locked": locked, "x": x, "y": y, "z": z, "rotation": rotation}
|
||||
for key, val in local_vars.items():
|
||||
if val is not None:
|
||||
|
||||
@@ -81,10 +81,7 @@ from gns3server.utils.uuid_validator import validate_uuid
|
||||
if __name__ == "__main__":
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
|
||||
|
||||
try:
|
||||
from gns3server.agent.web_wireshark.manager import WebWiresharkManager
|
||||
except ImportError:
|
||||
from manager import WebWiresharkManager
|
||||
from gns3server.agent.web_wireshark.manager import WebWiresharkManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ class WebWiresharkManager:
|
||||
logger.warning(f"Container {container_id[:12]} health check failed: {e}")
|
||||
return False
|
||||
|
||||
async def _exec_in_container(self, container_id: str, command: str, timeout: int = None) -> tuple:
|
||||
async def _exec_in_container(self, container_id: str, command: str, timeout: Optional[int] = None) -> tuple:
|
||||
"""Execute command in container with timeout.
|
||||
|
||||
Args:
|
||||
@@ -197,7 +197,7 @@ class WebWiresharkManager:
|
||||
stdout, _ = await proc.communicate()
|
||||
|
||||
# Build parent->children mapping
|
||||
children_map = {}
|
||||
children_map: dict[int, list[int]] = {}
|
||||
for line in stdout.decode().strip().split("\n"):
|
||||
parts = line.split(None, 2)
|
||||
if len(parts) < 3:
|
||||
@@ -326,7 +326,7 @@ class WebWiresharkManager:
|
||||
return
|
||||
|
||||
# Build parent->children mapping and collect process info
|
||||
children_map = {} # ppid -> [pid]
|
||||
children_map: dict[int, list[int]] = {} # ppid -> [pid]
|
||||
process_info = {} # pid -> (ppid, command)
|
||||
|
||||
for line in stdout.decode().strip().split("\n"):
|
||||
@@ -480,7 +480,7 @@ class WebWiresharkManager:
|
||||
logger.debug(f"Cannot get URL from Config: {e}")
|
||||
return None
|
||||
|
||||
async def _get_container_gateway_ip(self, container_id: str = None) -> Optional[str]:
|
||||
async def _get_container_gateway_ip(self, container_id: Optional[str] = None) -> Optional[str]:
|
||||
"""Get the Docker bridge gateway IP for container to access host.
|
||||
|
||||
Args:
|
||||
@@ -519,7 +519,7 @@ class WebWiresharkManager:
|
||||
|
||||
return None
|
||||
|
||||
async def get_container_ip(self, container_name: str, container_id: str = None) -> Optional[str]:
|
||||
async def get_container_ip(self, container_name: str, container_id: Optional[str] = None) -> Optional[str]:
|
||||
"""Get the container IP address in the wireshark network.
|
||||
|
||||
Args:
|
||||
@@ -626,7 +626,7 @@ class WebWiresharkManager:
|
||||
project_id: str,
|
||||
image: str = "gns3/web-wireshark:latest",
|
||||
memory: str = "2g",
|
||||
memory_swap: str = None,
|
||||
memory_swap: Optional[str] = None,
|
||||
cpus: float = 1.0,
|
||||
pids_limit: int = 1000,
|
||||
) -> str:
|
||||
@@ -935,10 +935,10 @@ class WebWiresharkManager:
|
||||
project_id: str,
|
||||
link_id: str,
|
||||
jwt_token: str,
|
||||
capture_stream_url: str = None,
|
||||
capture_stream_url: Optional[str] = None,
|
||||
image: str = "gns3/web-wireshark:latest",
|
||||
memory: str = "2g",
|
||||
memory_swap: str = None,
|
||||
memory_swap: Optional[str] = None,
|
||||
cpus: float = 1.0,
|
||||
pids_limit: int = 1000,
|
||||
):
|
||||
|
||||
@@ -7,7 +7,7 @@ about Web Wireshark containers and sessions.
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -30,7 +30,7 @@ async def collect_webwireshark_stats(projects: List) -> Dict:
|
||||
"""
|
||||
from .manager import WebWiresharkManager
|
||||
|
||||
stats = {"total_containers": 0, "running_containers": 0, "active_sessions": 0, "containers": []}
|
||||
stats: Dict[str, Any] = {"total_containers": 0, "running_containers": 0, "active_sessions": 0, "containers": []}
|
||||
|
||||
# Create a single manager instance and reuse it
|
||||
manager = WebWiresharkManager()
|
||||
|
||||
Reference in new issue
Block a user