From 8b4c3a3517e90178d2d1ff0b13e82744fddafbce Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 29 Mar 2026 23:49:36 +0800 Subject: [PATCH 1/6] feat(controller): extend /statistics API with project, node and link stats Return aggregated statistics: - computes: per-compute CPU/memory/disk stats (existing) - projects: total, opened, closed counts - nodes: total count with breakdown by type and status - links: total count with capturing count --- .../api/routes/controller/controller.py | 56 +++++++++++++++++-- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/gns3server/api/routes/controller/controller.py b/gns3server/api/routes/controller/controller.py index bc0a249f8..9952ecd6d 100644 --- a/gns3server/api/routes/controller/controller.py +++ b/gns3server/api/routes/controller/controller.py @@ -24,7 +24,7 @@ from fastapi.encoders import jsonable_encoder from fastapi.routing import Mount from websockets.exceptions import ConnectionClosed, WebSocketException -from typing import List +from typing import List, Dict from gns3server.config import Config from gns3server.controller import Controller @@ -162,19 +162,65 @@ async def update_iou_license(iou_license: schemas.IOULicense) -> schemas.IOULice @router.get("/statistics", dependencies=[Depends(get_current_active_user)]) -async def statistics() -> List[dict]: +async def statistics() -> dict: """ - Return server statistics. + Return server statistics including compute resources, projects, and nodes. """ + controller = Controller.instance() + + # Compute statistics (existing behavior) compute_statistics = [] - for compute in list(Controller.instance().computes.values()): + for compute in list(controller.computes.values()): try: r = await compute.get("/statistics") compute_statistics.append({"compute_id": compute.id, "compute_name": compute.name, "statistics": r.json}) except ControllerError as e: log.error(f"Could not retrieve statistics on compute {compute.name}: {e}") - return compute_statistics + + # Project statistics + projects = list(controller.projects.values()) + project_stats = { + "total": len(projects), + "opened": sum(1 for p in projects if p.status == "opened"), + "closed": sum(1 for p in projects if p.status == "closed"), + } + + # Node statistics + all_nodes = [] + for project in projects: + all_nodes.extend(project.nodes.values()) + + node_by_type = {} + node_by_status = {} + for node in all_nodes: + node_by_type[node.node_type] = node_by_type.get(node.node_type, 0) + 1 + node_by_status[node.status] = node_by_status.get(node.status, 0) + 1 + + node_stats = { + "total": len(all_nodes), + "by_type": node_by_type, + "by_status": node_by_status, + } + + # Link statistics + all_links = [] + for project in projects: + all_links.extend(project.links.values()) + + link_capturing = sum(1 for link in all_links if getattr(link, "capturing", False)) + + link_stats = { + "total": len(all_links), + "capturing": link_capturing, + } + + return { + "computes": compute_statistics, + "projects": project_stats, + "nodes": node_stats, + "links": link_stats, + } @router.get("/notifications", dependencies=[Depends(get_current_active_user)]) From b4caffe13b30c9429bc149d52dd0622715999e60 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 29 Mar 2026 23:59:17 +0800 Subject: [PATCH 2/6] fix(statistics): handle closed projects where nodes/links are dicts Open projects store Node/Link objects, but closed projects store dictionaries loaded from topology JSON. This fix checks the type before accessing attributes. --- .../api/routes/controller/controller.py | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/gns3server/api/routes/controller/controller.py b/gns3server/api/routes/controller/controller.py index 9952ecd6d..9878633f8 100644 --- a/gns3server/api/routes/controller/controller.py +++ b/gns3server/api/routes/controller/controller.py @@ -194,8 +194,15 @@ async def statistics() -> dict: node_by_type = {} node_by_status = {} for node in all_nodes: - node_by_type[node.node_type] = node_by_type.get(node.node_type, 0) + 1 - node_by_status[node.status] = node_by_status.get(node.status, 0) + 1 + # Handle both Node objects (open project) and dicts (closed project) + if isinstance(node, dict): + node_type = node.get("node_type", "unknown") + node_status = node.get("status", "unknown") + else: + node_type = getattr(node, "node_type", "unknown") + node_status = getattr(node, "status", "unknown") + node_by_type[node_type] = node_by_type.get(node_type, 0) + 1 + node_by_status[node_status] = node_by_status.get(node_status, 0) + 1 node_stats = { "total": len(all_nodes), @@ -208,7 +215,14 @@ async def statistics() -> dict: for project in projects: all_links.extend(project.links.values()) - link_capturing = sum(1 for link in all_links if getattr(link, "capturing", False)) + def is_capturing(link): + if hasattr(link, "capturing"): + return link.capturing + if isinstance(link, dict): + return link.get("capturing", False) + return False + + link_capturing = sum(1 for link in all_links if is_capturing(link)) link_stats = { "total": len(all_links), From 33fdfd65baff8255483bcbce736aafe86d71bbe3 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 30 Mar 2026 00:06:50 +0800 Subject: [PATCH 3/6] feat(statistics): distinguish open vs closed project nodes Closed project nodes don't have status info in topology JSON. Add open_project_nodes and closed_project_nodes counts to help clarify why by_status may be empty. --- .../api/routes/controller/controller.py | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/gns3server/api/routes/controller/controller.py b/gns3server/api/routes/controller/controller.py index 9878633f8..58b89f2ea 100644 --- a/gns3server/api/routes/controller/controller.py +++ b/gns3server/api/routes/controller/controller.py @@ -186,26 +186,38 @@ async def statistics() -> dict: "closed": sum(1 for p in projects if p.status == "closed"), } - # Node statistics - all_nodes = [] - for project in projects: - all_nodes.extend(project.nodes.values()) - + # Node statistics - distinguish open vs closed project nodes + open_project_nodes = [] + closed_project_nodes = [] node_by_type = {} node_by_status = {} - for node in all_nodes: - # Handle both Node objects (open project) and dicts (closed project) - if isinstance(node, dict): - node_type = node.get("node_type", "unknown") - node_status = node.get("status", "unknown") + + for project in projects: + nodes = project.nodes.values() + if project.status == "closed": + closed_project_nodes.extend(nodes) else: - node_type = getattr(node, "node_type", "unknown") - node_status = getattr(node, "status", "unknown") + open_project_nodes.extend(nodes) + + # Open project nodes have real status + for node in open_project_nodes: + node_type = getattr(node, "node_type", "unknown") + node_status = getattr(node, "status", "unknown") node_by_type[node_type] = node_by_type.get(node_type, 0) + 1 node_by_status[node_status] = node_by_status.get(node_status, 0) + 1 + # Closed project nodes don't have status, count them separately + for node in closed_project_nodes: + if isinstance(node, dict): + node_type = node.get("node_type", "unknown") + else: + node_type = getattr(node, "node_type", "unknown") + node_by_type[node_type] = node_by_type.get(node_type, 0) + 1 + node_stats = { - "total": len(all_nodes), + "total": len(open_project_nodes) + len(closed_project_nodes), + "open_project_nodes": len(open_project_nodes), + "closed_project_nodes": len(closed_project_nodes), "by_type": node_by_type, "by_status": node_by_status, } From b379747aeb6ba5dfb6a333791c472fcd700210ea Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 30 Mar 2026 00:09:09 +0800 Subject: [PATCH 4/6] docs: add statistics API documentation Document the extended /statistics endpoint with complete response schema and field descriptions. --- docs/features/statistics-api.md | 143 ++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 docs/features/statistics-api.md diff --git a/docs/features/statistics-api.md b/docs/features/statistics-api.md new file mode 100644 index 000000000..f7a6eb5b3 --- /dev/null +++ b/docs/features/statistics-api.md @@ -0,0 +1,143 @@ +# Statistics API + +## `GET /statistics` + +Returns aggregated server statistics including compute resources, projects, nodes, and links. + +**Authentication:** Requires active user session + +**Method:** `GET` + +**URL:** `http://server:3080/v1/statistics` + +### Response + +```json +{ + "computes": [ + { + "compute_id": "string", + "compute_name": "string", + "statistics": { + "memory_total": 16777216000, + "memory_free": 8000000000, + "memory_used": 8777216000, + "swap_total": 2147479552, + "swap_free": 1500000000, + "swap_used": 647279552, + "cpu_usage_percent": 45, + "memory_usage_percent": 52, + "swap_usage_percent": 30, + "disk_usage_percent": 67, + "load_average_percent": [12, 8, 5] + } + } + ], + "projects": { + "total": 5, + "opened": 3, + "closed": 2 + }, + "nodes": { + "total": 42, + "open_project_nodes": 30, + "closed_project_nodes": 12, + "by_type": { + "qemu": 20, + "docker": 12, + "dynamips": 6, + "vpcs": 4 + }, + "by_status": { + "started": 25, + "stopped": 12, + "suspended": 5 + } + }, + "links": { + "total": 38, + "capturing": 2 + } +} +``` + +### Field Descriptions + +#### `computes` + +Array of compute node statistics. Each compute reports: + +| Field | Type | Description | +|-------|------|-------------| +| `compute_id` | string | Unique identifier for the compute | +| `compute_name` | string | Human-readable name | +| `statistics` | object | Resource usage statistics | + +#### `computes[].statistics` + +| Field | Type | Description | +|-------|------|-------------| +| `memory_total` | integer | Total physical memory in bytes | +| `memory_free` | integer | Free memory in bytes | +| `memory_used` | integer | Used memory in bytes | +| `swap_total` | integer | Total swap space in bytes | +| `swap_free` | integer | Free swap space in bytes | +| `swap_used` | integer | Used swap space in bytes | +| `cpu_usage_percent` | integer | CPU usage percentage (0-100) | +| `memory_usage_percent` | integer | Memory usage percentage (0-100) | +| `swap_usage_percent` | integer | Swap usage percentage (0-100) | +| `disk_usage_percent` | integer | Disk usage percentage for project directory (0-100) | +| `load_average_percent` | integer[] | Load average as percentage per CPU core (1/5/15 min) | + +#### `projects` + +| Field | Type | Description | +|-------|------|-------------| +| `total` | integer | Total number of projects | +| `opened` | integer | Number of projects currently opened | +| `closed` | integer | Number of projects currently closed | + +#### `nodes` + +| Field | Type | Description | +|-------|------|-------------| +| `total` | integer | Total number of nodes across all projects | +| `open_project_nodes` | integer | Nodes in opened projects (has real status) | +| `closed_project_nodes` | integer | Nodes in closed projects (loaded from topology JSON, no status) | +| `by_type` | object | Node count grouped by node type (qemu, docker, dynamips, etc.) | +| `by_status` | object | Node count grouped by status (only for `open_project_nodes`) | + +**Note on `by_status`:** Status is a runtime attribute only available for nodes in opened projects. Closed projects store topology data in JSON format which does not include runtime status. Therefore `by_status` only reflects nodes from opened projects. + +Valid node statuses: `started`, `stopped`, `suspended` + +#### `links` + +| Field | Type | Description | +|-------|------|-------------| +| `total` | integer | Total number of links across all projects | +| `capturing` | integer | Number of links currently capturing packets | + +### Example Usage + +```bash +# Get statistics +curl -X GET http://localhost:3080/v1/statistics \ + -H "Authorization: Bearer " +``` + +### Dashboard Integration + +This API is designed for monitoring dashboards that need: + +- **System health**: CPU, memory, disk from `computes[].statistics` +- **Project overview**: Project counts from `projects` +- **Node inventory**: Node counts by type and status from `nodes` +- **Capture monitoring**: Active capture sessions from `links.capturing` + +### Error Responses + +| Status | Description | +|--------|-------------| +| 401 | Unauthorized - invalid or missing session | +| 500 | Internal server error | From d64e4e28e37edea923e754f63d3832532a4b0677 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 30 Mar 2026 00:23:35 +0800 Subject: [PATCH 5/6] docs: add future optimization for per-compute node statistics --- docs/features/statistics-api.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/features/statistics-api.md b/docs/features/statistics-api.md index f7a6eb5b3..d992b96c0 100644 --- a/docs/features/statistics-api.md +++ b/docs/features/statistics-api.md @@ -141,3 +141,36 @@ This API is designed for monitoring dashboards that need: |--------|-------------| | 401 | Unauthorized - invalid or missing session | | 500 | Internal server error | + +### Future Optimizations + +#### Per-Compose Node Statistics + +Currently `nodes` are aggregated globally. Future enhancement could add per-compute breakdown: + +```json +"nodes": { + "total": 42, + "by_compute": { + "local": { + "total": 30, + "open_project_nodes": 20, + "closed_project_nodes": 10, + "by_type": { "qemu": 20, "docker": 10 } + }, + "remote-server-1": { + "total": 12, + "open_project_nodes": 10, + "closed_project_nodes": 2, + "by_type": { "docker": 12 } + } + }, + "by_type": { "qemu": 20, "docker": 22 }, + "open_project_nodes": 30, + "closed_project_nodes": 12, + "by_type": { "qemu": 20, "docker": 22 }, + "by_status": { "started": 25, "stopped": 12, "suspended": 5 } +} +``` + +This requires tracking which compute each node runs on (Node._compute). From 050b7a80ab8da04997ac09f6af6b8b6de1285efc Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 1 Apr 2026 02:08:18 +0800 Subject: [PATCH 6/6] docs: add Controller + Compute setup guide Co-Authored-By: Claude Opus 4.6 --- docs/features/compute-controller-setup.md | 96 +++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 docs/features/compute-controller-setup.md diff --git a/docs/features/compute-controller-setup.md b/docs/features/compute-controller-setup.md new file mode 100644 index 000000000..59dfff39e --- /dev/null +++ b/docs/features/compute-controller-setup.md @@ -0,0 +1,96 @@ +# Controller + Compute Setup + +This document describes the minimum configuration required to set up a GNS3 Controller with remote Compute nodes. + +## Architecture Overview + +- **Compute**: Runs individual nodes (QEMU, Docker, etc.) and provides resource monitoring +- **Controller**: Manages multiple computes, projects, and provides the REST API +- **Database**: Controller uses SQLite to store projects, nodes, and compute registration + +## Minimum Configuration + +### 1. Compute Node Configuration + +Create the configuration file at `~/.config/GNS3/3.1/gns3_server.conf`: + +```ini +[Server] +host = 0.0.0.0 +port = 3080 +compute_username = gns3 +compute_password = gns3 +``` + +Start the Compute: +```bash +gns3server +``` + +### 2. Controller Node Configuration + +Create the configuration file at `~/.config/GNS3/3.1/gns3_server.conf`: + +```ini +[Server] +host = 192.168.1.140 +port = 3080 +compute_username = gns3 +compute_password = gns3 +``` + +Start the Controller: +```bash +gns3server +``` + +### 3. Register Compute with Controller + +Use the API to register a remote compute: + +```bash +POST /v3/computes +{ + "protocol": "http", + "host": "192.168.1.x", + "port": 3080, + "user": "gns3", + "password": "gns3" +} +``` + +## Important Notes + +### Host Configuration + +- **Controller `host`**: If set to `0.0.0.0`, it will be changed to `127.0.0.1`, which breaks cross-subnet link creation +- **Solution**: Always use the actual IP address for Controller's `host` field + +### Password Configuration + +- If `compute_password` is not set, a random 16-character password is auto-generated on startup +- The Controller must use the same credentials as the Compute's configuration + +### Network Requirements + +- Controller and Compute must be on the same LAN for cross-compute links to work +- UDP tunnel is used for cross-compute links, requiring network connectivity on UDP ports + +### Configuration File Location + +- Default location: `~/.config/GNS3/3.1/gns3_server.conf` +- Version `3.0` uses `~/.config/GNS3/3.0/` + +## Troubleshooting + +### 401 Unauthorized on Compute Connect + +1. Check that Compute's `compute_username` and `compute_password` match what was passed to the API +2. Verify the Compute's configuration file is correctly loaded +3. Ensure the `[Server]` section is used (not `[Controller]`) + +### No Common Subnet Error + +1. Verify Controller's `host` is set to its actual IP, not `0.0.0.0` +2. Ensure both machines are on the same network +3. Check firewall rules allow UDP communication