diff --git a/CHANGELOG b/CHANGELOG
index c6e1d886..1ba69b21 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,5 +1,26 @@
# Change Log
+## 2.2.25 14/09/2021
+
+* Release web UI 2.2.25
+* Fix issue preventing to use custom nested symbols. Fixes #1969
+* Updated affinity symbols
+* Fix qemu-img rebase code to support Qemu 6.1. Ref https://github.com/GNS3/gns3-server/pull/1962
+* Reinstate qemu-img rebase
+* Return disk usage for partition that contains the default project directory. Fixes #1947
+* Explicitly require setuptools, utils/get_resource.py imports pkg_resources
+
+## 2.2.24 25/08/2021
+
+* Release web UI 2.2.24
+* Fix issue when searching for image with relative path. Fixes #1925
+* Fix wrong error when NAT interface is not allowed. Fixes #1943
+* Fix incorrect Qemu binary selected when importing template. Fixes https://github.com/GNS3/gns3-gui/issues/3216
+* Fix error when updating a link style. Fixes https://github.com/GNS3/gns3-gui/issues/2461
+* Some fixes for early support for Python3.10 The loop parameter has been removed from most of asyncio‘s high-level API following deprecation in Python 3.8.
+* Early support for Python3.10 Fixes #1940
+* Bump pywin32 from 300 to 301
+
## 2.2.23 05/08/2021
* Release web UI 2.2.23
diff --git a/dev-requirements.txt b/dev-requirements.txt
index d6cfcd64..5d789f00 100644
--- a/dev-requirements.txt
+++ b/dev-requirements.txt
@@ -1,8 +1,8 @@
-r requirements.txt
pytest==6.2.4
-flake8==3.9.1
+flake8==3.9.2
pytest-timeout==1.4.2
pytest-asyncio==0.15.1
-requests==2.25.1
-httpx==0.18.1
+requests==2.26.0
+httpx==0.18.2
diff --git a/gns3server/api/routes/controller/dependencies/authentication.py b/gns3server/api/routes/controller/dependencies/authentication.py
index 0af058d7..0ca08e21 100644
--- a/gns3server/api/routes/controller/dependencies/authentication.py
+++ b/gns3server/api/routes/controller/dependencies/authentication.py
@@ -61,11 +61,7 @@ async def get_current_active_user(
)
# remove the prefix (e.g. "/v3") from URL path
- match = re.search(r"^(/v[0-9]+).*", request.url.path)
- if match:
- path = request.url.path[len(match.group(1)):]
- else:
- path = request.url.path
+ path = re.sub(r"^/v[0-9]", "", request.url.path)
# special case: always authorize access to the "/users/me" endpoint
if path == "/users/me":
diff --git a/gns3server/api/routes/controller/nodes.py b/gns3server/api/routes/controller/nodes.py
index c175d583..0f28eb73 100644
--- a/gns3server/api/routes/controller/nodes.py
+++ b/gns3server/api/routes/controller/nodes.py
@@ -260,6 +260,28 @@ async def reload_node(node: Node = Depends(dep_node)) -> Response:
return Response(status_code=status.HTTP_204_NO_CONTENT)
+@router.post("/{node_id}/isolate", status_code=status.HTTP_204_NO_CONTENT)
+async def isolate_node(node: Node = Depends(dep_node)) -> Response:
+ """
+ Isolate a node (suspend all attached links).
+ """
+
+ for link in node.links:
+ await link.update_suspend(True)
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
+
+
+@router.post("/{node_id}/unisolate", status_code=status.HTTP_204_NO_CONTENT)
+async def unisolate_node(node: Node = Depends(dep_node)) -> Response:
+ """
+ Un-isolate a node (resume all attached suspended links).
+ """
+
+ for link in node.links:
+ await link.update_suspend(False)
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
+
+
@router.get("/{node_id}/links", response_model=List[schemas.Link], response_model_exclude_unset=True)
async def get_node_links(node: Node = Depends(dep_node)) -> List[schemas.Link]:
"""
diff --git a/gns3server/api/routes/controller/permissions.py b/gns3server/api/routes/controller/permissions.py
index 15d6d1be..504f2c59 100644
--- a/gns3server/api/routes/controller/permissions.py
+++ b/gns3server/api/routes/controller/permissions.py
@@ -19,13 +19,16 @@
API routes for permissions.
"""
-from fastapi import APIRouter, Depends, Response, status
+import re
+
+from fastapi import APIRouter, Depends, Response, Request, status
+from fastapi.routing import APIRoute
from uuid import UUID
from typing import List
+
from gns3server import schemas
from gns3server.controller.controller_error import (
- ControllerError,
ControllerBadRequestError,
ControllerNotFoundError,
ControllerForbiddenError,
@@ -33,6 +36,7 @@ from gns3server.controller.controller_error import (
from gns3server.db.repositories.rbac import RbacRepository
from .dependencies.database import get_repository
+from .dependencies.authentication import get_current_active_user
import logging
@@ -54,18 +58,46 @@ async def get_permissions(
@router.post("", response_model=schemas.Permission, status_code=status.HTTP_201_CREATED)
async def create_permission(
+ request: Request,
permission_create: schemas.PermissionCreate,
+ current_user: schemas.User = Depends(get_current_active_user),
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
) -> schemas.Permission:
"""
Create a new permission.
"""
- if await rbac_repo.check_permission_exists(permission_create):
- raise ControllerBadRequestError(f"Permission '{permission_create.methods} {permission_create.path} "
- f"{permission_create.action}' already exists")
+ # TODO: should we prevent having multiple permissions with same methods/path?
+ #if await rbac_repo.check_permission_exists(permission_create):
+ # raise ControllerBadRequestError(f"Permission '{permission_create.methods} {permission_create.path} "
+ # f"{permission_create.action}' already exists")
- return await rbac_repo.create_permission(permission_create)
+ for route in request.app.routes:
+ if isinstance(route, APIRoute):
+
+ # remove the prefix (e.g. "/v3") from the route path
+ route_path = re.sub(r"^/v[0-9]", "", route.path)
+ # replace route path ID parameters by an UUID regex
+ route_path = re.sub(r"{\w+_id}", "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", route_path)
+ # replace remaining route path parameters by an word matching regex
+ route_path = re.sub(r"/{[\w:]+}", r"/\\w+", route_path)
+
+ # the permission can match multiple routes
+ if permission_create.path.endswith("/*"):
+ route_path += r"/.*"
+
+ if re.fullmatch(route_path, permission_create.path):
+ for method in permission_create.methods:
+ if method in list(route.methods):
+ # check user has the right to add the permission (i.e has already to right on the path)
+ if not await rbac_repo.check_user_is_authorized(current_user.user_id, method, permission_create.path):
+ raise ControllerForbiddenError(f"User '{current_user.username}' doesn't have the rights to "
+ f"add a permission on {method} {permission_create.path} or "
+ f"the endpoint doesn't exist")
+ return await rbac_repo.create_permission(permission_create)
+
+ raise ControllerBadRequestError(f"Permission '{permission_create.methods} {permission_create.path}' "
+ f"doesn't match any existing endpoint")
@router.get("/{permission_id}", response_model=schemas.Permission)
@@ -115,7 +147,18 @@ async def delete_permission(
success = await rbac_repo.delete_permission(permission_id)
if not success:
- raise ControllerError(f"Permission '{permission_id}' could not be deleted")
+ raise ControllerNotFoundError(f"Permission '{permission_id}' could not be deleted")
return Response(status_code=status.HTTP_204_NO_CONTENT)
+
+@router.post("/prune", status_code=status.HTTP_204_NO_CONTENT)
+async def prune_permissions(
+ rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
+) -> Response:
+ """
+ Prune orphaned permissions.
+ """
+
+ await rbac_repo.prune_permissions()
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
diff --git a/gns3server/api/routes/controller/users.py b/gns3server/api/routes/controller/users.py
index f051135f..76b704f7 100644
--- a/gns3server/api/routes/controller/users.py
+++ b/gns3server/api/routes/controller/users.py
@@ -75,7 +75,7 @@ async def authenticate(
) -> schemas.Token:
"""
Alternative authentication method using json.
- Example: curl http://host:port/v3/users/authenticate -d '{"username": "admin", "password": "admin"}'
+ Example: curl http://host:port/v3/users/authenticate -d '{"username": "admin", "password": "admin"} -H "Content-Type: application/json" '
"""
user = await users_repo.authenticate_user(username=user_credentials.username, password=user_credentials.password)
diff --git a/gns3server/appliances/nokia-vsim.gns3a b/gns3server/appliances/nokia-vsim.gns3a
new file mode 100644
index 00000000..d05618cf
--- /dev/null
+++ b/gns3server/appliances/nokia-vsim.gns3a
@@ -0,0 +1,46 @@
+{
+ "name": "Nokia vSIM",
+ "category": "router",
+ "description": "The Nokia Virtualized 7750 SR and 7950 XRS Simulator (vSIM) is a Virtualized Network Function (VNF) that simulates the control, management, and forwarding functions of a 7750 SR or 7950 XRS router. The vSIM runs the same Service Router Operating System (SR OS) as 7750 SR and 7950 XRS hardware-based routers and, therefore, has the same feature set and operational behavior as those platforms.",
+ "vendor_name": "Nokia",
+ "vendor_url": "https://www.nokia.com/networks/",
+ "documentation_url": "https://documentation.nokia.com/",
+ "product_name": "Nokia vSIM",
+ "product_url": "https://www.nokia.com/networks/products/virtualized-service-router/",
+ "registry_version": 4,
+ "status": "experimental",
+ "maintainer": "Vinicius Rocha",
+ "maintainer_email": "viniciusatr@gmail.com",
+ "usage": "Login is admin and password is admin. \n\nWe are using one IOM with one MDA 12x100G (w/ breakout).\n\nYou must add your license: file vi cf3:license.txt",
+ "first_port_name": "A/1",
+ "port_name_format": "1/1/{port1}",
+ "qemu": {
+ "adapter_type": "virtio-net-pci",
+ "adapters": 13,
+ "ram": 4096,
+ "cpus": 2,
+ "hda_disk_interface": "virtio",
+ "arch": "x86_64",
+ "console_type": "telnet",
+ "kvm": "require",
+ "options": "-nographic -smbios type=1,product=TIMOS:license-file=cf3:license.txt\\ slot=A\\ chassis=SR-1\\ card=cpm-1\\ mda/1=me12-100gb-qsfp28"
+ },
+ "images": [
+ {
+ "filename": "sros-vsr-21.7.R1.qcow2",
+ "version": "21.7.R1",
+ "md5sum": "7eed38c01350ebaf9c6105e26ce5307e",
+ "filesize": 568655872,
+ "download_url": "https://customer.nokia.com/support/s/",
+ "compression": "zip"
+ }
+ ],
+ "versions": [
+ {
+ "name": "21.7.R1",
+ "images": {
+ "hda_disk_image": "sros-vsr-21.7.R1.qcow2"
+ }
+ }
+ ]
+}
diff --git a/gns3server/appliances/open-media-vault.gns3a b/gns3server/appliances/open-media-vault.gns3a
index 6bc5ef04..0b68c4b9 100644
--- a/gns3server/appliances/open-media-vault.gns3a
+++ b/gns3server/appliances/open-media-vault.gns3a
@@ -7,7 +7,7 @@
"documentation_url": "hhttps://docs.openmediavault.org",
"product_name": "OpenMediaVault",
"product_url": "https://www.openmediavault.org/",
- "registry_version": 3,
+ "registry_version": 4,
"status": "stable",
"maintainer": "Savio D'souza",
"maintainer_email": "savio2002@yahoo.in",
@@ -17,21 +17,29 @@
"adapter_type": "e1000",
"adapters": 1,
"ram": 2048,
- "hda_disk_interface": "ide",
- "hdb_disk_interface": "ide",
- "arch": "x86_64",
+ "hda_disk_interface": "sata",
+ "hdb_disk_interface": "sata",
+ "arch": "x86_64",
"console_type": "vnc",
"boot_priority": "dc",
"kvm": "require"
},
"images": [
+ {
+ "filename": "openmediavault_5.6.13-amd64.iso",
+ "version": "5.6.13",
+ "md5sum": "f08b41a5111fffca0355d53e26ec47ab",
+ "filesize": 652214272,
+ "download_url": "https://www.openmediavault.org/download.html",
+ "direct_download_url": "https://sourceforge.net/projects/openmediavault/files/5.6.13/openmediavault_5.6.13-amd64.iso/download"
+ },
{
"filename": "openmediavault_5.5.11-amd64.iso",
"version": "5.5.11",
"md5sum": "76baad8e13dd49bee9b4b4a6936b7296",
"filesize": 608174080,
"download_url": "https://www.openmediavault.org/download.html",
- "direct_download_url": "https://sourceforge.net/projects/openmediavault/files/latest/download"
+ "direct_download_url": "https://sourceforge.net/projects/openmediavault/files/5.5.11/openmediavault_5.5.11-amd64.iso/download"
},
{
"filename": "empty30G.qcow2",
@@ -44,12 +52,20 @@
],
"versions": [
{
- "name": "5.5.11",
+ "name": "5.6.13",
"images": {
"hda_disk_image": "empty30G.qcow2",
"hdb_disk_image": "empty30G.qcow2",
- "cdrom_image": "openmediavault_5.5.11-amd64.iso"
+ "cdrom_image": "openmediavault_5.6.13-amd64.iso"
}
+ },
+ {
+ "name": "5.5.11",
+ "images": {
+ "hda_disk_image": "empty30G.qcow2",
+ "hdb_disk_image": "empty30G.qcow2",
+ "cdrom_image": "openmediavault_5.5.11-amd64.iso"
}
+ }
]
}
diff --git a/gns3server/appliances/openwrt.gns3a b/gns3server/appliances/openwrt.gns3a
index a8b8fca8..a7228ea4 100644
--- a/gns3server/appliances/openwrt.gns3a
+++ b/gns3server/appliances/openwrt.gns3a
@@ -22,6 +22,24 @@
"kvm": "allow"
},
"images": [
+ {
+ "filename": "openwrt-21.02.0-x86-64-generic-ext4-combined.img",
+ "version": "21.02.0",
+ "md5sum": "1ba2a5c5c05e592c36a469a8ecd3bcf5",
+ "filesize": 126353408,
+ "download_url": "https://downloads.openwrt.org/releases/21.02.0/targets/x86/64/",
+ "direct_download_url": "https://downloads.openwrt.org/releases/21.02.0/targets/x86/64/openwrt-21.02.0-x86-64-generic-ext4-combined.img.gz",
+ "compression": "gzip"
+ },
+ {
+ "filename": "openwrt-19.07.8-x86-64-combined-ext4.img",
+ "version": "19.07.8",
+ "md5sum": "a9d9776a96968a2042484330f285cae3",
+ "filesize": 285736960,
+ "download_url": "https://downloads.openwrt.org/releases/19.07.8/targets/x86/64/",
+ "direct_download_url": "https://downloads.openwrt.org/releases/19.07.8/targets/x86/64/openwrt-19.07.8-x86-64-combined-ext4.img",
+ "compression": "gzip"
+ },
{
"filename": "openwrt-19.07.7-x86-64-combined-ext4.img",
"version": "19.07.7",
@@ -168,6 +186,18 @@
}
],
"versions": [
+ {
+ "name": "21.02.0",
+ "images": {
+ "hda_disk_image": "openwrt-21.02.0-x86-64-generic-ext4-combined.img"
+ }
+ },
+ {
+ "name": "19.07.8",
+ "images": {
+ "hda_disk_image": "openwrt-19.07.8-x86-64-combined-ext4.img"
+ }
+ },
{
"name": "19.07.7",
"images": {
diff --git a/gns3server/appliances/ostinato.gns3a b/gns3server/appliances/ostinato.gns3a
index 7e72c1e9..2cbd0a87 100644
--- a/gns3server/appliances/ostinato.gns3a
+++ b/gns3server/appliances/ostinato.gns3a
@@ -1,24 +1,27 @@
{
"name": "Ostinato",
"category": "guest",
- "description": "Ostinato is an open-source, cross-platform network packet crafter/traffic generator and analyzer with a friendly GUI. Craft and send packets of several streams with different protocols at different rates.",
+ "description": "Packet crafter and traffic generator for network engineers",
"vendor_name": "Ostinato",
- "vendor_url": "http://ostinato.org/",
- "documentation_url": "http://ostinato.org/docs.html",
+ "vendor_url": "https://ostinato.org/",
+ "documentation_url": "https://ostinato.org/docs",
"product_name": "Ostinato",
- "product_url": "http://ostinato.org/",
- "registry_version": 3,
- "status": "experimental",
- "maintainer": "Bernhard Ehlers",
- "maintainer_email": "be@bernhard-ehlers.de",
- "usage": "Use interfaces starting with eth1 as traffic interfaces, eth0 is only for the (optional) management of the server/drone.",
- "symbol": "ostinato-3d-icon.svg",
- "port_name_format": "eth{0}",
+ "product_url": "https://ostinato.org/",
+ "registry_version": 4,
+ "status": "stable",
+ "availability": "service-contract",
+ "maintainer": "Srivats P",
+ "maintainer_email": "support@ostinato.org",
+ "symbol": ":/symbols/affinity/circle/gray/cog.svg",
+ "first_port_name": "eth0/mgmt",
+ "port_name_format": "eth{port1}",
+ "linked_clone": true,
"qemu": {
"adapter_type": "e1000",
"adapters": 4,
"ram": 256,
- "hda_disk_interface": "ide",
+ "cpus": 2,
+ "hda_disk_interface": "sata",
"arch": "i386",
"console_type": "vnc",
"kvm": "allow",
@@ -26,33 +29,18 @@
},
"images": [
{
- "filename": "ostinato-0.9-1.qcow2",
- "version": "0.9",
- "md5sum": "00b4856ec9fffbcbcab7a8f757355d69",
- "filesize": 101646336,
- "download_url": "http://www.bernhard-ehlers.de/projects/ostinato4gns3/index.html",
- "direct_download_url": "http://www.bernhard-ehlers.de/projects/ostinato4gns3/ostinato-0.9-1.qcow2"
- },
- {
- "filename": "ostinato-0.8-1.qcow2",
- "version": "0.8",
- "md5sum": "12e990ba695103cfac82f8771b8015d4",
- "filesize": 57344000,
- "download_url": "http://www.bernhard-ehlers.de/projects/ostinato4gns3/index.html",
- "direct_download_url": "http://www.bernhard-ehlers.de/projects/ostinato4gns3/ostinato-0.8-1.qcow2"
+ "version": "1.1",
+ "filename": "ostinatostd-1.1-1.qcow2",
+ "filesize": 134217728,
+ "md5sum": "aa027e83cefea1c38d0102eb2f28956e",
+ "download_url": "https://ostinato.org/pricing/gns3"
}
],
"versions": [
{
- "name": "0.9",
+ "name": "1.1",
"images": {
- "hda_disk_image": "ostinato-0.9-1.qcow2"
- }
- },
- {
- "name": "0.8",
- "images": {
- "hda_disk_image": "ostinato-0.8-1.qcow2"
+ "hda_disk_image": "ostinatostd-1.1-1.qcow2"
}
}
]
diff --git a/gns3server/appliances/vyos.gns3a b/gns3server/appliances/vyos.gns3a
index aba9a41d..f7157e4c 100644
--- a/gns3server/appliances/vyos.gns3a
+++ b/gns3server/appliances/vyos.gns3a
@@ -11,7 +11,7 @@
"status": "stable",
"maintainer": "GNS3 Team",
"maintainer_email": "developers@gns3.net",
- "usage": "Default username/password is vyos/vyos.\n\nAt first boot of versions 1.1.x/1.2.x the router will start from the cdrom. Login and then type \"install image\" and follow the instructions.",
+ "usage": "Default username/password is vyos/vyos.\n\nAt first boot the router will start from the cdrom. Login and then type \"install image\" and follow the instructions.",
"symbol": "vyos.svg",
"port_name_format": "eth{0}",
"qemu": {
@@ -26,12 +26,12 @@
},
"images": [
{
- "filename": "vyos-1.3.0-rc5-amd64.qcow2",
- "version": "1.3.0-rc5",
- "md5sum": "dd704f59afc0fccdf601cc750bf2c438",
- "filesize": 361955328,
- "download_url": "https://www.b-ehlers.de/GNS3/images/",
- "direct_download_url": "https://www.b-ehlers.de/GNS3/images/vyos-1.3.0-rc5-amd64.qcow2"
+ "filename": "vyos-1.3.0-rc6-amd64.iso",
+ "version": "1.3.0-rc6",
+ "md5sum": "b3939f82a35b23d428ee0ad4ac8be087",
+ "filesize": 331350016,
+ "download_url": "https://vyos.net/get/snapshots/",
+ "direct_download_url": "https://s3.amazonaws.com/s3-us.vyos.io/snapshot/vyos-1.3.0-rc6/vyos-1.3.0-rc6-amd64.iso"
},
{
"filename": "vyos-1.2.8-amd64.iso",
@@ -66,9 +66,10 @@
],
"versions": [
{
- "name": "1.3.0-rc5",
+ "name": "1.3.0-rc6",
"images": {
- "hda_disk_image": "vyos-1.3.0-rc5-amd64.qcow2"
+ "hda_disk_image": "empty8G.qcow2",
+ "cdrom_image": "vyos-1.3.0-rc6-amd64.iso"
}
},
{
diff --git a/gns3server/compute/base_manager.py b/gns3server/compute/base_manager.py
index 82f206d9..1d8c78e7 100644
--- a/gns3server/compute/base_manager.py
+++ b/gns3server/compute/base_manager.py
@@ -475,8 +475,7 @@ class BaseManager:
for root, dirs, files in os.walk(directory):
for file in files:
- # If filename is the same
- if s[1] == file and (s[0] == '' or os.path.basename(s[0]) == os.path.basename(root)):
+ if s[1] == file and (s[0] == '' or root == os.path.join(directory, s[0])):
path = os.path.normpath(os.path.join(root, s[1]))
if os.path.exists(path):
return path
diff --git a/gns3server/compute/builtin/nodes/nat.py b/gns3server/compute/builtin/nodes/nat.py
index 21deca92..29d17959 100644
--- a/gns3server/compute/builtin/nodes/nat.py
+++ b/gns3server/compute/builtin/nodes/nat.py
@@ -36,10 +36,16 @@ class Nat(Cloud):
def __init__(self, name, node_id, project, manager, ports=None):
+ allowed_interfaces = Config.instance().settings.Server.allowed_interfaces
+ if allowed_interfaces:
+ allowed_interfaces = allowed_interfaces.split(',')
if sys.platform.startswith("linux"):
nat_interface = Config.instance().settings.Server.default_nat_interface
if not nat_interface:
nat_interface = "virbr0"
+ if allowed_interfaces and nat_interface not in allowed_interfaces:
+ raise NodeError("NAT interface {} is not allowed be used on this server. "
+ "Please check the server configuration file.".format(nat_interface))
if nat_interface not in [interface["name"] for interface in gns3server.utils.interfaces.interfaces()]:
raise NodeError(f"NAT interface {nat_interface} is missing, please install libvirt")
interface = nat_interface
@@ -47,6 +53,9 @@ class Nat(Cloud):
nat_interface = Config.instance().settings.Server.default_nat_interface
if not nat_interface:
nat_interface = "vmnet8"
+ if allowed_interfaces and nat_interface not in allowed_interfaces:
+ raise NodeError("NAT interface {} is not allowed be used on this server. "
+ "Please check the server configuration file.".format(nat_interface))
interfaces = list(
filter(
lambda x: nat_interface in x.lower(),
diff --git a/gns3server/compute/qemu/__init__.py b/gns3server/compute/qemu/__init__.py
index 2993ddbf..2a0c48c3 100644
--- a/gns3server/compute/qemu/__init__.py
+++ b/gns3server/compute/qemu/__init__.py
@@ -152,8 +152,6 @@ class Qemu(BaseManager):
log.debug(f"Searching for Qemu binaries in '{path}'")
try:
for f in os.listdir(path):
- if f.endswith("-spice"):
- continue
if (
(f.startswith("qemu-system") or f.startswith("qemu-kvm") or f == "qemu" or f == "qemu.exe")
and os.access(os.path.join(path, f), os.X_OK)
diff --git a/gns3server/compute/qemu/qemu_vm.py b/gns3server/compute/qemu/qemu_vm.py
index 3077b973..d9028f91 100644
--- a/gns3server/compute/qemu/qemu_vm.py
+++ b/gns3server/compute/qemu/qemu_vm.py
@@ -1832,23 +1832,16 @@ class QemuVM(BaseNode):
def _get_qemu_img(self):
"""
Search the qemu-img binary in the same binary of the qemu binary
- for avoiding version incompatibility.
+ to avoid version incompatibility.
:returns: qemu-img path or raise an error
"""
- qemu_img_path = ""
+
qemu_path_dir = os.path.dirname(self.qemu_path)
- try:
- for f in os.listdir(qemu_path_dir):
- if f.startswith("qemu-img"):
- qemu_img_path = os.path.join(qemu_path_dir, f)
- except OSError as e:
- raise QemuError(f"Error while looking for qemu-img in {qemu_path_dir}: {e}")
-
- if not qemu_img_path:
- raise QemuError(f"Could not find qemu-img in {qemu_path_dir}")
-
- return qemu_img_path
+ qemu_image_path = shutil.which("qemu-img", path=qemu_path_dir)
+ if qemu_image_path:
+ return qemu_image_path
+ raise QemuError(f"Could not find qemu-img in {qemu_path_dir}")
async def _qemu_img_exec(self, command):
@@ -1864,27 +1857,36 @@ class QemuVM(BaseNode):
log.info(f"{self._get_qemu_img()} returned with {retcode}")
return retcode
+ async def _find_disk_file_format(self, disk):
+
+ qemu_img_path = self._get_qemu_img()
+ try:
+ output = await subprocess_check_output(qemu_img_path, "info", "--output=json", disk)
+ except subprocess.SubprocessError as e:
+ raise QemuError(f"Error received while checking Qemu disk format: {e}")
+ if output:
+ try:
+ json_data = json.loads(output)
+ except ValueError as e:
+ raise QemuError(f"Invalid JSON data returned by qemu-img: {e}")
+ return json_data.get("format")
+
async def _create_linked_clone(self, disk_name, disk_image, disk):
+
try:
qemu_img_path = self._get_qemu_img()
- command = [qemu_img_path, "create", "-o", f"backing_file={disk_image}", "-f", "qcow2", disk]
- try:
- base_qcow2 = Qcow2(disk_image)
- if base_qcow2.crypt_method:
- # Workaround for https://gitlab.com/qemu-project/qemu/-/issues/441
- # Also embed a secret name so it doesn't have to be passed to qemu -drive ...
- options = {
- "encrypt.key-secret": os.path.basename(disk_image),
- "driver": "qcow2",
- "file": {
- "driver": "file",
- "filename": disk_image,
- },
- }
- command = [qemu_img_path, "create", "-b", "json:"+json.dumps(options, separators=(',', ':')),
- "-f", "qcow2", "-u", disk, str(base_qcow2.size)]
- except Qcow2Error:
- pass # non-qcow2 base images are acceptable (e.g. vmdk, raw image)
+ backing_file_format = await self._find_disk_file_format(disk_image)
+ if not backing_file_format:
+ raise QemuError(f"Could not detect format for disk image: {disk_image}")
+ backing_options, base_qcow2 = Qcow2.backing_options(disk_image)
+ if base_qcow2 and base_qcow2.crypt_method:
+ # Workaround for https://gitlab.com/qemu-project/qemu/-/issues/441
+ # (we have to pass -u and the size). Also embed secret name.
+ command = [qemu_img_path, "create", "-b", backing_options,
+ "-F", backing_file_format, "-f", "qcow2", "-u", disk, str(base_qcow2.size)]
+ else:
+ command = [qemu_img_path, "create", "-o", "backing_file={}".format(disk_image),
+ "-F", backing_file_format, "-f", "qcow2", disk]
retcode = await self._qemu_img_exec(command)
if retcode:
@@ -2068,19 +2070,14 @@ class QemuVM(BaseNode):
if retcode == 3:
# image has leaked clusters, but is not corrupted, let's try to fix it
log.warning(f"Qemu image {disk_image} has leaked clusters")
- if (await self._qemu_img_exec([qemu_img_path, "check", "-r", "leaks", f"{disk_image}"])) == 3:
- self.project.emit(
- "log.warning",
- {"message": f"Qemu image '{disk_image}' has leaked clusters and could not be fixed"},
- )
+ if await self._qemu_img_exec([qemu_img_path, "check", "-r", "leaks", "{}".format(disk_image)]) == 3:
+ self.project.emit("log.warning", {"message": "Qemu image '{}' has leaked clusters and could not be fixed".format(disk_image)})
elif retcode == 2:
# image is corrupted, let's try to fix it
log.warning(f"Qemu image {disk_image} is corrupted")
- if (await self._qemu_img_exec([qemu_img_path, "check", "-r", "all", f"{disk_image}"])) == 2:
- self.project.emit(
- "log.warning",
- {"message": f"Qemu image '{disk_image}' is corrupted and could not be fixed"},
- )
+ if await self._qemu_img_exec([qemu_img_path, "check", "-r", "all", "{}".format(disk_image)]) == 2:
+ self.project.emit("log.warning", {"message": "Qemu image '{}' is corrupted and could not be fixed".format(disk_image)})
+ # ignore retcode == 1. One reason is that the image is encrypted and there is no encrypt.key-secret available
except (OSError, subprocess.SubprocessError) as e:
stdout = self.read_qemu_img_stdout()
raise QemuError(f"Could not check '{disk_name}' disk image: {e}\n{stdout}")
@@ -2091,10 +2088,16 @@ class QemuVM(BaseNode):
# create the disk
await self._create_linked_clone(disk_name, disk_image, disk)
else:
- # The disk exists we check if the clone works
+ backing_file_format = await self._find_disk_file_format(disk_image)
+ if not backing_file_format:
+ raise QemuError("Could not detect format for disk image: {}".format(disk_image))
+ # Rebase the image. This is in case the base image moved to a different directory,
+ # which will be the case if we imported a portable project. This uses
+ # get_abs_image_path(hdX_disk_image) and ignores the old base path embedded
+ # in the qcow2 file itself.
try:
qcow2 = Qcow2(disk)
- await qcow2.validate(qemu_img_path)
+ await qcow2.rebase(qemu_img_path, disk_image, backing_file_format)
except (Qcow2Error, OSError) as e:
raise QemuError(f"Could not use qcow2 disk image '{disk_image}' for {disk_name} {e}")
diff --git a/gns3server/compute/qemu/utils/qcow2.py b/gns3server/compute/qemu/utils/qcow2.py
index 52269f36..60fdc972 100644
--- a/gns3server/compute/qemu/utils/qcow2.py
+++ b/gns3server/compute/qemu/utils/qcow2.py
@@ -15,6 +15,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
+import json
import os
import asyncio
import struct
@@ -88,31 +89,50 @@ class Qcow2:
return None
return path
- async def rebase(self, qemu_img, base_image):
+ @staticmethod
+ def backing_options(base_image):
+ """
+ If the base_image is encrypted qcow2, return options for the upper layer
+ which include a secret name (equal to the basename)
+
+ :param base_image: Path to the base file (which may or may not be qcow2)
+
+ :returns: (base image string, Qcow2 object representing base image or None)
+ """
+
+ try:
+ base_qcow2 = Qcow2(base_image)
+ if base_qcow2.crypt_method:
+ # Embed a secret name so it doesn't have to be passed to qemu -drive ...
+ options = {
+ "encrypt.key-secret": os.path.basename(base_image),
+ "driver": "qcow2",
+ "file": {
+ "driver": "file",
+ "filename": base_image,
+ },
+ }
+ return ("json:"+json.dumps(options, separators=(',', ':')), base_qcow2)
+ else:
+ return (base_image, base_qcow2)
+ except Qcow2Error:
+ return (base_image, None) # non-qcow2 base images are acceptable (e.g. vmdk, raw image)
+
+ async def rebase(self, qemu_img, base_image, backing_file_format):
"""
Rebase a linked clone in order to use the correct disk
:param qemu_img: Path to the qemu-img binary
:param base_image: Path to the base image
+ :param backing_file_format: File format of the base image
"""
if not os.path.exists(base_image):
raise FileNotFoundError(base_image)
- command = [qemu_img, "rebase", "-u", "-b", base_image, self._path]
+ backing_options, _ = Qcow2.backing_options(base_image)
+ command = [qemu_img, "rebase", "-u", "-b", backing_options, "-F", backing_file_format, self._path]
process = await asyncio.create_subprocess_exec(*command)
retcode = await process.wait()
if retcode != 0:
raise Qcow2Error("Could not rebase the image")
self._reload()
-
- async def validate(self, qemu_img):
- """
- Run qemu-img info to validate the file and its backing images
-
- :param qemu_img: Path to the qemu-img binary
- """
- command = [qemu_img, "info", "--backing-chain", self._path]
- process = await asyncio.create_subprocess_exec(*command)
- retcode = await process.wait()
- if retcode != 0:
- raise Qcow2Error("Could not validate the image")
diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py
index b4a02c0e..ae224b7d 100644
--- a/gns3server/controller/link.py
+++ b/gns3server/controller/link.py
@@ -174,7 +174,6 @@ class Link:
async def update_link_style(self, link_style):
if link_style != self._link_style:
self._link_style = link_style
- await self.update()
self._project.emit_notification("link.updated", self.asdict())
self._project.dump()
diff --git a/gns3server/controller/node.py b/gns3server/controller/node.py
index ca086455..c6a046e4 100644
--- a/gns3server/controller/node.py
+++ b/gns3server/controller/node.py
@@ -25,6 +25,7 @@ from .compute import ComputeConflict, ComputeError
from .controller_error import ControllerError, ControllerTimeoutError
from .ports.port_factory import PortFactory, StandardPortFactory, DynamipsPortFactory
from ..utils.images import images_directories
+from ..config import Config
from ..utils.qt import qt_font_to_style
@@ -293,10 +294,11 @@ class Node:
if val is None:
val = ":/symbols/computer.svg"
- # No abs path, fix them (bug of 1.X)
try:
- if not val.startswith(":") and os.path.abspath(val):
- val = os.path.basename(val)
+ if not val.startswith(":") and os.path.isabs(val):
+ default_symbol_directory = Config.instance().settings.Server.symbols_path
+ if os.path.commonprefix([default_symbol_directory, val]) != default_symbol_directory:
+ val = os.path.basename(val)
except OSError:
pass
diff --git a/gns3server/crash_report.py b/gns3server/crash_report.py
index 9b0f379b..46b31fd8 100644
--- a/gns3server/crash_report.py
+++ b/gns3server/crash_report.py
@@ -59,7 +59,7 @@ class CrashReport:
Report crash to a third party service
"""
- DSN = "https://aefc1e0e41e94957936f8773071aebf9:056b5247d4854b81ac9162d9ccc5a503@o19455.ingest.sentry.io/38482"
+ DSN = "https://54d3363bab36489fb0f7cbbdda6ca7c5:9f1012f8aa1547f683e00c0aac9b99f6@o19455.ingest.sentry.io/38482"
_instance = None
def __init__(self):
diff --git a/gns3server/db/models/permissions.py b/gns3server/db/models/permissions.py
index 4779b6af..8be3d669 100644
--- a/gns3server/db/models/permissions.py
+++ b/gns3server/db/models/permissions.py
@@ -53,19 +53,19 @@ def create_default_roles(target, connection, **kw):
default_permissions = [
{
"description": "Allow access to all endpoints",
- "methods": ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH"],
+ "methods": ["GET", "POST", "PUT", "DELETE"],
"path": "/",
"action": "ALLOW"
},
{
"description": "Allow to create and list projects",
- "methods": ["GET", "HEAD", "POST"],
+ "methods": ["GET", "POST"],
"path": "/projects",
"action": "ALLOW"
},
{
"description": "Allow to create and list templates",
- "methods": ["GET", "HEAD", "POST"],
+ "methods": ["GET", "POST"],
"path": "/templates",
"action": "ALLOW"
},
@@ -77,7 +77,7 @@ def create_default_roles(target, connection, **kw):
},
{
"description": "Allow access to all symbol endpoints",
- "methods": ["GET", "HEAD", "POST"],
+ "methods": ["GET", "POST"],
"path": "/symbols/*",
"action": "ALLOW"
},
diff --git a/gns3server/db/models/roles.py b/gns3server/db/models/roles.py
index 6cba0cc1..b531a50f 100644
--- a/gns3server/db/models/roles.py
+++ b/gns3server/db/models/roles.py
@@ -38,7 +38,7 @@ class Role(BaseTable):
__tablename__ = "roles"
role_id = Column(GUID, primary_key=True, default=generate_uuid)
- name = Column(String, unique=True)
+ name = Column(String, unique=True, index=True)
description = Column(String)
is_builtin = Column(Boolean, default=False)
permissions = relationship("Permission", secondary=permission_role_link, back_populates="roles")
diff --git a/gns3server/db/repositories/rbac.py b/gns3server/db/repositories/rbac.py
index 6e1096c9..02fd652c 100644
--- a/gns3server/db/repositories/rbac.py
+++ b/gns3server/db/repositories/rbac.py
@@ -17,7 +17,7 @@
from uuid import UUID
from typing import Optional, List, Union
-from sqlalchemy import select, update, delete
+from sqlalchemy import select, update, delete, null
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -194,7 +194,8 @@ class RbacRepository(BaseRepository):
Get all permissions.
"""
- query = select(models.Permission)
+ query = select(models.Permission).\
+ order_by(models.Permission.path.desc())
result = await self._db_session.execute(query)
return result.scalars().all()
@@ -257,6 +258,22 @@ class RbacRepository(BaseRepository):
await self._db_session.commit()
return result.rowcount > 0
+ async def prune_permissions(self) -> int:
+ """
+ Prune orphaned permissions.
+ """
+
+ query = select(models.Permission).\
+ filter((~models.Permission.roles.any()) & (models.Permission.user_id == null()))
+ result = await self._db_session.execute(query)
+ permissions = result.scalars().all()
+ permissions_deleted = 0
+ for permission in permissions:
+ if await self.delete_permission(permission.permission_id):
+ permissions_deleted += 1
+ log.info(f"{permissions_deleted} orphaned permissions have been deleted")
+ return permissions_deleted
+
def _match_permission(
self,
permissions: List[models.Permission],
@@ -282,9 +299,9 @@ class RbacRepository(BaseRepository):
"""
query = select(models.Permission).\
- join(models.User.permissions). \
+ join(models.User.permissions).\
filter(models.User.user_id == user_id).\
- order_by(models.Permission.path)
+ order_by(models.Permission.path.desc())
result = await self._db_session.execute(query)
return result.scalars().all()
@@ -379,11 +396,11 @@ class RbacRepository(BaseRepository):
"""
query = select(models.Permission).\
- join(models.Permission.roles). \
- join(models.Role.groups). \
- join(models.UserGroup.users). \
+ join(models.Permission.roles).\
+ join(models.Role.groups).\
+ join(models.UserGroup.users).\
filter(models.User.user_id == user_id).\
- order_by(models.Permission.path)
+ order_by(models.Permission.path.desc())
result = await self._db_session.execute(query)
permissions = result.scalars().all()
diff --git a/gns3server/handlers/api/compute/qemu_handler.py b/gns3server/handlers/api/compute/qemu_handler.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/gns3server/handlers/api/compute/server_handler.py b/gns3server/handlers/api/compute/server_handler.py
new file mode 100644
index 00000000..f2e15c5f
--- /dev/null
+++ b/gns3server/handlers/api/compute/server_handler.py
@@ -0,0 +1,135 @@
+
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2015 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 .
+
+import psutil
+import platform
+
+from gns3server.web.route import Route
+from gns3server.config import Config
+from gns3server.schemas.version import VERSION_SCHEMA
+from gns3server.schemas.server_statistics import SERVER_STATISTICS_SCHEMA
+from gns3server.compute.port_manager import PortManager
+from gns3server.utils.cpu_percent import CpuPercent
+from gns3server.utils.path import get_default_project_directory
+from gns3server.version import __version__
+from aiohttp.web import HTTPConflict
+
+
+class ServerHandler:
+
+ @Route.get(
+ r"/version",
+ description="Retrieve the server version number",
+ output=VERSION_SCHEMA)
+ def version(request, response):
+
+ config = Config.instance()
+ local_server = config.get_section_config("Server").getboolean("local", False)
+ response.json({"version": __version__, "local": local_server})
+
+ @Route.get(
+ r"/statistics",
+ description="Retrieve server statistics",
+ output=SERVER_STATISTICS_SCHEMA,
+ status_codes={
+ 200: "Statistics information returned",
+ 409: "Conflict"
+ })
+ def statistics(request, response):
+
+ try:
+ memory_total = psutil.virtual_memory().total
+ memory_free = psutil.virtual_memory().available
+ memory_used = memory_total - memory_free # actual memory usage in a cross platform fashion
+ swap_total = psutil.swap_memory().total
+ swap_free = psutil.swap_memory().free
+ swap_used = psutil.swap_memory().used
+ cpu_percent = int(CpuPercent.get())
+ load_average_percent = [int(x / psutil.cpu_count() * 100) for x in psutil.getloadavg()]
+ memory_percent = int(psutil.virtual_memory().percent)
+ swap_percent = int(psutil.swap_memory().percent)
+ disk_usage_percent = int(psutil.disk_usage(get_default_project_directory()).percent)
+ except psutil.Error as e:
+ raise HTTPConflict(text="Psutil error detected: {}".format(e))
+ response.json({"memory_total": memory_total,
+ "memory_free": memory_free,
+ "memory_used": memory_used,
+ "swap_total": swap_total,
+ "swap_free": swap_free,
+ "swap_used": swap_used,
+ "cpu_usage_percent": cpu_percent,
+ "memory_usage_percent": memory_percent,
+ "swap_usage_percent": swap_percent,
+ "disk_usage_percent": disk_usage_percent,
+ "load_average_percent": load_average_percent})
+
+ @Route.get(
+ r"/debug",
+ description="Return debug information about the compute",
+ status_codes={
+ 201: "Written"
+ })
+ def debug(request, response):
+ response.content_type = "text/plain"
+ response.text = ServerHandler._getDebugData()
+
+ @staticmethod
+ def _getDebugData():
+ try:
+ addrs = ["* {}: {}".format(key, val) for key, val in psutil.net_if_addrs().items()]
+ except UnicodeDecodeError:
+ addrs = ["INVALID ADDR WITH UNICODE CHARACTERS"]
+
+ data = """Version: {version}
+OS: {os}
+Python: {python}
+CPU: {cpu}
+Memory: {memory}
+
+Networks:
+{addrs}
+""".format(
+ version=__version__,
+ os=platform.platform(),
+ python=platform.python_version(),
+ memory=psutil.virtual_memory(),
+ cpu=psutil.cpu_times(),
+ addrs="\n".join(addrs)
+ )
+
+ try:
+ connections = psutil.net_connections()
+ # You need to be root for OSX
+ except psutil.AccessDenied:
+ connections = None
+
+ if connections:
+ data += "\n\nConnections:\n"
+ for port in PortManager.instance().tcp_ports:
+ found = False
+ for open_port in connections:
+ if open_port.laddr[1] == port:
+ found = True
+ data += "TCP {}: {}\n".format(port, found)
+ for port in PortManager.instance().udp_ports:
+ found = False
+ for open_port in connections:
+ if open_port.laddr[1] == port:
+ found = True
+ data += "UDP {}: {}\n".format(port, found)
+ return data
diff --git a/gns3server/handlers/api/controller/server_handler.py b/gns3server/handlers/api/controller/server_handler.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/gns3server/static/web-ui/26.30249f0e3aeb3f791226.js b/gns3server/static/web-ui/26.30249f0e3aeb3f791226.js
new file mode 100644
index 00000000..40f6feae
--- /dev/null
+++ b/gns3server/static/web-ui/26.30249f0e3aeb3f791226.js
@@ -0,0 +1 @@
+"use strict";(self.webpackChunkgns3_web_ui=self.webpackChunkgns3_web_ui||[]).push([[26],{91026:function(q,c,a){a.r(c),a.d(c,{TopologySummaryComponent:function(){return N}});var t=a(65508),d=a(96852),_=a(14200),m=a(36889),h=a(3941),f=a(15132),p=a(40098),v=a(39095),u=a(88802),y=a(73044),g=a(59412),x=a(93386);function T(i,e){if(1&i){var o=t.EpF();t.TgZ(0,"div",2),t.NdJ("mousemove",function(r){return t.CHM(o),t.oxw().dragWidget(r)},!1,t.evT)("mouseup",function(){return t.CHM(o),t.oxw().toggleDragging(!1)},!1,t.evT),t.qZA()}}function C(i,e){1&i&&(t.O4$(),t.TgZ(0,"svg",28),t._UZ(1,"rect",29),t.qZA())}function S(i,e){1&i&&(t.O4$(),t.TgZ(0,"svg",28),t._UZ(1,"rect",30),t.qZA())}function b(i,e){1&i&&(t.O4$(),t.TgZ(0,"svg",28),t._UZ(1,"rect",31),t.qZA())}function E(i,e){if(1&i&&(t.TgZ(0,"div"),t._uU(1),t.qZA()),2&i){var o=t.oxw().$implicit;t.xp6(1),t.lnq(" ",o.console_type," ",o.console_host,":",o.console," ")}}function Z(i,e){1&i&&(t.TgZ(0,"div"),t._uU(1," none "),t.qZA())}function O(i,e){if(1&i&&(t.TgZ(0,"div",25),t.TgZ(1,"div"),t.YNc(2,C,2,0,"svg",26),t.YNc(3,S,2,0,"svg",26),t.YNc(4,b,2,0,"svg",26),t._uU(5),t.qZA(),t.YNc(6,E,2,3,"div",27),t.YNc(7,Z,2,0,"div",27),t.qZA()),2&i){var o=e.$implicit;t.xp6(2),t.Q6J("ngIf","started"===o.status),t.xp6(1),t.Q6J("ngIf","suspended"===o.status),t.xp6(1),t.Q6J("ngIf","stopped"===o.status),t.xp6(1),t.hij(" ",o.name," "),t.xp6(1),t.Q6J("ngIf",null!=o.console&&null!=o.console&&"none"!=o.console_type),t.xp6(1),t.Q6J("ngIf",null==o.console||"none"===o.console_type)}}function M(i,e){1&i&&(t.O4$(),t.TgZ(0,"svg",28),t._UZ(1,"rect",29),t.qZA())}function w(i,e){1&i&&(t.O4$(),t.TgZ(0,"svg",28),t._UZ(1,"rect",31),t.qZA())}function A(i,e){if(1&i&&(t.TgZ(0,"div",25),t.TgZ(1,"div"),t.YNc(2,M,2,0,"svg",26),t.YNc(3,w,2,0,"svg",26),t._uU(4),t.qZA(),t.TgZ(5,"div"),t._uU(6),t.qZA(),t.TgZ(7,"div"),t._uU(8),t.qZA(),t.qZA()),2&i){var o=e.$implicit,s=t.oxw(2);t.xp6(2),t.Q6J("ngIf",o.connected),t.xp6(1),t.Q6J("ngIf",!o.connected),t.xp6(1),t.hij(" ",o.name," "),t.xp6(2),t.hij(" ",o.host," "),t.xp6(2),t.hij(" ",s.server.location," ")}}var P=function(i){return{lightTheme:i}},F=function(){return{right:!0,left:!0,bottom:!0,top:!0}};function D(i,e){if(1&i){var o=t.EpF();t.TgZ(0,"div",3),t.NdJ("mousedown",function(){return t.CHM(o),t.oxw().toggleDragging(!0)})("resizeStart",function(){return t.CHM(o),t.oxw().toggleDragging(!1)})("resizeEnd",function(n){return t.CHM(o),t.oxw().onResizeEnd(n)}),t.TgZ(1,"div",4),t.TgZ(2,"mat-tab-group"),t.TgZ(3,"mat-tab",5),t.NdJ("click",function(){return t.CHM(o),t.oxw().toggleTopologyVisibility(!0)}),t.TgZ(4,"div",6),t.TgZ(5,"div",7),t.TgZ(6,"mat-select",8),t.TgZ(7,"mat-optgroup",9),t.TgZ(8,"mat-option",10),t.NdJ("onSelectionChange",function(){return t.CHM(o),t.oxw().applyStatusFilter("started")}),t._uU(9,"started"),t.qZA(),t.TgZ(10,"mat-option",11),t.NdJ("onSelectionChange",function(){return t.CHM(o),t.oxw().applyStatusFilter("suspended")}),t._uU(11,"suspended"),t.qZA(),t.TgZ(12,"mat-option",12),t.NdJ("onSelectionChange",function(){return t.CHM(o),t.oxw().applyStatusFilter("stopped")}),t._uU(13,"stopped"),t.qZA(),t.qZA(),t.TgZ(14,"mat-optgroup",13),t.TgZ(15,"mat-option",14),t.NdJ("onSelectionChange",function(){return t.CHM(o),t.oxw().applyCaptureFilter("capture")}),t._uU(16,"active capture(s)"),t.qZA(),t.TgZ(17,"mat-option",15),t.NdJ("onSelectionChange",function(){return t.CHM(o),t.oxw().applyCaptureFilter("packet")}),t._uU(18,"active packet captures"),t.qZA(),t.qZA(),t.qZA(),t.qZA(),t.TgZ(19,"div",16),t.TgZ(20,"mat-select",17),t.NdJ("selectionChange",function(){return t.CHM(o),t.oxw().setSortingOrder()})("valueChange",function(n){return t.CHM(o),t.oxw().sortingOrder=n}),t.TgZ(21,"mat-option",18),t._uU(22,"sort by name ascending"),t.qZA(),t.TgZ(23,"mat-option",19),t._uU(24,"sort by name descending"),t.qZA(),t.qZA(),t.qZA(),t._UZ(25,"mat-divider",20),t.TgZ(26,"div",21),t.YNc(27,O,8,6,"div",22),t.qZA(),t.qZA(),t.qZA(),t.TgZ(28,"mat-tab",23),t.NdJ("click",function(){return t.CHM(o),t.oxw().toggleTopologyVisibility(!1)}),t.TgZ(29,"div",6),t.TgZ(30,"div",24),t.YNc(31,A,9,5,"div",22),t.qZA(),t.qZA(),t.qZA(),t.qZA(),t.qZA(),t.qZA()}if(2&i){var s=t.oxw();t.Q6J("ngStyle",s.style)("ngClass",t.VKq(9,P,s.isLightThemeEnabled))("validateResize",s.validate)("resizeEdges",t.DdM(11,F))("enableGhostResize",!0),t.xp6(20),t.Q6J("value",s.sortingOrder),t.xp6(6),t.Q6J("ngStyle",s.styleInside),t.xp6(1),t.Q6J("ngForOf",s.filteredNodes),t.xp6(4),t.Q6J("ngForOf",s.computes)}}var N=function(){function i(e,o,s,r,n){this.nodesDataSource=e,this.projectService=o,this.computeService=s,this.linksDataSource=r,this.themeService=n,this.closeTopologySummary=new t.vpe,this.style={},this.styleInside={height:"280px"},this.subscriptions=[],this.nodes=[],this.filteredNodes=[],this.sortingOrder="asc",this.startedStatusFilterEnabled=!1,this.suspendedStatusFilterEnabled=!1,this.stoppedStatusFilterEnabled=!1,this.captureFilterEnabled=!1,this.packetFilterEnabled=!1,this.computes=[],this.isTopologyVisible=!0,this.isDraggingEnabled=!1,this.isLightThemeEnabled=!1}return i.prototype.ngOnInit=function(){var e=this;this.isLightThemeEnabled="light"===this.themeService.getActualTheme(),this.subscriptions.push(this.nodesDataSource.changes.subscribe(function(o){e.nodes=o,e.nodes.forEach(function(s){("0.0.0.0"===s.console_host||"0:0:0:0:0:0:0:0"===s.console_host||"::"===s.console_host)&&(s.console_host=e.server.host)}),e.filteredNodes=o.sort("asc"===e.sortingOrder?e.compareAsc:e.compareDesc)})),this.projectService.getStatistics(this.server,this.project.project_id).subscribe(function(o){e.projectsStatistics=o}),this.computeService.getComputes(this.server).subscribe(function(o){e.computes=o}),this.style={top:"60px",right:"0px",width:"320px",height:"400px"}},i.prototype.toggleDragging=function(e){this.isDraggingEnabled=e},i.prototype.dragWidget=function(e){var o=Number(e.movementX),s=Number(e.movementY),r=Number(this.style.width.split("px")[0]),n=Number(this.style.height.split("px")[0]),l=Number(this.style.top.split("px")[0])+s;if(this.style.left){var I=Number(this.style.left.split("px")[0])+o;this.style={position:"fixed",left:I+"px",top:l+"px",width:r+"px",height:n+"px"}}else{var U=Number(this.style.right.split("px")[0])-o;this.style={position:"fixed",right:U+"px",top:l+"px",width:r+"px",height:n+"px"}}},i.prototype.validate=function(e){return!(e.rectangle.width&&e.rectangle.height&&(e.rectangle.width<290||e.rectangle.height<260))},i.prototype.onResizeEnd=function(e){this.style={position:"fixed",left:e.rectangle.left+"px",top:e.rectangle.top+"px",width:e.rectangle.width+"px",height:e.rectangle.height+"px"},this.styleInside={height:e.rectangle.height-120+"px"}},i.prototype.toggleTopologyVisibility=function(e){this.isTopologyVisible=e},i.prototype.compareAsc=function(e,o){return e.name
-
+
@@ -46,6 +46,6 @@
gtag('config', 'G-5D6FZL9923');
-
+