Repository: KatharaFramework/Kathara (language: python)

Issue:
Docker Image Update Policy
Add an option in the settings to manage the update of the Docker Images. 

**Actual behavior:**
Every time a device is ran, Kathará checks if there are updates for the device image on the DockerHub. If there are, Kathará asks to the user (prompting on the terminal where Kathará is executed) if he wants to update the image. 


**Expected behavior:** 
The Kathará settings have an option to:

- Always update the images without prompting the request.
- Never update the images without prompting the request. 
- Ask to the user the permission for doing the upgrade prompting the request (default). 


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index 2bc40fef..f9ba2e22 100644
--- a/.gitignore
+++ b/.gitignore
@@ -45,6 +45,7 @@ nosetests.xml
 coverage.xml
 *,cover
 .hypothesis/
+.pytest_cache/
 
 # Translations
 *.mo
@@ -98,6 +99,10 @@ Dockerfile~
 /src/kathara.build/
 /src/kathara.dist/
 
+# Packages
+*.rpm
+*.tar.zst
+
 
 /config
 /.vscode/
diff --git a/docs/kathara-lab.conf.5.ronn b/docs/kathara-lab.conf.5.ronn
index 5c9c3911..3916bbd4 100644
--- a/docs/kathara-lab.conf.5.ronn
+++ b/docs/kathara-lab.conf.5.ronn
@@ -46,6 +46,9 @@ In order to establish a uniform convention, comment lines should always start wi
 * `sysctl` (string):
 	Set a sysctl option for this device. Only the `net.` namespace is allowed to be set. Can be set multiple times per device, each will add a new entry (unless the same config item is used again).
 
+* `env` (string):
+	Set an environment variable for the device. Can be set multiple times per device, each will add a new entry (unless the same variable is used again). The format is: ENV_NAME=ENV_VALUE.
+
 * `shell` (string):
     Use the specified shell to connect to the device, e.g., when `kathara connect` is called.
 
diff --git a/docs/kathara-vstart.1.ronn b/docs/kathara-vstart.1.ronn
index 2b2e6431..0a96c460 100644
--- a/docs/kathara-vstart.1.ronn
+++ b/docs/kathara-vstart.1.ronn
@@ -10,7 +10,7 @@ kathara-vstart(1) -- Start a new Kathara device
 [`-e` [<EXEC_COMMANDS> [<EXEC_COMMANDS> ...]]] [`--mem` <MEM>]  
 [`--cpus` <CPUS>] [`-i` <IMAGE>] [`-H`]  
 [`--xterm` <XTERM>] [`--print`] [`--bridged`]  
-[--port <[HOST\:]GUEST[/PROTOCOL]> [<[HOST\:]GUEST[/PROTOCOL]> ...]] [--sysctl <SYSCTL> [<SYSCTL> ...]]
+[--port <[HOST\:]GUEST[/PROTOCOL]> [<[HOST\:]GUEST[/PROTOCOL]> ...]] [--env <ENV> [<ENV> ...]] [--sysctl <SYSCTL> [<SYSCTL> ...]]
 [`--shell` <SHELL>]  
 
 ## DESCRIPTION
@@ -96,6 +96,9 @@ Notice: unless differently stated, command line arguments (DEVICE_NAME) and opti
 * `--sysctl` <SYSCTL> [<SYSCTL> ...]:
     Set a sysctl option for the device. Only the `net.` namespace is allowed to be set.
 
+* `--env` <ENV> [<ENV> ...]:
+    Set an environment variable for the device. The format is: ENV_NAME=ENV_VALUE.
+
 * `--num_terms` <NUM_TERMS>:
     Choose the number of terminals to open for the device.
 
diff --git a/docs/kathara.conf.5.ronn b/docs/kathara.conf.5.ronn
index 18b5029c..7b17995e 100644
--- a/docs/kathara.conf.5.ronn
+++ b/docs/kathara.conf.5.ronn
@@ -83,6 +83,13 @@ Each Manager specifies additional parameters which are used only when the Manage
 
 	Default to `true`.
 
+* `image_update_policy` (string):
+    This parameter specifies the policy when a Docker image update is available for a running device.
+
+    Possible values are `Prompt`, `Always`, `Never`.
+
+    Default to `Prompt`.
+
 * `shared_cd` (boolean):
     This parameter allows to connect devices of different users to the same collision domains.
 
@@ -139,8 +146,9 @@ Each Manager specifies additional parameters which are used only when the Manage
             "last_checked": 1600087624.6843708,
             "hosthome_mount": false,
             "shared_mount": true,
+            "image_update_policy": "Prompt",
             "shared_cd": false,
-            "remote_url": "http://192.168.127.78:2375",
+            "remote_url": null,
             "cert_path": null
         }
 
diff --git a/scripts/Linux-Deb/Docker-Linux-Build/Dockerfile_template b/scripts/Linux-Deb/Docker-Linux-Build/Dockerfile_template
index ca5861b8..31f0aaa1 100644
--- a/scripts/Linux-Deb/Docker-Linux-Build/Dockerfile_template
+++ b/scripts/Linux-Deb/Docker-Linux-Build/Dockerfile_template
@@ -34,6 +34,7 @@ RUN add-apt-repository ppa:deadsnakes/ppa \
 		python3.9-distutils \
 	&& python3.9 -m pip install --upgrade pip
 
+__NOKOGIRI__
 RUN gem install ronn-ng
 RUN mkdir /root/.gnupg && chmod 700 /root/.gnupg
 
diff --git a/scripts/Linux-Deb/Makefile b/scripts/Linux-Deb/Makefile
index 804f5c00..1e5373f7 100644
--- a/scripts/Linux-Deb/Makefile
+++ b/scripts/Linux-Deb/Makefile
@@ -1,13 +1,13 @@
 #!/usr/bin/make -f
 
-VERSION=3.3.0
+VERSION=3.5.0
 DEBIAN_PACKAGE_VERSION=1
 LAUNCHPAD_NAME=user
 NO_BINARY_PACKAGES=pyroute2|pyuv|deepdiff
 
-.PHONY: allSigned docker-build-image prepare-deb-source unpack-deb-source clean-output-folder copy-debian-folder download-pip build-man build-deb-unsigned build-deb-signed ppa deps clean venv autocompletion
+.PHONY: allSigned docker-build-image prepare-deb-source unpack-deb-source clean-output-folder copy-debian-folder download-pip build-man build-deb-unsigned build-deb-signed ppa clean venv autocompletion
 
-allSigned: clean prepare-deb-source docker-signed_bionic docker-signed_focal docker-signed_hirsute docker-signed_impish
+allSigned: clean prepare-deb-source docker-signed_bionic docker-signed_focal docker-signed_impish docker-signed_jammy
 
 docker-unsigned_%: clean prepare-deb-source docker-build-image_%
 	docker run -ti --rm -v `pwd`/../../:/opt/kathara kathara/linux-build-deb:$* /bin/bash -c "make build-deb-unsigned distro=$*"
@@ -17,6 +17,11 @@ docker-signed_%: docker-build-image_%
 
 docker-build-image_%:
 	cd Docker-Linux-Build && cp Dockerfile_template Dockerfile
+	if [ "$*" = "bionic" ]; then \
+		cd Docker-Linux-Build && sed -i -e "s|__NOKOGIRI__|RUN gem install nokogiri -v 1.12.5|g" Dockerfile; \
+	else \
+		cd Docker-Linux-Build && sed -i -e "s|__NOKOGIRI__||g" Dockerfile; \
+	fi;
 	cd Docker-Linux-Build && sed -i -e "s|__DISTRO__|$*|g" Dockerfile
 	cd Docker-Linux-Build && docker build -t kathara/linux-build-deb:$* .
 	cd Docker-Linux-Build && rm Dockerfile
@@ -44,7 +49,7 @@ copy-debian-folder:
 	sed -i -e 's/__VERSION__/$(VERSION)/g' Output/kathara-$(VERSION)/debian/changelog
 	
 venv:
-	python3.9 -m venv /root/3.9-env 
+	python3.9 -m venv /root/3.9-env
 
 download-pip: copy-debian-folder
 	mkdir Output/kathara-$(VERSION)/debian/pythonLibs_amd64
@@ -52,9 +57,11 @@ download-pip: copy-debian-folder
 	grep -Ev "$(NO_BINARY_PACKAGES)" Output/kathara-$(VERSION)/requirements.txt > Output/kathara-$(VERSION)/requirements-binary.txt
 	grep -E "$(NO_BINARY_PACKAGES)" Output/kathara-$(VERSION)/requirements.txt > Output/kathara-$(VERSION)/requirements-no-binary.txt
 	/root/3.9-env/bin/pip3.9 install --upgrade setuptools
+	cd Output/kathara-$(VERSION)/debian/pythonLibs_amd64 && /root/3.9-env/bin/pip3.9 download flit_core
 	cd Output/kathara-$(VERSION)/debian/pythonLibs_amd64 && /root/3.9-env/bin/pip3.9 download --no-binary=:all: -r ../../requirements-no-binary.txt
 	cd Output/kathara-$(VERSION)/debian/pythonLibs_amd64 && /root/3.9-env/bin/pip3.9 download --platform manylinux2014_x86_64 --platform x86_64 --only-binary=:all: -r ../../requirements-binary.txt
 	cd Output/kathara-$(VERSION)/debian/pythonLibs_amd64 && /root/3.9-env/bin/pip3.9 download nuitka
+	cd Output/kathara-$(VERSION)/debian/pythonLibs_arm64 && /root/3.9-env/bin/pip3.9 download flit_core
 	cd Output/kathara-$(VERSION)/debian/pythonLibs_arm64 && /root/3.9-env/bin/pip3.9 download --no-binary=:all: -r ../../requirements-no-binary.txt
 	cd Output/kathara-$(VERSION)/debian/pythonLibs_arm64 && /root/3.9-env/bin/pip3.9 download --platform manylinux2014_aarch64 --platform arm64 --only-binary=:all: -r ../../requirements-binary.txt
 	cd Output/kathara-$(VERSION)/debian/pythonLibs_arm64 && /root/3.9-env/bin/pip3.9 download nuitka
@@ -85,9 +92,6 @@ ppa:
 	sed -i -e 's/__LAUNCHPAD_NAME__/$(LAUNCHPAD_NAME)/g' $(HOME)/.dput.cf
 	dput -U ppa:katharaframework/kathara Output/*.changes
 
-deps:
-	gem install ronn-ng
-
 clean:
 	if [ -d Output ]; then \
 		rm -rf Output; \
diff --git a/scripts/Linux-Deb/debian/changelog b/scripts/Linux-Deb/debian/changelog
index 6103d4fe..52db8231 100644
--- a/scripts/Linux-Deb/debian/changelog
+++ b/scripts/Linux-Deb/debian/changelog
@@ -1,9 +1,9 @@
 kathara (__VERSION__-__DEBIAN_PACKAGE_VERSION____UBUNTU_VERSION__) __UBUNTU_VERSION__; urgency=low
 
-  * Add multi-arch support (thanks to Nicolas Ollinger and Marcel Großmann)
-  * Unmount /etc/resolv.conf and /etc/hosts instead of patching them with cat
-  * Lab object has no side-effect when it is deployed with selected devices
-  * Fix exec command with non-returning commands (like ping)
-  * Add check that throws an exception when a device is connected multiple times on the same CD
+  * Fix lab.dep parser
+  * Refactor of Managers and add more expressive Python APIs
+  * Decouple UI Logic from Business Logic
+  * Add Settings option to select the Docker Image Update Policy
+  * Add possibility to declare container environment variables from vstart and lab.conf
 
  -- Mariano Scazzariello <******@gmail.com>  __DATE__
diff --git a/scripts/Linux-Deb/debian/control b/scripts/Linux-Deb/debian/control
index 4bdbec5f..c725be9f 100644
--- a/scripts/Linux-Deb/debian/control
+++ b/scripts/Linux-Deb/debian/control
@@ -10,7 +10,8 @@ Build-Depends: debhelper (>= 10),
 	python3.9-venv (>= 3.9.0),
 	python3.9-distutils (>= 3.9.0) | python3-distutils (>= 3.8.0),
 	patchelf,
-	bash-completion
+	bash-completion,
+	zlib1g-dev
 Vcs-Git: https://github.com/KatharaFramework/Kathara.git
 Vcs-Browser: https://github.com/KatharaFramework/Kathara
 Homepage: https://www.kathara.org/
diff --git a/scripts/Linux-Pkg/Docker-Linux-Build/Dockerfile b/scripts/Linux-Pkg/Docker-Linux-Build/Dockerfile
index 48d54edb..e04d7c1c 100644
--- a/scripts/Linux-Pkg/Docker-Linux-Build/Dockerfile
+++ b/scripts/Linux-Pkg/Docker-Linux-Build/Dockerfile
@@ -1,4 +1,4 @@
-FROM archlinux:base-20210131.0.14634
+FROM archlinux:latest
 
 ENV RUBYOPT="-KU -E utf-8:utf-8"
 
diff --git a/scripts/Linux-Pkg/Makefile b/scripts/Linux-Pkg/Makefile
index cd65c9c4..a3683243 100644
--- a/scripts/Linux-Pkg/Makefile
+++ b/scripts/Linux-Pkg/Makefile
@@ -1,6 +1,6 @@
 #!/usr/bin/make -f
 
-VERSION=3.3.0
+VERSION=3.5.0
 PACKAGE_VERSION=1
 AUR_NAME=user
 AUR_MAIL=your@mail.com
diff --git a/scripts/Linux-Pkg/pkginfo/kathara.changelog b/scripts/Linux-Pkg/pkginfo/kathara.changelog
index 0d00bf7d..f7ba9866 100644
--- a/scripts/Linux-Pkg/pkginfo/kathara.changelog
+++ b/scripts/Linux-Pkg/pkginfo/kathara.changelog
@@ -1,8 +1,8 @@
 __DATE__ Kathara Team <******@kathara.org>
 
 * Release v__VERSION__
-    * Add multi-arch support (thanks to Nicolas Ollinger and Marcel Großmann)
-    * Unmount /etc/resolv.conf and /etc/hosts instead of patching them with cat
-    * Lab object has no side-effect when it is deployed with selected devices
-    * Fix exec command with non-returning commands (like ping)
-    * Add check that throws an exception when a device is connected multiple times on the same CD
\ No newline at end of file
+    * Fix lab.dep parser
+    * Refactor of Managers and add more expressive Python APIs
+    * Decouple UI Logic from Business Logic
+    * Add Settings option to select the Docker Image Update Policy
+    * Add possibility to declare container environment variables from vstart and lab.conf
\ No newline at end of file
diff --git a/scripts/Linux-Rpm/Makefile b/scripts/Linux-Rpm/Makefile
index 248e140c..ae577095 100644
--- a/scripts/Linux-Rpm/Makefile
+++ b/scripts/Linux-Rpm/Makefile
@@ -1,6 +1,6 @@
 #!/usr/bin/make -f
 
-VERSION=3.3.0
+VERSION=3.5.0
 PACKAGE_VERSION=1
 
 .PHONY: all clean docker-build-image prepare-source prepare-man-pages prepare-bash-completion pack-source build
diff --git a/scripts/Linux-Rpm/rpm/kathara.spec b/scripts/Linux-Rpm/rpm/kathara.spec
index 9ddd6bf3..3db91f45 100644
--- a/scripts/Linux-Rpm/rpm/kathara.spec
+++ b/scripts/Linux-Rpm/rpm/kathara.spec
@@ -68,8 +68,8 @@ chmod g+s %{_libdir}/kathara/kathara
 
 %changelog
 *  __DATE__ Kathara Team <******@kathara.org> - __VERSION__-__PACKAGE_VERSION__
-- Add multi-arch support (thanks to Nicolas Ollinger and Marcel Großmann)
-- Unmount /etc/resolv.conf and /etc/hosts instead of patching them with cat
-- Lab object has no side-effect when it is deployed with selected devices
-- Fix exec command with non-returning commands (like ping)
-- Add check that throws an exception when a device is connected multiple times on the same CD
\ No newline at end of file
+- Fix lab.dep parser
+- Refactor of Managers and add more expressive Python APIs
+- Decouple UI Logic from Business Logic
+- Add Settings option to select the Docker Image Update Policy
+- Add possibility to declare container environment variables from vstart and lab.conf
\ No newline at end of file
diff --git a/scripts/OSX/Makefile b/scripts/OSX/Makefile
index 611a089a..3bcfc980 100644
--- a/scripts/OSX/Makefile
+++ b/scripts/OSX/Makefile
@@ -1,7 +1,7 @@
 #!/usr/bin/make -s
 
 PRODUCT=Kathara
-VERSION=3.3.0
+VERSION=3.5.0
 TARGET_DIRECTORY=Output
 APPLE_DEVELOPER_CERTIFICATE_ID=FakeID
 ROFF_DIR=../../docs/Roff
@@ -21,7 +21,7 @@ py_env:
 	python3 -m venv $(VENV_DIR)
 
 deps: py_env
-	$(VENV_DIR)/bin/pip3.9 install https://github.com/pyinstaller/pyinstaller/archive/develop.zip
+	$(VENV_DIR)/bin/pip3.9 install pyinstaller
 	$(VENV_DIR)/bin/pip3.9 install -r ../../src/requirements.txt
 	$(VENV_DIR)/bin/pip3.9 install pytest
 	gem install ronn-ng --user-install
@@ -40,7 +40,8 @@ test:
 
 binary_%: test
 	cp ./kathara.spec ../../src/
-	cd ../../src && $(VENV_DIR)/bin/pyinstaller --distpath=./kathara.dist --workpath=./kathara.build --target-arch $* kathara.spec
+	sed -i '' -e "s:__ARCH__:$*:g" ../../src/kathara.spec
+	cd ../../src && $(VENV_DIR)/bin/pyinstaller --distpath=./kathara.dist --workpath=./kathara.build kathara.spec
 
 copyBuildDirectory_%: binary_% manpages
 	[ -d $(TARGET_DIRECTORY) ] || mkdir $(TARGET_DIRECTORY)
diff --git a/scripts/OSX/kathara.spec b/scripts/OSX/kathara.spec
index b50ffd99..ad976a66 100644
--- a/scripts/OSX/kathara.spec
+++ b/scripts/OSX/kathara.spec
@@ -62,4 +62,5 @@ exe = EXE(pyz,
           upx=True,
           upx_exclude=[],
           runtime_tmpdir=None,
+          target_arch='__ARCH__',
           console=True )
\ No newline at end of file
diff --git a/scripts/Windows/installer.iss b/scripts/Windows/installer.iss
index bfc074ca..8a0213a0 100644
--- a/scripts/Windows/installer.iss
+++ b/scripts/Windows/installer.iss
@@ -2,7 +2,7 @@
 ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
 
 #define MyAppName "Kathara"
-#define MyAppVersion "3.3.0"
+#define MyAppVersion "3.5.0"
 #define MyAppPublisher "Kathara Team"
 #define MyAppURL "https://www.kathara.org"
 #define MyAppExeName "kathara.exe"
diff --git a/scripts/autocompletion/requirements.txt b/scripts/autocompletion/requirements.txt
index d4bac972..cb4352df 100644
--- a/scripts/autocompletion/requirements.txt
+++ b/scripts/autocompletion/requirements.txt
@@ -3,4 +3,4 @@ requests>=2.22.0;
 terminaltables>=3.1.0;
 slug>=2.0;
 deepdiff>=4.0.9;
-kubernetes>=20.13.0;
\ No newline at end of file
+kubernetes>=23.3.0;
\ No newline at end of file
diff --git a/scripts/pydoc/Makefile b/scripts/pydoc/Makefile
index da637a4a..606ba52c 100644
--- a/scripts/pydoc/Makefile
+++ b/scripts/pydoc/Makefile
@@ -7,18 +7,18 @@ all: clean doc verify-doc
 doc: clean verify-doc
 	python3 generate_doc.py
 	cd docs && rm Kathara.model.md Kathara.setting.md Kathara.manager.md
-	sed -i '/manager.md/d' ./docs/Kathara-API-Docs.md
-	sed -i '/setting.md/d' ./docs/Kathara-API-Docs.md
-	sed -i '/docker.md/d' ./docs/Kathara-API-Docs.md
-	sed -i '/kubernetes.md/d' ./docs/Kathara-API-Docs.md
-	sed -i '/KubernetesSettingsAddon.md/d' ./docs/Kathara-API-Docs.md
-	sed -i '/DockerSettingsAddon.md/d' ./docs/Kathara-API-Docs.md
-	sed -i '/model.md/d' ./docs/Kathara-API-Docs.md
-	sed -i 's/\.\///g' ./docs/Kathara-API-Docs.md
-	sed -i 's/\.md//g' ./docs/Kathara-API-Docs.md
-	sed -i '/Modules/,/Classes/d' ./docs/Kathara-API-Docs.md
-	sed -i '/Functions/q' ./docs/Kathara-API-Docs.md
-	sed -i '/Functions/d' ./docs/Kathara-API-Docs.md
+	sed -i "" '/manager.md/d' ./docs/Kathara-API-Docs.md
+	sed -i "" '/setting.md/d' ./docs/Kathara-API-Docs.md
+	sed -i "" '/docker.md/d' ./docs/Kathara-API-Docs.md
+	sed -i "" '/kubernetes.md/d' ./docs/Kathara-API-Docs.md
+	sed -i "" '/KubernetesSettingsAddon.md/d' ./docs/Kathara-API-Docs.md
+	sed -i "" '/DockerSettingsAddon.md/d' ./docs/Kathara-API-Docs.md
+	sed -i "" '/model.md/d' ./docs/Kathara-API-Docs.md
+	sed -i "" 's/\.\///g' ./docs/Kathara-API-Docs.md
+	sed -i "" 's/\.md//g' ./docs/Kathara-API-Docs.md
+	sed -i "" '/Modules/,/Classes/d' ./docs/Kathara-API-Docs.md
+	sed -i "" '/Functions/q' ./docs/Kathara-API-Docs.md
+	sed -i "" '/Functions/d' ./docs/Kathara-API-Docs.md
 
 verify-doc:
 	python3 -m pip install lazydocs
diff --git a/setup.cfg b/setup.cfg
index e906610f..67f4a495 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,6 @@
 [metadata]
 name = kathara
-version = 3.3.0
+version = 3.5.0
 author = Kathara Framework
 author_email = contact@kathara.org
 description = A lightweight container based emulation system
diff --git a/setup.py b/setup.py
index 6bbca01a..ac78d097 100644
--- a/setup.py
+++ b/setup.py
@@ -7,18 +7,18 @@
     package_dir={'': 'src'},
     packages=find_packages('src'),
     py_modules=['kathara'],
-    version='3.3.0',
+    version='3.5.0',
     license='gpl-3.0',
     description='A lightweight container based emulation system.',
     author='Kathara Framework',
     author_email='contact@kathara.org',
     url='https://www.kathara.org',
-    download_url='https://github.com/KatharaFramework/Kathara/archive/refs/tags/3.3.0.tar.gz',
+    download_url='https://github.com/KatharaFramework/Kathara/archive/refs/tags/3.5.0.tar.gz',
     keywords=['NETWORK-EMULATION', 'CONTAINERS', 'NFV'],
     install_requires=[
         "binaryornot>=0.4.4",
         "docker>=4.4.4",
-        "kubernetes>=20.13.0",
+        "kubernetes>=23.3.0",
         "requests>=2.22.0",
         "coloredlogs>=10.0",
         "terminaltables>=3.1.0",
diff --git a/src/Kathara/cli/command/CheckCommand.py b/src/Kathara/cli/command/CheckCommand.py
index ab47503e..d4799fb8 100644
--- a/src/Kathara/cli/command/CheckCommand.py
+++ b/src/Kathara/cli/command/CheckCommand.py
@@ -65,6 +65,6 @@ def linux_platform_info():
         try:
             Kathara.get_instance().deploy_lab(lab)
             print("*\tContainer run successfully.")
-            Kathara.get_instance().undeploy_lab(lab.hash)
+            Kathara.get_instance().undeploy_lab(lab_hash=lab.hash)
         except Exception as e:
             logging.exception("\t! Running `Hello World` failed: %s" % str(e))
diff --git a/src/Kathara/cli/command/ConnectCommand.py b/src/Kathara/cli/command/ConnectCommand.py
index df114638..5d39b4d9 100644
--- a/src/Kathara/cli/command/ConnectCommand.py
+++ b/src/Kathara/cli/command/ConnectCommand.py
@@ -68,5 +68,5 @@ def run(self, current_path: str, argv: List[str]) -> None:
 
         lab_hash = utils.generate_urlsafe_hash(lab_path)
 
-        Kathara.get_instance().connect_tty(lab_hash, machine_name=args['machine_name'], shell=args['shell'],
+        Kathara.get_instance().connect_tty(machine_name=args['machine_name'], lab_hash=lab_hash, shell=args['shell'],
                                            logs=args['logs'])
diff --git a/src/Kathara/cli/command/ExecCommand.py b/src/Kathara/cli/command/ExecCommand.py
index 9bfdcc3e..354b0041 100644
--- a/src/Kathara/cli/command/ExecCommand.py
+++ b/src/Kathara/cli/command/ExecCommand.py
@@ -73,7 +73,7 @@ def run(self, current_path: str, argv: List[str]) -> None:
             lab_path = utils.get_absolute_path(lab_path)
 
         lab_hash = utils.generate_urlsafe_hash(lab_path)
-        exec_output = Kathara.get_instance().exec(lab_hash, args['machine_name'], args['command'])
+        exec_output = Kathara.get_instance().exec(args['machine_name'], args['command'], lab_hash=lab_hash)
 
         try:
             while True:
diff --git a/src/Kathara/cli/command/LcleanCommand.py b/src/Kathara/cli/command/LcleanCommand.py
index 465c6fec..5e16bcdb 100644
--- a/src/Kathara/cli/command/LcleanCommand.py
+++ b/src/Kathara/cli/command/LcleanCommand.py
@@ -1,6 +1,8 @@
 import argparse
+import logging
 from typing import List
 
+from ..ui.utils import format_headers
 from ... import utils
 from ...foundation.cli.command.Command import Command
 from ...manager.Kathara import Kathara
@@ -46,6 +48,6 @@ def run(self, current_path: str, argv: List[str]) -> None:
 
         lab_hash = utils.generate_urlsafe_hash(lab_path)
 
-        Kathara.get_instance().undeploy_lab(lab_hash,
-                                            selected_machines=set(args['machine_names'])
-                                            )
+        logging.info(format_headers("Stopping Network Scenario"))
+
+        Kathara.get_instance().undeploy_lab(lab_hash=lab_hash, selected_machines=set(args['machine_names']))
diff --git a/src/Kathara/cli/command/LconfigCommand.py b/src/Kathara/cli/command/LconfigCommand.py
index 9ed48d7d..476c7ace 100644
--- a/src/Kathara/cli/command/LconfigCommand.py
+++ b/src/Kathara/cli/command/LconfigCommand.py
@@ -68,7 +68,7 @@ def run(self, current_path: str, argv: List[str]) -> None:
         lab = Lab(None, path=lab_path)
 
         device = lab.get_or_new_machine(args['name'])
-        device.api_object = Kathara.get_instance().get_machine_api_object(lab.hash, args['name'])
+        device.api_object = Kathara.get_instance().get_machine_api_object(args['name'], lab_hash=lab.hash)
 
         for eth in args['eths']:
             logging.info("Adding interface to device `%s` for collision domain `%s`..." % (args['name'], eth))
diff --git a/src/Kathara/cli/command/LinfoCommand.py b/src/Kathara/cli/command/LinfoCommand.py
index 637c4fbe..48d5146d 100644
--- a/src/Kathara/cli/command/LinfoCommand.py
+++ b/src/Kathara/cli/command/LinfoCommand.py
@@ -1,6 +1,8 @@
 import argparse
 from typing import List
 
+from ..ui.utils import create_table
+from ..ui.utils import format_headers
 from ... import utils
 from ...foundation.cli.command.Command import Command
 from ...manager.Kathara import Kathara
@@ -74,56 +76,61 @@ def run(self, current_path: str, argv: List[str]) -> None:
             return
 
         if args['conf']:
-            if args['name']:
-                print(Kathara.get_instance().get_formatted_machine_info(args['name'], lab_hash))
-            else:
-                self._get_conf_info(lab_path)
-
+            self._get_conf_info(lab_path, machine_name=args['name'])
             return
 
         if args['name']:
-            print(Kathara.get_instance().get_formatted_machine_info(args['name'], lab_hash))
+            print(format_headers("Device Information"))
+            print(str(next(Kathara.get_instance().get_machine_stats(args['name'], lab_hash))))
+            print(format_headers())
         else:
-            lab_info = Kathara.get_instance().get_formatted_lab_info(lab_hash)
-
-            print(next(lab_info))
+            machines_stats = Kathara.get_instance().get_machines_stats(lab_hash)
+            print(next(create_table(machines_stats)))
 
     @staticmethod
-    def _get_machine_live_info(lab_hash, machine_name):
+    def _get_machine_live_info(lab_hash: str, machine_name: str) -> None:
         Curses.get_instance().init_window()
 
         try:
             while True:
                 Curses.get_instance().print_string(
-                    Kathara.get_instance().get_formatted_machine_info(machine_name, lab_hash)
+                    format_headers("Device Information") + "\n" +
+                    str(next(Kathara.get_instance().get_machine_stats(machine_name, lab_hash))) + "\n" +
+                    format_headers()
                 )
         finally:
             Curses.get_instance().close()
 
     @staticmethod
     def _get_lab_live_info(lab_hash: str) -> None:
-        lab_info = Kathara.get_instance().get_formatted_lab_info(lab_hash)
+        machines_stats = Kathara.get_instance().get_machines_stats(lab_hash)
+        table = create_table(machines_stats)
 
         Curses.get_instance().init_window()
 
         try:
             while True:
-                Curses.get_instance().print_string(next(lab_info))
+                Curses.get_instance().print_string(next(table))
         except StopIteration:
             pass
         finally:
             Curses.get_instance().close()
 
     @staticmethod
-    def _get_conf_info(lab_path: str) -> None:
-        print(utils.format_headers("Lab Information"))
-
+    def _get_conf_info(lab_path: str, machine_name: str = None) -> None:
         lab = LabParser.parse(lab_path)
+        if machine_name:
+            print(format_headers("Device Information"))
+            print(str(lab.machines[machine_name]))
+            print(format_headers())
+            return
+
+        print(format_headers("Network Scenario Information"))
         lab_meta_information = str(lab)
 
         if lab_meta_information:
             print(lab_meta_information)
-            print(utils.format_headers())
+            print(format_headers())
 
         n_machines = len(lab.machines)
         n_links = len(lab.links) if BRIDGE_LINK_NAME not in lab.links else len(lab.links) - 1
@@ -131,4 +138,4 @@ def _get_conf_info(lab_path: str) -> None:
         print("There are %d devices." % n_machines)
         print("There are %d collision domains." % n_links)
 
-        print(utils.format_headers())
+        print(format_headers())
diff --git a/src/Kathara/cli/command/ListCommand.py b/src/Kathara/cli/command/ListCommand.py
index 93b4f6b3..19951b5c 100644
--- a/src/Kathara/cli/command/ListCommand.py
+++ b/src/Kathara/cli/command/ListCommand.py
@@ -1,6 +1,7 @@
 import argparse
 from typing import List
 
+from ..ui.utils import create_table
 from ... import utils
 from ...foundation.cli.command.Command import Command
 from ...manager.Kathara import Kathara
@@ -57,39 +58,21 @@ def run(self, current_path: str, argv: List[str]) -> None:
         all_users = bool(args['all'])
 
         if args['live']:
-            if args['name']:
-                self._get_machine_live_info(args['name'], all_users)
-            else:
-                self._get_lab_live_info(all_users)
+            self._get_live_info(machine_name=args['name'], all_users=all_users)
         else:
-            if args['name']:
-                print(Kathara.get_instance().get_formatted_machine_info(args['name'], all_users=all_users))
-            else:
-                lab_info = Kathara.get_instance().get_formatted_lab_info(all_users=all_users)
-
-                print(next(lab_info))
-
-    @staticmethod
-    def _get_machine_live_info(machine_name: str, all_users: bool) -> None:
-        Curses.get_instance().init_window()
-
-        try:
-            while True:
-                Curses.get_instance().print_string(
-                    Kathara.get_instance().get_formatted_machine_info(machine_name, all_users=all_users)
-                )
-        finally:
-            Curses.get_instance().close()
+            machines_stats = Kathara.get_instance().get_machines_stats(machine_name=args['name'], all_users=all_users)
+            print(next(create_table(machines_stats)))
 
     @staticmethod
-    def _get_lab_live_info(all_users: bool) -> None:
-        lab_info = Kathara.get_instance().get_formatted_lab_info(all_users=all_users)
+    def _get_live_info(machine_name: str, all_users: bool) -> None:
+        machines_stats = Kathara.get_instance().get_machines_stats(machine_name=machine_name, all_users=all_users)
+        table = create_table(machines_stats)
 
         Curses.get_instance().init_window()
 
         try:
             while True:
-                Curses.get_instance().print_string(next(lab_info))
+                Curses.get_instance().print_string(next(table))
         except StopIteration:
             pass
         finally:
diff --git a/src/Kathara/cli/command/LstartCommand.py b/src/Kathara/cli/command/LstartCommand.py
index 242e3f6e..34141f71 100644
--- a/src/Kathara/cli/command/LstartCommand.py
+++ b/src/Kathara/cli/command/LstartCommand.py
@@ -4,6 +4,8 @@
 import sys
 from typing import List
 
+from ..ui.utils import create_table
+from ..ui.utils import format_headers
 from ... import utils
 from ...exceptions import PrivilegeError
 from ...foundation.cli.command.Command import Command
@@ -138,9 +140,9 @@ def run(self, current_path: str, argv: List[str]) -> Lab:
                 Setting.get_instance().open_terminals = False
 
         if args['dry_mode']:
-            logging.info(utils.format_headers("Checking Lab"))
+            logging.info(format_headers("Checking Network Scenario"))
         else:
-            logging.info(utils.format_headers("Starting Lab"))
+            logging.info(format_headers("Starting Network Scenario"))
 
         try:
             lab = LabParser.parse(lab_path)
@@ -159,7 +161,7 @@ def run(self, current_path: str, argv: List[str]) -> Lab:
 
         if lab_meta_information:
             logging.info("\n" + lab_meta_information)
-            logging.info(utils.format_headers())
+            logging.info(format_headers())
 
         if len(lab.machines) <= 0:
             raise Exception("No devices in the current lab. Exiting...")
@@ -205,6 +207,7 @@ def run(self, current_path: str, argv: List[str]) -> Lab:
         Kathara.get_instance().deploy_lab(lab, selected_machines=args['machine_name'])
 
         if args['list']:
-            print(next(Kathara.get_instance().get_formatted_lab_info(lab.hash)))
+            machines_stats = Kathara.get_instance().get_machines_stats(lab_hash=lab.hash)
+            print(next(create_table(machines_stats)))
 
         return lab
diff --git a/src/Kathara/cli/command/VcleanCommand.py b/src/Kathara/cli/command/VcleanCommand.py
index 2db8a62f..5b61e9ea 100644
--- a/src/Kathara/cli/command/VcleanCommand.py
+++ b/src/Kathara/cli/command/VcleanCommand.py
@@ -39,8 +39,6 @@ def run(self, current_path: str, argv: List[str]) -> None:
 
         lab_hash = utils.generate_urlsafe_hash("kathara_vlab")
 
-        Kathara.get_instance().undeploy_lab(lab_hash,
-                                            selected_machines={args['name']}
-                                            )
+        Kathara.get_instance().undeploy_lab(lab_hash=lab_hash, selected_machines={args['name']})
 
         logging.info("Device `%s` deleted successfully!" % args['name'])
diff --git a/src/Kathara/cli/command/VconfigCommand.py b/src/Kathara/cli/command/VconfigCommand.py
index 17837b5e..a5da8933 100644
--- a/src/Kathara/cli/command/VconfigCommand.py
+++ b/src/Kathara/cli/command/VconfigCommand.py
@@ -58,7 +58,7 @@ def run(self, current_path: str, argv: List[str]) -> None:
         lab = Lab("kathara_vlab")
 
         device = lab.get_or_new_machine(args['name'])
-        device.api_object = Kathara.get_instance().get_machine_api_object(lab.hash, args['name'])
+        device.api_object = Kathara.get_instance().get_machine_api_object(args['name'], lab_name=lab.name)
 
         for eth in args['eths']:
             logging.info("Adding interface to device `%s` for collision domain `%s`..." % (args['name'], eth))
diff --git a/src/Kathara/cli/command/VstartCommand.py b/src/Kathara/cli/command/VstartCommand.py
index 7c20c27c..bfa90851 100644
--- a/src/Kathara/cli/command/VstartCommand.py
+++ b/src/Kathara/cli/command/VstartCommand.py
@@ -3,6 +3,7 @@
 import sys
 from typing import List
 
+from ..ui.utils import format_headers
 from ... import utils
 from ...foundation.cli.command.Command import Command
 from ...manager.Kathara import Kathara
@@ -136,6 +137,14 @@ def __init__(self) -> None:
             required=False,
             help='Set sysctl option for the device.'
         )
+        self.parser.add_argument(
+            '--env',
+            dest='envs',
+            metavar='ENV',
+            nargs='+',
+            required=False,
+            help='Set environment variable for the device.'
+        )
         self.parser.add_argument(
             '--shell',
             required=False,
@@ -150,7 +159,7 @@ def run(self, current_path: str, argv: List[str]) -> None:
             logging.info("Device configuration is correct. Exiting...")
             sys.exit(0)
         else:
-            logging.info(utils.format_headers("Starting Device"))
+            logging.info(format_headers("Starting Device"))
 
         Setting.get_instance().open_terminals = args['terminals'] if args['terminals'] is not None \
             else Setting.get_instance().open_terminals
diff --git a/src/Kathara/cli/command/WipeCommand.py b/src/Kathara/cli/command/WipeCommand.py
index c450cc85..29dfc8d5 100644
--- a/src/Kathara/cli/command/WipeCommand.py
+++ b/src/Kathara/cli/command/WipeCommand.py
@@ -3,6 +3,7 @@
 import sys
 from typing import List
 
+from ..ui.utils import confirmation_prompt
 from ... import utils
 from ...foundation.cli.command.Command import Command
 from ...manager.Kathara import Kathara
@@ -56,10 +57,10 @@ def run(self, current_path: str, argv: List[str]) -> None:
         args = self.get_args()
 
         if not args['force']:
-            utils.confirmation_prompt("Are you sure to wipe Kathara?", lambda: None, sys.exit)
+            confirmation_prompt("Are you sure to wipe Kathara?", lambda: None, sys.exit)
 
         if args['settings']:
-            Setting.wipe()
+            Setting.wipe_from_disk()
         else:
             if args['all'] and not utils.is_admin():
                 raise Exception("You must be root in order to wipe all Kathara devices of all users.")
diff --git a/src/Kathara/cli/ui/event/HandleProgressBar.py b/src/Kathara/cli/ui/event/HandleProgressBar.py
new file mode 100644
index 00000000..49268741
--- /dev/null
+++ b/src/Kathara/cli/ui/event/HandleProgressBar.py
@@ -0,0 +1,49 @@
+from typing import Any
+from typing import List
+
+import progressbar
+
+
+class HandleProgressBar(object):
+    """Generic listener for handling a progress bar."""
+    __slots__ = ['message', 'progress_bar']
+
+    def __init__(self, message: str) -> None:
+        self.message = message
+        self.progress_bar = None
+
+    def init(self, items: List[Any]) -> None:
+        """Initialize a progress bar on the items.
+
+        Args:
+            items (List[Any]): List of items on which init the progress bar.
+
+        Returns:
+            None
+        """
+        self.progress_bar = progressbar.ProgressBar(
+            widgets=[self.message, progressbar.Bar(), ' ', progressbar.Counter(format='%(value)d/%(max_value)d')],
+            redirect_stdout=True,
+            max_value=len(items)
+        )
+
+    def update(self, item: Any) -> None:
+        """Update the progress bar with the item.
+
+        Args:
+            item (Any): Item that triggered the progress bar update.
+
+        Returns:
+            None
+        """
+        if self.progress_bar:
+            self.progress_bar += 1
+
+    def finish(self) -> None:
+        """Closes a progress bar.
+
+        Returns:
+            None
+        """
+        if self.progress_bar:
+            self.progress_bar.finish()
diff --git a/src/Kathara/cli/ui/event/OpenMachineTerminal.py b/src/Kathara/cli/ui/event/OpenMachineTerminal.py
new file mode 100644
index 00000000..bf146c79
--- /dev/null
+++ b/src/Kathara/cli/ui/event/OpenMachineTerminal.py
@@ -0,0 +1,20 @@
+from ..utils import open_machine_terminal
+from ....model import Machine as MachinePackage
+from ....setting.Setting import Setting
+
+
+class OpenMachineTerminal(object):
+    """Listener fired when a device is deployed and started."""
+
+    def run(self, item: 'MachinePackage.Machine') -> None:
+        """If allowed, open a terminal, with the emulator specified in the settings, into the device.
+
+        Args:
+            item (Kathara.model.Machine): Device where open a terminal.
+
+        Returns:
+            None
+        """
+        if Setting.get_instance().open_terminals:
+            for i in range(0, item.get_num_terms()):
+                open_machine_terminal(item)
diff --git a/src/Kathara/cli/ui/event/UpdateDockerImage.py b/src/Kathara/cli/ui/event/UpdateDockerImage.py
new file mode 100644
index 00000000..a78116ba
--- /dev/null
+++ b/src/Kathara/cli/ui/event/UpdateDockerImage.py
@@ -0,0 +1,27 @@
+from ...ui.utils import confirmation_prompt
+from ....manager.docker.DockerImage import DockerImage
+from ....setting.Setting import Setting
+
+
+class UpdateDockerImage(object):
+    """Listener fired when there is a Docker Image update."""
+
+    def run(self, docker_image: DockerImage, image_name: str) -> None:
+        """Prompt the user for Docker Image Update.
+
+        Args:
+            docker_image (DockerImage): DockerImage instance.
+            image_name (str): Name of the Docker image to update.
+
+        Returns:
+            None
+        """
+        policy = Setting.get_instance().image_update_policy
+        if policy == 'Prompt':
+            confirmation_prompt("A new version of image `%s` has been found on Docker Hub. "
+                                "Do you want to pull it?" % image_name,
+                                lambda: docker_image.pull(image_name),
+                                lambda: None
+                                )
+        elif policy == 'Always':
+            docker_image.pull(image_name)
diff --git a/src/Kathara/connectors/__init__.py b/src/Kathara/cli/ui/event/__init__.py
similarity index 100%
rename from src/Kathara/connectors/__init__.py
rename to src/Kathara/cli/ui/event/__init__.py
diff --git a/src/Kathara/cli/ui/event/register.py b/src/Kathara/cli/ui/event/register.py
new file mode 100644
index 00000000..6d866e36
--- /dev/null
+++ b/src/Kathara/cli/ui/event/register.py
@@ -0,0 +1,42 @@
+from .UpdateDockerImage import UpdateDockerImage
+from .OpenMachineTerminal import OpenMachineTerminal
+from .HandleProgressBar import HandleProgressBar
+from ....event.EventDispatcher import EventDispatcher
+
+
+def register_cli_events() -> None:
+    """Register events to handle UI from CLI.
+
+    Returns:
+        None
+    """
+    _register_link_events()
+    _register_machine_events()
+
+    EventDispatcher.get_instance().register("docker_image_update_found", UpdateDockerImage())
+
+
+def _register_link_events() -> None:
+    link_deploy_progress_bar_handler = HandleProgressBar('Deploying collision domains...')
+    EventDispatcher.get_instance().register("links_deploy_started", link_deploy_progress_bar_handler, "init")
+    EventDispatcher.get_instance().register("link_deployed", link_deploy_progress_bar_handler, "update")
+    EventDispatcher.get_instance().register("links_deploy_ended", link_deploy_progress_bar_handler, "finish")
+
+    link_undeploy_progress_bar_handler = HandleProgressBar('Deleting collision domains...')
+    EventDispatcher.get_instance().register("links_undeploy_started", link_undeploy_progress_bar_handler, "init")
+    EventDispatcher.get_instance().register("link_undeployed", link_undeploy_progress_bar_handler, "update")
+    EventDispatcher.get_instance().register("links_undeploy_ended", link_undeploy_progress_bar_handler, "finish")
+
+
+def _register_machine_events() -> None:
+    machine_deploy_progress_bar_handler = HandleProgressBar('Deploying devices...')
+    EventDispatcher.get_instance().register("machines_deploy_started", machine_deploy_progress_bar_handler, "init")
+    EventDispatcher.get_instance().register("machine_deployed", machine_deploy_progress_bar_handler, "update")
+    EventDispatcher.get_instance().register("machines_deploy_ended", machine_deploy_progress_bar_handler, "finish")
+
+    machine_undeploy_progress_bar_handler = HandleProgressBar('Deleting devices...')
+    EventDispatcher.get_instance().register("machines_undeploy_started", machine_undeploy_progress_bar_handler, "init")
+    EventDispatcher.get_instance().register("machine_undeployed", machine_undeploy_progress_bar_handler, "update")
+    EventDispatcher.get_instance().register("machines_undeploy_ended", machine_undeploy_progress_bar_handler, "finish")
+
+    EventDispatcher.get_instance().register("machine_deployed", OpenMachineTerminal())
diff --git a/src/Kathara/cli/ui/setting/CommonOptionsHandler.py b/src/Kathara/cli/ui/setting/CommonOptionsHandler.py
index 3d48c2b0..78452746 100644
--- a/src/Kathara/cli/ui/setting/CommonOptionsHandler.py
+++ b/src/Kathara/cli/ui/setting/CommonOptionsHandler.py
@@ -1,5 +1,5 @@
 from . import utils as setting_utils
-from ....connectors.DockerHubApi import DockerHubApi
+from ....webhooks.DockerHubApi import DockerHubApi
 from ....exceptions import HTTPConnectionError
 from ....foundation.cli.ui.setting.OptionsHandler import OptionsHandler
 from ....manager.Kathara import Kathara
diff --git a/src/Kathara/cli/ui/setting/DockerOptionsHandler.py b/src/Kathara/cli/ui/setting/DockerOptionsHandler.py
index 45f32cb2..456ed765 100644
--- a/src/Kathara/cli/ui/setting/DockerOptionsHandler.py
+++ b/src/Kathara/cli/ui/setting/DockerOptionsHandler.py
@@ -67,6 +67,39 @@ def add_items(self, current_menu: ConsoleMenu, menu_formatter: MenuFormatBuilder
 
         shared_item = SubmenuItem(shared_string, shared_menu, current_menu)
 
+        # Image Update Policy Option
+        image_update_policy_string = "Docker Image Update Policy"
+        image_update_policy_menu = SelectionMenu(strings=[],
+                                                 title=image_update_policy_string,
+                                                 subtitle=setting_utils.current_string("image_update_policy"),
+                                                 prologue_text="""Choose the policy when a Docker image update """
+                                                               """is available for a running device.             
+
+                                                               Default is %s.""" % DEFAULTS['image_update_policy'],
+                                                 formatter=menu_formatter
+                                                 )
+
+        image_update_policy_menu.append_item(FunctionItem(text="Prompt",
+                                                          function=setting_utils.update_setting_value,
+                                                          args=["image_update_policy", "Prompt"],
+                                                          should_exit=True
+                                                          )
+                                             )
+        image_update_policy_menu.append_item(FunctionItem(text="Always",
+                                                          function=setting_utils.update_setting_value,
+                                                          args=["image_update_policy", "Always"],
+                                                          should_exit=True
+                                                          )
+                                             )
+        image_update_policy_menu.append_item(FunctionItem(text="Never",
+                                                          function=setting_utils.update_setting_value,
+                                                          args=["image_update_policy", "Never"],
+                                                          should_exit=True
+                                                          )
+                                             )
+
+        image_update_policy_item = SubmenuItem(image_update_policy_string, image_update_policy_menu, current_menu)
+
         # Shared Links Option
         shared_cd_string = "Enable Shared Collision Domains between users"
         shared_cd_menu = SelectionMenu(strings=[],
@@ -102,7 +135,8 @@ def remote_url_unix():
                                             title=remote_url_string,
                                             subtitle=setting_utils.current_string("remote_url"),
                                             prologue_text="""You can specify a remote Docker Daemon URL.
-                                            Default is %s.""" % DEFAULTS['remote_url'],
+                                            
+                                                          Default is %s.""" % DEFAULTS['remote_url'],
                                             formatter=menu_formatter
                                             )
 
@@ -126,8 +160,10 @@ def remote_url_unix():
             cert_path_menu = SelectionMenu(strings=[],
                                            title=cert_path_string,
                                            subtitle=setting_utils.current_string("cert_path"),
-                                           prologue_text="""When using a remote Docker Daemon, a TLS Cert could be required.
-                                           Default is %s.""" % DEFAULTS['cert_path'],
+                                           prologue_text="""When using a remote Docker Daemon, a TLS Cert could be """
+                                                         """required.
+                                                         
+                                                            Default is %s.""" % DEFAULTS['cert_path'],
                                            formatter=menu_formatter
                                            )
 
@@ -164,6 +200,7 @@ def remote_url_unix():
 
         current_menu.append_item(hosthome_item)
         current_menu.append_item(shared_item)
+        current_menu.append_item(image_update_policy_item)
         current_menu.append_item(shared_cd_item)
         if remote_url_item:
             current_menu.append_item(remote_url_item)
diff --git a/src/Kathara/cli/ui/setting/utils.py b/src/Kathara/cli/ui/setting/utils.py
index 603c893e..8b4ee0ee 100644
--- a/src/Kathara/cli/ui/setting/utils.py
+++ b/src/Kathara/cli/ui/setting/utils.py
@@ -49,7 +49,7 @@ def update_setting_value(attribute_name: str, value: Any, stdout: bool = True) -
 
         Setting.get_instance().check()
 
-        Setting.get_instance().save()
+        Setting.get_instance().save_to_disk()
 
         if stdout:
             print(SAVED_STRING)
@@ -97,7 +97,7 @@ def read_value(attribute_name: str, validator: BaseValidator, prompt_msg: str, e
         return
 
     setattr(Setting.get_instance(), attribute_name, answer.input_string)
-    Setting.get_instance().save()
+    Setting.get_instance().save_to_disk()
 
     print(SAVED_STRING)
 
diff --git a/src/Kathara/cli/ui/utils.py b/src/Kathara/cli/ui/utils.py
new file mode 100644
index 00000000..80a2e16e
--- /dev/null
+++ b/src/Kathara/cli/ui/utils.py
@@ -0,0 +1,138 @@
+import logging
+import subprocess
+import sys
+from datetime import datetime
+from typing import Any, Dict, Generator
+from typing import Callable
+
+from terminaltables import DoubleTable
+
+from ... import utils
+from ...foundation.manager.stats.IMachineStats import IMachineStats
+from ...setting.Setting import Setting
+from ...trdparty.consolemenu import PromptUtils, Screen
+
+FORBIDDEN_TABLE_COLUMNS = ["container_name"]
+
+
+def confirmation_prompt(prompt_string: str, callback_yes: Callable, callback_no: Callable) -> Any:
+    prompt_utils = PromptUtils(Screen())
+    answer = prompt_utils.prompt_for_bilateral_choice(prompt_string, 'y', 'n')
+
+    if answer == "n":
+        return callback_no()
+
+    return callback_yes()
+
+
+def format_headers(message: str = "") -> str:
+    footer = "=============================="
+    half_message = int((len(message) / 2) + 1)
+    second_half_message = half_message
+
+    if len(message) % 2 == 0:
+        second_half_message -= 1
+
+    message = " " + message + " " if message != "" else "=="
+    return footer[half_message:] + message + footer[second_half_message:]
+
+
+def create_table(streams: Generator[Dict[str, IMachineStats], None, None]) -> \
+        Generator[str, None, None]:
+    table = DoubleTable([])
+    table.inner_row_border = True
+
+    while True:
+        try:
+            result = next(streams)
+        except StopIteration:
+            return
+
+        if not result:
+            return
+
+        table.table_data = []
+        for item in result.values():
+            row_data = item.to_dict()
+            row_data = dict(filter(lambda x: x[0] not in FORBIDDEN_TABLE_COLUMNS, row_data.items()))
+
+            if not table.table_data:
+                table.table_data.append(list(map(lambda x: x.replace('_', ' ').upper(), row_data.keys())))
+
+            table.table_data.append(row_data.values())
+
+        yield "TIMESTAMP: %s" % datetime.now() + "\n\n" + table.table
+
+
+def open_machine_terminal(machine) -> None:
+    """Connect to the device with the terminal specified in the settings.
+
+    Returns:
+        None
+    """
+    Setting.get_instance().check_terminal()
+    terminal = Setting.get_instance().terminal
+
+    logging.debug("Opening terminal for device %s.", machine.name)
+
+    executable_path = utils.get_executable_path(sys.argv[0])
+
+    if not executable_path:
+        raise Exception("Unable to find Kathara.")
+
+    is_vmachine = "-v" if machine.lab.path is None else ""
+    connect_command = "%s connect %s -l %s" % (executable_path, is_vmachine, machine.name)
+
+    logging.debug("Terminal will open in directory %s." % machine.lab.path)
+
+    def unix_connect() -> None:
+        if terminal == "TMUX":
+            from ...trdparty.libtmux.tmux import TMUX
+
+            logging.debug("Attaching `%s` to TMUX session `%s` with command `%s`" % (machine.name, machine.lab.name,
+                                                                                     connect_command))
+
+            TMUX.get_instance().add_window(machine.lab.name, machine.name, connect_command, cwd=machine.lab.path)
+        else:
+            logging.debug("Opening Linux terminal with command: %s." % connect_command)
+
+            # Command should be passed as an array
+            # https://stackoverflow.com/questions/9935151/popen-error-errno-2-no-such-file-or-directory/9935511
+            subprocess.Popen([terminal, "-e", connect_command],
+                             cwd=machine.lab.path,
+                             start_new_session=True
+                             )
+
+    def windows_connect() -> None:
+        complete_win_command = "& %s" % connect_command
+        logging.debug("Opening Windows terminal with command: %s." % complete_win_command)
+        subprocess.Popen(["powershell.exe",
+                          '-Command',
+                          complete_win_command
+                          ],
+                         creationflags=subprocess.CREATE_NEW_CONSOLE,
+                         cwd=machine.lab.path
+                         )
+
+    def osx_connect() -> None:
+        cd_to_lab_path = "cd \"%s\" &&" % machine.lab.path if machine.lab.path is not None else ""
+        complete_osx_command = "%s clear && %s && exit" % (cd_to_lab_path, connect_command)
+
+        if terminal == "TMUX":
+            from ...trdparty.libtmux.tmux import TMUX
+
+            logging.debug("Attaching `%s` to TMUX session `%s` with command `%s`" % (machine.name, machine.lab.name,
+                                                                                     complete_osx_command))
+
+            TMUX.get_instance().add_window(machine.lab.name, machine.name, complete_osx_command, cwd=machine.lab.path)
+        else:
+            import appscript
+            logging.debug("Opening OSX terminal with command: %s." % complete_osx_command)
+            terminal_app = appscript.app(terminal)
+            if terminal == 'iTerm':
+                window = terminal_app.create_window_with_default_profile()
+                window.current_session.write(text=complete_osx_command)
+            elif terminal == 'Terminal':
+                terminal_app.do_script(complete_osx_command)
+
+    utils.exec_by_platform(unix_connect, windows_connect, osx_connect)
diff --git a/src/Kathara/event/EventDispatcher.py b/src/Kathara/event/EventDispatcher.py
new file mode 100644
index 00000000..79f9e195
--- /dev/null
+++ b/src/Kathara/event/EventDispatcher.py
@@ -0,0 +1,89 @@
+from __future__ import annotations
+
+from typing import List, Callable, Any, Optional
+
+
+class EventDispatcher(object):
+    """Class implementing an event dispatcher for custom events."""
+    __slots__ = ['events']
+
+    __instance: EventDispatcher = None
+
+    @staticmethod
+    def get_instance() -> EventDispatcher:
+        """Get an instance of the Dispatcher.
+
+        Returns:
+            EventDispatcher: instance of the class.
+        """
+        if EventDispatcher.__instance is None:
+            EventDispatcher()
+
+        return EventDispatcher.__instance
+
+    def __init__(self) -> None:
+        if EventDispatcher.__instance is not None:
+            raise Exception("This class is a singleton!")
+        else:
+            self.events = {}
+
+            EventDispatcher.__instance = self
+
+    def get_subscribers(self, event: str) -> List[Callable]:
+        """Return subscribers of a given event.
+
+        Args:
+            event (str): Name of the event.
+
+        Returns:
+            List[Callable]: Subscribed callbacks for the event.
+        """
+        return self.events[event]
+
+    def register(self, event: str, obj: Any, method: Optional[str] = None) -> None:
+        """Register a subscriber for a given event. Subscriber is composed of a class instance and
+        an optional method name. If method name is not provided, by default the `run` method is searched.
+
+        Args:
+            event (str): Name of the event.
+            obj (Any): instance of a class of a subscriber.
+            method (Optional[str]): method name of the subscriber to run as callback. If method name is not provided,
+                by default the `run` method is searched.
+
+        Returns:
+            None
+        """
+        if event not in self.events:
+            self.events[event] = []
+
+        self.get_subscribers(event).append(getattr(obj, 'run' if not method else method))
+
+    def dispatch(self, event: str, **kwargs: Any) -> None:
+        """Dispatch a given event.
+
+        Args:
+            event (str): Name of the event.
+            kwargs (Any): Arguments to pass to the event callback.
+
+        Returns:
+            None
+        """
+        if event not in self.events:
+            return
+
+        for callback in self.get_subscribers(event):
+            callback(**kwargs)
+
+    def unregister(self, event: str) -> None:
+        """Unregister all callbacks of a given event.
+
+        Args:
+            event (str): Name of the event.
+
+        Returns:
+            None
+        """
+        if event not in self.events:
+            return
+
+        del self.events[event]
diff --git a/src/Kathara/event/__init__.py b/src/Kathara/event/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/Kathara/exceptions.py b/src/Kathara/exceptions.py
index 18c778f6..6e7e794a 100644
--- a/src/Kathara/exceptions.py
+++ b/src/Kathara/exceptions.py
@@ -7,11 +7,16 @@ class HTTPConnectionError(Exception):
     pass
 
 
+# Settings Exceptions
 class SettingsError(Exception):
     def __init__(self, message) -> None:
         super().__init__("Settings file is not valid: %s\nFix it or delete it before launching." % message)
 
 
+class SettingsNotFound(Exception):
+    pass
+
+
 class DockerDaemonConnectionError(Exception):
     pass
 
diff --git a/src/Kathara/foundation/manager/IManager.py b/src/Kathara/foundation/manager/IManager.py
index b3f006a9..9ffb1c7d 100644
--- a/src/Kathara/foundation/manager/IManager.py
+++ b/src/Kathara/foundation/manager/IManager.py
@@ -1,16 +1,18 @@
 import io
 from abc import ABC, abstractmethod
-from typing import Dict, Set, Any, Generator, Tuple, List
+from typing import Dict, Set, Any, Generator, Tuple, List, Optional
 
+from .stats.ILinkStats import ILinkStats
+from .stats.IMachineStats import IMachineStats
 from ...model.Lab import Lab
 from ...model.Machine import Machine
 
 
 class IManager(ABC):
+    """Interface to be implemented in the virtualization managers"""
     @abstractmethod
     def deploy_lab(self, lab: Lab, selected_machines: Set[str] = None) -> None:
-        """
-        Deploy a Kathara network scenario.
+        """Deploy a Kathara network scenario.
 
         Args:
             lab (Kathara.model.Lab): A Kathara network scenario.
@@ -23,8 +25,7 @@ def deploy_lab(self, lab: Lab, selected_machines: Set[str] = None) -> None:
 
     @abstractmethod
     def update_lab(self, lab: Lab) -> None:
-        """
-        Update a running network scenario.
+        """Update a running network scenario.
 
         Args:
             lab (Kathara.model.Lab): A Kathara network scenario.
@@ -35,27 +36,32 @@ def update_lab(self, lab: Lab) -> None:
         raise NotImplementedError("You must implement `update_lab` method.")
 
     @abstractmethod
-    def undeploy_lab(self, lab_hash: str, selected_machines: Set[str] = None) -> None:
-        """
-        Undeploy a Kathara network scenario.
+    def undeploy_lab(self, lab_hash: Optional[str] = None, lab_name: Optional[str] = None,
+                     selected_machines: Optional[Set[str]] = None) -> None:
+        """Undeploy a Kathara network scenario.
 
         Args:
-            lab_hash (str): The hash of the network scenario to undeploy.
-            selected_machines (Set[str]): If not None, undeploy only the specified devices.
+            lab_hash (Optional[str]): The hash of the network scenario. Can be used as an alternative to lab_name.
+                If None, lab_name should be set.
+            lab_name (Optional[str]): The name of the network scenario. Can be used as an alternative to lab_hash.
+                If None, lab_hash should be set.
+            selected_machines (Optional[Set[str]]): If not None, undeploy only the specified devices.
 
         Returns:
             None
+
+        Raises:
+            Exception: You must specify a running network scenario hash or name.
         """
         raise NotImplementedError("You must implement `undeploy_lab` method.")
 
     @abstractmethod
     def wipe(self, all_users: bool = False) -> None:
-        """
-        Undeploy all the running network scenarios.
+        """Undeploy all the running network scenarios.
 
         Args:
-        all_users (bool): If false, undeploy only the current user network scenarios. If true, undeploy the
-           running network scenarios of all users.
+            all_users (bool): If false, undeploy only the current user network scenarios. If true, undeploy the
+                running network scenarios of all users.
 
         Returns:
             None
@@ -63,42 +69,50 @@ def wipe(self, all_users: bool = False) -> None:
         raise NotImplementedError("You must implement `wipe` method.")
 
     @abstractmethod
-    def connect_tty(self, lab_hash: str, machine_name: str, shell: str = None, logs: bool = False) -> None:
-        """
-        Connect to a device in a running network scenario, using the specified shell.
+    def connect_tty(self, machine_name: str, lab_hash: Optional[str] = None, lab_name: Optional[str] = None,
+                    shell: str = None, logs: bool = False) -> None:
+        """Connect to a device in a running network scenario, using the specified shell.
 
         Args:
-            lab_hash (str): The hash of the network scenario to undeploy.
             machine_name (str): The name of the device to connect.
+            lab_hash (str): The hash of the network scenario where the device is deployed.
+            lab_name (str): The name of the network scenario where the device is deployed.
             shell (str): The name of the shell to use for connecting.
             logs (bool): If True, print startup logs on stdout.
 
         Returns:
             None
+
+        Raises:
+            Exception: You must specify a running network scenario hash or name.
         """
         raise NotImplementedError("You must implement `connect_tty` method.")
 
     @abstractmethod
-    def exec(self, lab_hash: str, machine_name: str, command: str) -> Generator[Tuple[bytes, bytes], None, None]:
-        """
-        Exec a command on a device in a running network scenario.
+    def exec(self, machine_name: str, command: str, lab_hash: Optional[str] = None, lab_name: Optional[str] = None) -> \
+            Generator[Tuple[bytes, bytes], None, None]:
+        """Exec a command on a device in a running network scenario.
+
         Args:
-            lab_hash (str): The hash of the network scenario to undeploy.
             machine_name (str): The name of the device to connect.
             command (str): The command to exec on the device.
+            lab_hash (Optional[str]): The hash of the network scenario where the device is deployed.
+            lab_name (Optional[str]): The name of the network scenario where the device is deployed.
 
         Returns:
             Generator[Tuple[bytes, bytes]]: A generator of tuples containing the stdout and stderr in bytes.
+
+        Raises:
+            Exception: You must specify a running network scenario hash or name.
         """
         raise NotImplementedError("You must implement `exec` method.")
 
     @abstractmethod
     def copy_files(self, machine: Machine, guest_to_host: Dict[str, io.IOBase]) -> None:
-        """
-        Copy files on a running device in the specified paths.
+        """Copy files on a running device in the specified paths.
 
         Args:
-            machine (Kathara.model.Machine): A running machine object. It must have the api_object field populated.
+            machine (Kathara.model.Machine): A running device object. It must have the api_object field populated.
             guest_to_host (Dict[str, io.IOBase]): A dict containing the device path as key and
                 fileobj to copy in path as value.
 
@@ -108,86 +122,144 @@ def copy_files(self, machine: Machine, guest_to_host: Dict[str, io.IOBase]) -> N
         raise NotImplementedError("You must implement `copy_files` method.")
 
     @abstractmethod
-    def get_lab_info(self, lab_hash: str = None, machine_name: str = None, all_users: bool = False) -> \
-            Generator[Dict[str, Any], None, None]:
-        """
-        Return information about the running devices.
+    def get_machine_api_object(self, machine_name: str, lab_hash: str = None, lab_name: str = None,
+                               all_users: bool = False) -> Any:
+        """Return the corresponding API object of a running device in a network scenario.
 
         Args:
-            lab_hash (str): If not None, return information of the corresponding network scenario.
-            machine_name (str): If not None, return information of the specified device.
-            all_users (bool): If True, return information about the device of all users.
+            machine_name (str): The name of the device.
+            lab_hash (str): The hash of the network scenario. Can be used as an alternative to lab_name.
+                If None, lab_name should be set.
+            lab_name (str): The name of the network scenario. Can be used as an alternative to lab_name.
+                If None, lab_hash should be set.
+            all_users (bool): If True, return information about devices of all users.
 
         Returns:
-              Generator[Dict[str, Any], None, None]: A generator containing dicts containing device names as keys and
-              their info as values.
+            Any: API object of the device specific for the current manager.
         """
-        raise NotImplementedError("You must implement `get_lab_info` method.")
+        raise NotImplementedError("You must implement `get_machine_api_object` method.")
 
     @abstractmethod
-    def get_formatted_lab_info(self, lab_hash: str = None, machine_name: str = None, all_users: bool = False) -> str:
-        """
-        Return a formatted string with the information about the running devices.
+    def get_machines_api_objects(self, lab_hash: str = None, lab_name: str = None, all_users: bool = False) \
+            -> List[Any]:
+        """Return API objects of running devices.
 
         Args:
-            lab_hash (str): If not None, return information of the corresponding network scenario.
-            machine_name (str): If not None, return information of the specified device.
-            all_users (bool): If True, return information about the device of all users.
+            lab_hash (str): The hash of the network scenario. Can be used as an alternative to lab_name.
+            lab_name (str): The name of the network scenario. Can be used as an alternative to lab_name.
+            all_users (bool): If True, return information about devices of all users.
 
         Returns:
-             str: String containing devices info
+            List[Any]: API objects of devices, specific for the current manager.
         """
-        raise NotImplementedError("You must implement `get_formatted_lab_info` method.")
+        raise NotImplementedError("You must implement `get_machines_api_objects` method.")
 
     @abstractmethod
-    def get_machine_api_object(self, lab_hash: str, machine_name: str) -> Any:
+    def get_link_api_object(self, link_name: str, lab_hash: str = None, lab_name: str = None,
+                            all_users: bool = False) -> Any:
+        """Return the corresponding API object of a collision domain in a network scenario.
+
+        Args:
+            link_name (str): The name of the collision domain.
+            lab_hash (str): The hash of the network scenario. Can be used as an alternative to lab_name.
+                If None, lab_name should be set.
+            lab_name (str): The name of the network scenario. Can be used as an alternative to lab_name.
+                If None, lab_hash should be set.
+            all_users (bool): If True, return information about collision domains of all users.
+
+        Returns:
+            Any: API object of the collision domain specific for the current manager.
         """
-        Return the corresponding API object of a running device in a network scenario.
+        raise NotImplementedError("You must implement `get_link_api_object` method.")
+
+    @abstractmethod
+    def get_links_api_objects(self, lab_hash: str = None, lab_name: str = None, all_users: bool = False) -> List[Any]:
+        """Return API objects of collision domains in a network scenario.
 
         Args:
-            lab_hash (str): The hash of the network scenario.
-            machine_name (str): The name of the device.
+            lab_hash (str): The hash of the network scenario. Can be used as an alternative to lab_name.
+            lab_name (str): The name of the network scenario. Can be used as an alternative to lab_name.
+            all_users (bool): If True, return information about collision domains of all users.
 
         Returns:
-            Any: The Api object of the device specific for the current manager.
+            List[Any]: API objects of collision domains, specific for the current manager.
         """
-        raise NotImplementedError("You must implement `get_machine_api_object` method.")
+        raise NotImplementedError("You must implement `get_links_api_objects` method.")
 
     @abstractmethod
-    def get_machine_info(self, machine_name: str, lab_hash: str = None, all_users: bool = False) \
-            -> List[Dict[str, Any]]:
+    def get_machines_stats(self, lab_hash: str = None, lab_name: str = None, machine_name: str = None,
+                           all_users: bool = False) -> Generator[Dict[str, IMachineStats], None, None]:
+        """Return information about the running devices.
+
+        Args:
+            lab_hash (str): The hash of the network scenario. Can be used as an alternative to lab_name.
+            lab_name (str): The name of the network scenario. Can be used as an alternative to lab_hash.
+            machine_name (str): If specified return all the devices with machine_name.
+            all_users (bool): If True, return information about the device of all users.
+
+        Returns:
+              Generator[Dict[str, IMachineStats], None, None]: A generator containing dicts that has API Object
+              identifier as keys and IMachineStats objects as values.
         """
-        Return information of running devices with a specified name.
+        raise NotImplementedError("You must implement `get_machines_stats` method.")
+
+    @abstractmethod
+    def get_machine_stats(self, machine_name: str, lab_hash: str = None,
+                          lab_name: str = None, all_users: bool = False) -> Generator[IMachineStats, None, None]:
+        """Return information of the specified device in a specified network scenario.
 
         Args:
             machine_name (str): The device name.
-            lab_hash (str): If not None, search the device in the specified network scenario.
+            lab_hash (str): The hash of the network scenario. Can be used as an alternative to lab_name.
+                If None, lab_name should be set.
+            lab_name (str): The name of the network scenario. Can be used as an alternative to lab_hash.
+                If None, lab_hash should be set.
             all_users (bool): If True, search the device among all the users devices.
 
         Returns:
-            List[Dict[str, Any]]: A list of dicts containing the devices info.
+            IMachineStats: IMachineStats object containing the device info.
         """
         raise NotImplementedError("You must implement `get_machine_info` method.")
 
     @abstractmethod
-    def get_formatted_machine_info(self, machine_name: str, lab_hash: str = None, all_users: bool = False) -> str:
+    def get_links_stats(self, lab_hash: str = None, lab_name: str = None, link_name: str = None,
+                        all_users: bool = False) -> Generator[Dict[str, ILinkStats], None, None]:
+        """Return information about deployed networks.
+
+        Args:
+           lab_hash (str): The hash of the network scenario. Can be used as an alternative to lab_name.
+           lab_name (str): The name of the network scenario. Can be used as an alternative to lab_hash.
+           link_name (str): If specified return all the networks with link_name.
+           all_users (bool): If True, return information about the networks of all users.
+
+        Returns:
+             Generator[Dict[str, ILinkStats], None, None]: A generator containing dicts that has API Object
+             identifier as keys and ILinksStats objects as values.
         """
-        Return formatted information of running devices with a specified name.
+        raise NotImplementedError("You must implement `get_links_stats` method.")
+
+    @abstractmethod
+    def get_link_stats(self, link_name: str, lab_hash: str = None, lab_name: str = None, all_users: bool = False) -> \
+            Generator[ILinkStats, None, None]:
+        """Return information of the specified deployed network in a specified network scenario.
 
         Args:
-            machine_name (str): The device name.
-            lab_hash (str): If not None, search the device in the specified network scenario.
-            all_users (bool): If True, search the device among all the users devices.
+           link_name (str): If specified return all the networks with link_name.
+           lab_hash (str): The hash of the network scenario. Can be used as an alternative to lab_name.
+                If None, lab_name should be set.
+           lab_name (str): The name of the network scenario. Can be used as an alternative to lab_hash.
+                If None, lab_hash should be set.
+           all_users (bool): If True, return information about the networks of all users.
 
         Returns:
-            str: The formatted devices properties.
+             Generator[Dict[str, ILinkStats], None, None]: A generator containing dicts that has API Object
+             identifier as keys and ILinksStats objects as values.
         """
-        raise NotImplementedError("You must implement `get_formatted_machine_info` method.")
+        raise NotImplementedError("You must implement `get_link_stats` method.")
 
     @abstractmethod
     def check_image(self, image_name: str) -> None:
-        """
-        Check if the specified image is valid.
+        """Check if the specified image is valid.
 
         Args:
             image_name (str): The name of the image
@@ -203,8 +275,7 @@ def check_image(self, image_name: str) -> None:
 
     @abstractmethod
     def get_release_version(self) -> str:
-        """
-        Return the current manager version.
+        """Return the current manager version.
 
         Returns:
             str: The current manager version.
@@ -213,8 +284,7 @@ def get_release_version(self) -> str:
 
     @staticmethod
     def get_formatted_manager_name() -> str:
-        """
-        Return a formatted string containing the current manager name.
+        """Return a formatted string containing the current manager name.
 
         Returns:
             str: A formatted string containing the current manager name.
diff --git a/src/Kathara/foundation/manager/stats/ILinkStats.py b/src/Kathara/foundation/manager/stats/ILinkStats.py
new file mode 100644
index 00000000..8284c071
--- /dev/null
+++ b/src/Kathara/foundation/manager/stats/ILinkStats.py
@@ -0,0 +1,24 @@
+from abc import ABC, abstractmethod
+from typing import Dict, Any
+
+
+class ILinkStats(ABC):
+    """Interface for handling the statistics of deployed collision domains"""
+
+    @abstractmethod
+    def update(self) -> None:
+        """Update dynamic statistics with the current ones.
+
+        Returns:
+            None
+        """
+        raise NotImplementedError("You must implement `update` method.")
+
+    @abstractmethod
+    def to_dict(self) -> Dict[str, Any]:
+        """Transform statistics into a dict representation.
+
+        Returns:
+            Dict[str, Any]: Dict containing statistics.
+        """
+        raise NotImplementedError("You must implement `to_dict` method.")
diff --git a/src/Kathara/foundation/manager/stats/IMachineStats.py b/src/Kathara/foundation/manager/stats/IMachineStats.py
new file mode 100644
index 00000000..a60ccf8b
--- /dev/null
+++ b/src/Kathara/foundation/manager/stats/IMachineStats.py
@@ -0,0 +1,24 @@
+from abc import ABC, abstractmethod
+from typing import Any, Dict
+
+
+class IMachineStats(ABC):
+    """Interface for handling the statistics of deployed devices"""
+
+    @abstractmethod
+    def update(self) -> None:
+        """Update dynamic statistics with the current ones.
+
+        Returns:
+            None
+        """
+        raise NotImplementedError("You must implement `update` method.")
+
+    @abstractmethod
+    def to_dict(self) -> Dict[str, Any]:
+        """Transform statistics into a dict representation.
+
+        Returns:
+            Dict[str, Any]: Dict containing statistics.
+        """
+        raise NotImplementedError("You must implement `to_dict` method.")
diff --git a/src/Kathara/foundation/manager/stats/__init__.py b/src/Kathara/foundation/manager/stats/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/Kathara/manager/Kathara.py b/src/Kathara/manager/Kathara.py
index 731b8482..991fc0c6 100644
--- a/src/Kathara/manager/Kathara.py
+++ b/src/Kathara/manager/Kathara.py
@@ -1,27 +1,29 @@
 from __future__ import annotations
 
 import io
-from typing import Set, Dict, Generator, Any, Tuple, List
+from typing import Set, Dict, Generator, Any, Tuple, List, Optional
 
 from ..foundation.manager.IManager import IManager
 from ..foundation.manager.ManagerFactory import ManagerFactory
+from ..foundation.manager.stats.ILinkStats import ILinkStats
+from ..foundation.manager.stats.IMachineStats import IMachineStats
 from ..model.Lab import Lab
 from ..model.Machine import Machine
 from ..setting.Setting import Setting, AVAILABLE_MANAGERS
 
 
 class Kathara(IManager):
-    """Facade class for interacting with Kathara. It is a proxy for the selected virtualization manager."""
+    """Facade class for interacting with Kathara. It is a proxy for the selected Manager."""
     __slots__ = ['manager']
 
     __instance: Kathara = None
 
     @staticmethod
     def get_instance() -> Kathara:
-        """Get an instance of the current Kathara Manager.
+        """Get an instance of Kathara.
     
         Returns:
-            Kathara: instance of the current Kathara Manager.
+            Kathara: instance of Kathara.
         """
         if Kathara.__instance is None:
             Kathara()
@@ -63,62 +65,79 @@ def update_lab(self, lab: Lab) -> None:
         """
         self.manager.update_lab(lab)
 
-    def undeploy_lab(self, lab_hash: str, selected_machines: Set[str] = None) -> None:
+    def undeploy_lab(self, lab_hash: Optional[str] = None, lab_name: Optional[str] = None,
+                     selected_machines: Optional[Set[str]] = None) -> None:
         """Undeploy a Kathara network scenario.
 
         Args:
-            lab_hash (str): The hash of the network scenario to undeploy.
-            selected_machines (Set[str]): If not None, undeploy only the specified devices.
+            lab_hash (Optional[str]): The hash of the network scenario. Can be used as an alternative to lab_name.
+                If None, lab_name should be set.
+            lab_name (Optional[str]): The name of the network scenario. Can be used as an alternative to lab_hash.
+                If None, lab_hash should be set.
+            selected_machines (Optional[Set[str]]): If not None, undeploy only the specified devices.
 
         Returns:
             None
+
+        Raises:
+            Exception: You must specify a running network scenario hash or name.
         """
-        self.manager.undeploy_lab(lab_hash, selected_machines)
+        self.manager.undeploy_lab(lab_hash, lab_name, selected_machines)
 
     def wipe(self, all_users: bool = False) -> None:
         """Undeploy all the running network scenarios.
 
         Args:
-        all_users (bool): If false, undeploy only the current user network scenarios. If true, undeploy the
-           running network scenarios of all users.
+            all_users (bool): If false, undeploy only the current user network scenarios. If true, undeploy the
+                running network scenarios of all users.
 
         Returns:
             None
         """
         self.manager.wipe(all_users)
 
-    def connect_tty(self, lab_hash: str, machine_name: str, shell: str = None, logs: bool = False) -> None:
+    def connect_tty(self, machine_name: str, lab_hash: Optional[str] = None, lab_name: Optional[str] = None,
+                    shell: str = None, logs: bool = False) -> None:
         """Connect to a device in a running network scenario, using the specified shell.
 
         Args:
-            lab_hash (str): The hash of the network scenario to undeploy.
             machine_name (str): The name of the device to connect.
+            lab_hash (str): The hash of the network scenario where the device is deployed.
+            lab_name (str): The name of the network scenario where the device is deployed.
             shell (str): The name of the shell to use for connecting.
             logs (bool): If True, print startup logs on stdout.
 
         Returns:
             None
+
+        Raises:
+            Exception: You must specify a running network scenario hash or name.
         """
-        self.manager.connect_tty(lab_hash, machine_name, shell, logs)
+        self.manager.connect_tty(machine_name, lab_hash, lab_name, shell, logs)
 
-    def exec(self, lab_hash: str, machine_name: str, command: str) -> Generator[Tuple[bytes, bytes], None, None]:
+    def exec(self, machine_name: str, command: str, lab_hash: Optional[str] = None, lab_name: Optional[str] = None) -> \
+            Generator[Tuple[bytes, bytes], None, None]:
         """Exec a command on a device in a running network scenario.
 
         Args:
-            lab_hash (str): The hash of the network scenario to undeploy.
             machine_name (str): The name of the device to connect.
             command (str): The command to exec on the device.
+            lab_hash (Optional[str]): The hash of the network scenario where the device is deployed.
+            lab_name (Optional[str]): The name of the network scenario where the device is deployed.
 
         Returns:
             Generator[Tuple[bytes, bytes]]: A generator of tuples containing the stdout and stderr in bytes.
+
+        Raises:
+            Exception: You must specify a running network scenario hash or name.
         """
-        return self.manager.exec(lab_hash, machine_name, command)
+        return self.manager.exec(machine_name, command, lab_hash, lab_name)
 
     def copy_files(self, machine: Machine, guest_to_host: Dict[str, io.IOBase]) -> None:
         """Copy files on a running device in the specified paths.
 
         Args:
-            machine (Kathara.model.Machine): A running machine object. It must have the api_object field populated.
+            machine (Kathara.model.Machine): A running device object. It must have the api_object field populated.
             guest_to_host (Dict[str, io.IOBase]): A dict containing the device path as key and
                 fileobj to copy in path as value.
 
@@ -127,72 +146,139 @@ def copy_files(self, machine: Machine, guest_to_host: Dict[str, io.IOBase]) -> N
         """
         self.manager.copy_files(machine, guest_to_host)
 
-    def get_lab_info(self, lab_hash: str = None, machine_name: str = None, all_users: bool = False) -> \
-            Generator[Dict[str, Any], None, None]:
-        """Return information about the running devices.
+    def get_machine_api_object(self, machine_name: str, lab_hash: str = None, lab_name: str = None,
+                               all_users: bool = False) -> Any:
+        """Return the corresponding API object of a running device in a network scenario.
 
         Args:
-            lab_hash (str): If not None, return information of the corresponding network scenario.
-            machine_name (str): If not None, return information of the specified device.
-            all_users (bool): If True, return information about the device of all users.
+            machine_name (str): The name of the device.
+            lab_hash (str): The hash of the network scenario. Can be used as an alternative to lab_name.
+                If None, lab_name should be set.
+            lab_name (str): The name of the network scenario. Can be used as an alternative to lab_name.
+                If None, lab_hash should be set.
+            all_users (bool): If True, return information about devices of all users.
 
         Returns:
-              Generator[Dict[str, Any], None, None]: A generator containing dicts containing device names as keys and
-              their info as values.
+            Any: API object of the device specific for the current manager.
         """
-        return self.manager.get_lab_info(lab_hash, machine_name, all_users)
+        return self.manager.get_machine_api_object(machine_name, lab_hash, lab_name, all_users)
 
-    def get_formatted_lab_info(self, lab_hash: str = None, machine_name: str = None, all_users: bool = False) -> str:
-        """Return a formatted string with the information about the running devices.
+    def get_machines_api_objects(self, lab_hash: str = None, lab_name: str = None, all_users: bool = False) \
+            -> List[Any]:
+        """Return API objects of running devices.
 
         Args:
-            lab_hash (str): If not None, return information of the corresponding network scenario.
-            machine_name (str): If not None, return information of the specified device.
-            all_users (bool): If True, return information about the device of all users.
+            lab_hash (str): The hash of the network scenario. Can be used as an alternative to lab_name.
+            lab_name (str): The name of the network scenario. Can be used as an alternative to lab_name.
+            all_users (bool): If True, return information about devices of all users.
 
         Returns:
-             str: String containing devices info
+            List[Any]: API objects of devices, specific for the current manager.
         """
-        return self.manager.get_formatted_lab_info(lab_hash, machine_name, all_users)
+        return self.manager.get_machines_api_objects(lab_hash, lab_name, all_users)
 
-    def get_machine_api_object(self, lab_hash: str, machine_name: str) -> Any:
-        """Return the corresponding API object of a running device in a network scenario.
+    def get_link_api_object(self, link_name: str, lab_hash: str = None, lab_name: str = None,
+                            all_users: bool = False) -> Any:
+        """Return the corresponding API object of a collision domain in a network scenario.
 
         Args:
-            lab_hash (str): The hash of the network scenario.
-            machine_name (str): The name of the device.
+            link_name (str): The name of the collision domain.
+            lab_hash (str): The hash of the network scenario. Can be used as an alternative to lab_name.
+                If None, lab_name should be set.
+            lab_name (str): The name of the network scenario. Can be used as an alternative to lab_name.
+                If None, lab_hash should be set.
+            all_users (bool): If True, return information about collision domains of all users.
 
         Returns:
-            Any: The Api object of the device specific for the current manager.
+            Any: API object of the collision domain specific for the current manager.
         """
-        return self.manager.get_machine_api_object(lab_hash, machine_name)
+        return self.manager.get_link_api_object(link_name, lab_hash, lab_name, all_users)
 
-    def get_machine_info(self, machine_name: str, lab_hash: str = None, all_users: bool = False) \
-            -> List[Dict[str, Any]]:
-        """Return information of running devices with a specified name.
+    def get_links_api_objects(self, lab_hash: str = None, lab_name: str = None, all_users: bool = False) -> List[Any]:
+        """Return API objects of collision domains in a network scenario.
 
         Args:
-            machine_name (str): The device name.
-            lab_hash (str): If not None, search the device in the specified network scenario.
-            all_users (bool): If True, search the device among all the users devices.
+            lab_hash (str): The hash of the network scenario. Can be used as an alternative to lab_name.
+            lab_name (str): The name of the network scenario. Can be used as an alternative to lab_name.
+            all_users (bool): If True, return information about collision domains of all users.
 
         Returns:
-            List[Dict[str, Any]]: A list of dicts containing the devices info.
+            List[Any]: API objects of collision domains, specific for the current manager.
         """
-        return self.manager.get_machine_info(machine_name, lab_hash, all_users)
+        return self.manager.get_links_api_objects(lab_hash, lab_name, all_users)
 
-    def get_formatted_machine_info(self, machine_name: str, lab_hash: str = None, all_users: bool = False) -> str:
-        """Return formatted information of running devices with a specified name.
+    def get_machines_stats(self, lab_hash: str = None, lab_name: str = None, machine_name: str = None,
+                           all_users: bool = False) -> Generator[Dict[str, IMachineStats], None, None]:
+        """Return information about the running devices.
+
+        Args:
+            lab_hash (str): The hash of the network scenario. Can be used as an alternative to lab_name.
+            lab_name (str): The name of the network scenario. Can be used as an alternative to lab_hash.
+            machine_name (str): If specified return all the devices with machine_name.
+            all_users (bool): If True, return information about the device of all users.
+
+        Returns:
+              Generator[Dict[str, IMachineStats], None, None]: A generator containing dicts that has API Object
+              identifier as keys and IMachineStats objects as values.
+        """
+        return self.manager.get_machines_stats(lab_hash, lab_name, machine_name, all_users)
+
+    def get_machine_stats(self, machine_name: str, lab_hash: str = None,
+                          lab_name: str = None, all_users: bool = False) -> Generator[IMachineStats, None, None]:
+        """Return information of the specified device in a specified network scenario.
 
         Args:
             machine_name (str): The device name.
-            lab_hash (str): If not None, search the device in the specified network scenario.
+            lab_hash (str): The hash of the network scenario. Can be used as an alternative to lab_name.
+                If None, lab_name should be set.
+            lab_name (str): The name of the network scenario. Can be used as an alternative to lab_hash.
+                If None, lab_hash should be set.
             all_users (bool): If True, search the device among all the users devices.
 
         Returns:
-            str: The formatted devices properties.
+            IMachineStats: IMachineStats object containing the device info.
+
+        Raises:
+            Exception: You must specify a running network scenario hash or name.
+        """
+        return self.manager.get_machine_stats(machine_name, lab_hash, lab_name, all_users)
+
+    def get_links_stats(self, lab_hash: str = None, lab_name: str = None, link_name: str = None,
+                        all_users: bool = False) -> Generator[Dict[str, ILinkStats], None, None]:
+        """Return information about deployed networks.
+
+        Args:
+           lab_hash (str): The hash of the network scenario. Can be used as an alternative to lab_name.
+           lab_name (str): The name of the network scenario. Can be used as an alternative to lab_hash.
+           link_name (str): If specified return all the networks with link_name.
+           all_users (bool): If True, return information about the networks of all users.
+
+        Returns:
+             Generator[Dict[str, ILinkStats], None, None]: A generator containing dicts that has API Object
+             identifier as keys and ILinksStats objects as values.
+        """
+        return self.manager.get_links_stats(lab_hash, lab_name, link_name, all_users)
+
+    def get_link_stats(self, link_name: str, lab_hash: str = None, lab_name: str = None, all_users: bool = False) -> \
+            Generator[ILinkStats, None, None]:
+        """Return information of the specified deployed network in a specified network scenario.
+
+        Args:
+           link_name (str): If specified return all the networks with link_name.
+           lab_hash (str): The hash of the network scenario. Can be used as an alternative to lab_name.
+                If None, lab_name should be set.
+           lab_name (str): The name of the network scenario. Can be used as an alternative to lab_hash.
+                If None, lab_hash should be set.
+           all_users (bool): If True, return information about the networks of all users.
+
+        Returns:
+             Generator[Dict[str, ILinkStats], None, None]: A generator containing dicts that has API Object
+             identifier as keys and ILinksStats objects as values.
+
+        Raises:
+            Exception: You must specify a running network scenario hash or name.
         """
-        return self.manager.get_formatted_machine_info(machine_name, lab_hash, all_users)
+        return self.manager.get_link_stats(link_name, lab_hash, lab_name, all_users)
 
     def check_image(self, image_name: str) -> None:
         """Check if the specified image is valid.
diff --git a/src/Kathara/manager/docker/DockerImage.py b/src/Kathara/manager/docker/DockerImage.py
index 6851fbee..f4e38813 100644
--- a/src/Kathara/manager/docker/DockerImage.py
+++ b/src/Kathara/manager/docker/DockerImage.py
@@ -5,7 +5,7 @@
 from docker import DockerClient
 from docker.errors import APIError
 
-from ... import utils
+from ...event.EventDispatcher import EventDispatcher
 
 
 class DockerImage(object):
@@ -86,14 +86,9 @@ def check_for_updates(self, image_name: str) -> None:
         (_, local_image_digest) = local_repo_digest.split("@")
         # We only need to update tagged images, not the ones with digests.
         if remote_image_digest != local_image_digest:
-            if utils.CLI_ENV:
-                utils.confirmation_prompt("A new version of image `%s` has been found on Docker Hub. "
-                                          "Do you want to pull it?" % image_name,
-                                          lambda: self.pull(image_name),
-                                          lambda: None
-                                          )
-            else:
-                self.pull(image_name)
+            EventDispatcher.get_instance().dispatch("docker_image_update_found",
+                                                    docker_image=self,
+                                                    image_name=image_name)
 
     def check(self, image_name: str) -> None:
         """Check the existence of the specified image.
diff --git a/src/Kathara/manager/docker/DockerLink.py b/src/Kathara/manager/docker/DockerLink.py
index df47c25f..f67a9830 100644
--- a/src/Kathara/manager/docker/DockerLink.py
+++ b/src/Kathara/manager/docker/DockerLink.py
@@ -1,17 +1,17 @@
 import logging
 import re
-from functools import partial
 from multiprocessing.dummy import Pool
-from typing import List, Union, Dict
+from typing import List, Union, Dict, Generator
 
 import docker
 import docker.models.networks
-import progressbar
 from docker import DockerClient
 from docker import types
 
+from .stats.DockerLinkStats import DockerLinkStats
 from ..docker.DockerPlugin import PLUGIN_NAME
 from ... import utils
+from ...event.EventDispatcher import EventDispatcher
 from ...exceptions import PrivilegeError
 from ...model.ExternalLink import ExternalLink
 from ...model.Lab import Lab
@@ -32,7 +32,7 @@ def deploy_links(self, lab: Lab, selected_links: Dict[str, Link] = None) -> None
 
         Args:
             lab (Kathara.model.Lab.Lab): A Kathara network scenario.
-            selected_links (Dict[str, Link]): Keys are Link names, values are Link objects.
+            selected_links (Dict[str, Link]): Keys are collision domains names, values are Link objects.
 
         Returns:
             None
@@ -44,31 +44,23 @@ def deploy_links(self, lab: Lab, selected_links: Dict[str, Link] = None) -> None
             link_pool = Pool(pool_size)
 
             items = utils.chunk_list(links, pool_size)
-            progress_bar = None
-            if utils.CLI_ENV:
-                progress_bar = progressbar.ProgressBar(
-                    widgets=['Deploying collision domains... ', progressbar.Bar(),
-                             ' ', progressbar.Counter(format='%(value)d/%(max_value)d')],
-                    redirect_stdout=True,
-                    max_value=len(links)
-                )
+
+            EventDispatcher.get_instance().dispatch("links_deploy_started", items=links)
 
             for chunk in items:
-                link_pool.map(func=partial(self._deploy_link, progress_bar), iterable=chunk)
+                link_pool.map(func=self._deploy_link, iterable=chunk)
 
-            if utils.CLI_ENV:
-                progress_bar.finish()
+            EventDispatcher.get_instance().dispatch("links_deploy_ended")
 
         # Create a docker bridge link in the lab object and assign the Docker Network object associated to it.
         docker_bridge = self.get_docker_bridge()
         link = lab.get_or_new_link(BRIDGE_LINK_NAME)
         link.api_object = docker_bridge
 
-    def _deploy_link(self, progress_bar: progressbar.ProgressBar, link_item: (str, Link)) -> None:
+    def _deploy_link(self, link_item: (str, Link)) -> None:
         """Deploy the collision domain contained in the link_item as a Docker network.
 
         Args:
-            progress_bar (Optional[progressbar.ProgressBar]): A progress bar object to display if used from cli.
             link_item (Tuple[str, Link]): A tuple composed by the name of the collision domain and a Link object
 
         Returns:
@@ -81,8 +73,7 @@ def _deploy_link(self, progress_bar: progressbar.ProgressBar, link_item: (str, L
 
         self.create(link)
 
-        if progress_bar is not None:
-            progress_bar += 1
+        EventDispatcher.get_instance().dispatch("link_deployed", item=link)
 
     def create(self, link: Link) -> None:
         """Create a Docker network representing the collision domain object and assign it to link.api_object.
@@ -101,9 +92,9 @@ def create(self, link: Link) -> None:
 
         # If a network with the same name exists, return it instead of creating a new one.
         link_name = self.get_network_name(link.name)
-        network_objects = self.get_links_api_objects_by_filters(link_name=link_name)
-        if network_objects:
-            link.api_object = network_objects.pop()
+        networks = self.get_links_api_objects_by_filters(link_name=link_name)
+        if networks:
+            link.api_object = networks.pop()
         else:
             network_ipam_config = docker.types.IPAMConfig(driver='null')
 
@@ -136,31 +127,23 @@ def undeploy(self, lab_hash: str) -> None:
         Returns:
             None
         """
-        links = self.get_links_api_objects_by_filters(lab_hash=lab_hash)
-        for item in links:
+        networks = self.get_links_api_objects_by_filters(lab_hash=lab_hash)
+        for item in networks:
             item.reload()
-        links = [item for item in links if len(item.containers) <= 0]
+        networks = [item for item in networks if len(item.containers) <= 0]
 
-        if len(links) > 0:
+        if len(networks) > 0:
             pool_size = utils.get_pool_size()
             links_pool = Pool(pool_size)
 
-            items = utils.chunk_list(links, pool_size)
+            items = utils.chunk_list(networks, pool_size)
 
-            progress_bar = None
-            if utils.CLI_ENV:
-                progress_bar = progressbar.ProgressBar(
-                    widgets=['Deleting collision domains... ', progressbar.Bar(),
-                             ' ', progressbar.Counter(format='%(value)d/%(max_value)d')],
-                    redirect_stdout=True,
-                    max_value=len(links)
-                )
+            EventDispatcher.get_instance().dispatch("links_undeploy_started", items=networks)
 
             for chunk in items:
-                links_pool.map(func=partial(self._undeploy_link, progress_bar), iterable=chunk)
+                links_pool.map(func=self._undeploy_link, iterable=chunk)
 
-            if utils.CLI_ENV:
-                progress_bar.finish()
+            EventDispatcher.get_instance().dispatch("links_undeploy_ended")
 
     def wipe(self, user: str = None) -> None:
         """Undeploy all the Docker networks of the specified user. If user is None, it undeploy all the Docker networks.
@@ -172,24 +155,23 @@ def wipe(self, user: str = None) -> None:
             None
         """
         user_label = "shared_cd" if Setting.get_instance().shared_cd else user
-        links = self.get_links_api_objects_by_filters(user=user_label)
-        for item in links:
+        networks = self.get_links_api_objects_by_filters(user=user_label)
+        for item in networks:
             item.reload()
-        links = [item for item in links if len(item.containers) <= 0]
+        networks = [item for item in networks if len(item.containers) <= 0]
 
         pool_size = utils.get_pool_size()
         links_pool = Pool(pool_size)
 
-        items = utils.chunk_list(links, pool_size)
+        items = utils.chunk_list(networks, pool_size)
 
         for chunk in items:
-            links_pool.map(func=partial(self._undeploy_link, None), iterable=chunk)
+            links_pool.map(func=self._undeploy_link, iterable=chunk)
 
-    def _undeploy_link(self, progress_bar: progressbar.ProgressBar, network: docker.models.networks.Network) -> None:
+    def _undeploy_link(self, network: docker.models.networks.Network) -> None:
         """Undeploy a Docker network.
 
         Args:
-            progress_bar (Optional[progressbar.ProgressBar]): A progress bar object to display if used from cli.
             network (docker.models.networks.Network): The Docker network to undeploy.
 
         Returns:
@@ -197,8 +179,7 @@ def _undeploy_link(self, progress_bar: progressbar.ProgressBar, network: docker.
         """
         self._delete_link(network)
 
-        if progress_bar is not None:
-            progress_bar += 1
+        EventDispatcher.get_instance().dispatch("link_undeployed", item=network)
 
     def get_docker_bridge(self) -> Union[None, docker.models.networks.Network]:
         """Return the Docker bridged network.
@@ -211,11 +192,11 @@ def get_docker_bridge(self) -> Union[None, docker.models.networks.Network]:
 
     def get_links_api_objects_by_filters(self, lab_hash: str = None, link_name: str = None, user: str = None) -> \
             List[docker.models.networks.Network]:
-        """Return the Docker networks specified by lab_hash, machine_name and user.
+        """Return the Docker networks specified by lab_hash and user.
 
         Args:
             lab_hash (str): The hash of a network scenario. If specified, return all the networks in the scenario.
-            link_name (str): The name of a network. If specified, return the specified networks of the scenario.
+            link_name (str): The name of a network. If specified, return the specified network of the scenario.
             user (str): The name of a user on the host. If specified, return only the networks of the user.
 
         Returns:
@@ -231,13 +212,50 @@ def get_links_api_objects_by_filters(self, lab_hash: str = None, link_name: str
 
         return self.client.networks.list(filters=filters)
 
+    def get_links_stats(self, lab_hash: str = None, link_name: str = None, user: str = None) -> \
+            Generator[Dict[str, DockerLinkStats], None, None]:
+        """Return a generator containing the Docker networks' stats.
+
+        Args:
+           lab_hash (str): The hash of a network scenario. If specified, return all the stats of the networks in the
+           scenario.
+           link_name (str): The name of a device. If specified, return the specified network stats.
+           user (str): The name of a user on the host. If specified, return only the stats of the specified user.
+
+        Returns:
+           Generator[Dict[str, DockerMachineStats], None, None]: A generator containing network names as keys and
+           DockerLinkStats as values.
+        """
+        networks = self.get_links_api_objects_by_filters(lab_hash=lab_hash, link_name=link_name, user=user)
+        if not networks:
+            if not link_name:
+                raise Exception("No collision domains found.")
+            else:
+                raise Exception(f"Collision domains with name {link_name} not found.")
+
+        networks = sorted(networks, key=lambda x: x.name)
+
+        network_streams = {}
+
+        for network in networks:
+            network_streams[network.name] = DockerLinkStats(network)
+
+        while True:
+            for network_stats in network_streams.values():
+                try:
+                    network_stats.update()
+                except StopIteration:
+                    continue
+
+            yield network_streams
+
     def _attach_external_interfaces(self, external_links: List[ExternalLink],
                                     network: docker.models.networks.Network) -> None:
         """Attach an external collision domain to a Docker network.
 
         Args:
             external_links (Kathara.model.ExternalLink): A Kathara external collision domain. It is used to create
-            a collision domain attached to a host interface.
+                a collision domain attached to a host interface.
             network (docker.models.networks.Network): A Docker network.
 
         Returns:
diff --git a/src/Kathara/manager/docker/DockerMachine.py b/src/Kathara/manager/docker/DockerMachine.py
index 31bbd8dd..c0ca6f88 100644
--- a/src/Kathara/manager/docker/DockerMachine.py
+++ b/src/Kathara/manager/docker/DockerMachine.py
@@ -1,17 +1,17 @@
 import logging
 import shlex
-from functools import partial
 from itertools import islice
 from multiprocessing.dummy import Pool
-from typing import List, Any, Dict, Generator, Optional, Set, Tuple
+from typing import List, Dict, Generator, Optional, Set, Tuple
 
 import docker.models.containers
-import progressbar
 from docker import DockerClient
 from docker.errors import APIError
 
 from .DockerImage import DockerImage
+from .stats.DockerMachineStats import DockerMachineStats
 from ... import utils
+from ...event.EventDispatcher import EventDispatcher
 from ...exceptions import MountDeniedError, MachineAlreadyExistsError
 from ...model.Lab import Lab
 from ...model.Link import BRIDGE_LINK_NAME
@@ -33,6 +33,9 @@
     "if [ -d \"/hostlab/{machine_name}\" ]; then "
     "(cd /hostlab/{machine_name} && tar c .) | (cd / && tar xhf -); fi",
 
+    # If /etc/hosts is not configured by the user, add the localhost mapping
+    "if [ ! -s \"/etc/hosts\" ]; then echo '127.0.0.1 localhost' > /etc/hosts; fi",
+
     # Give proper permissions to /var/www
     "if [ -d \"/var/www\" ]; then "
     "chmod -R 777 /var/www/*; fi",
@@ -117,14 +120,7 @@ def deploy_machines(self, lab: Lab, selected_machines: Set[str] = None) -> None:
         machines = {k: v for (k, v) in lab.machines.items() if k in selected_machines}.items() if selected_machines \
             else lab.machines.items()
 
-        progress_bar = None
-        if utils.CLI_ENV:
-            progress_bar = progressbar.ProgressBar(
-                widgets=['Deploying devices... ', progressbar.Bar(),
-                         ' ', progressbar.Counter(format='%(value)d/%(max_value)d')],
-                redirect_stdout=True,
-                max_value=len(machines)
-            )
+        EventDispatcher.get_instance().dispatch("machines_deploy_started", items=machines)
 
         # Deploy all lab machines.
         # If there is no lab.dep file, machines can be deployed using multithreading.
@@ -136,20 +132,17 @@ def deploy_machines(self, lab: Lab, selected_machines: Set[str] = None) -> None:
             items = utils.chunk_list(machines, pool_size)
 
             for chunk in items:
-                machines_pool.map(func=partial(self._deploy_and_start_machine, progress_bar), iterable=chunk)
+                machines_pool.map(func=self._deploy_and_start_machine, iterable=chunk)
         else:
             for item in machines:
-                self._deploy_and_start_machine(progress_bar, item)
+                self._deploy_and_start_machine(item)
 
-        if utils.CLI_ENV:
-            progress_bar.finish()
+        EventDispatcher.get_instance().dispatch("machines_deploy_ended")
 
-    def _deploy_and_start_machine(self, progress_bar: Optional[progressbar.ProgressBar],
-                                  machine_item: Tuple[str, Machine]) -> None:
+    def _deploy_and_start_machine(self, machine_item: Tuple[str, Machine]) -> None:
         """Deploy and start a Docker container from the device contained in machine_item.
 
         Args:
-            progress_bar (Optional[progressbar.ProgressBar]): A progress bar object to display if used from cli.
             machine_item (Tuple[str, Machine]): A tuple composed by the name of the device and a device object
 
         Returns:
@@ -160,8 +153,7 @@ def _deploy_and_start_machine(self, progress_bar: Optional[progressbar.ProgressB
         self.create(machine)
         self.start(machine)
 
-        if progress_bar is not None:
-            progress_bar += 1
+        EventDispatcher.get_instance().dispatch("machine_deployed", item=machine)
 
     def create(self, machine: Machine) -> None:
         """Create a Docker container representing the device and assign it to machine.api_object.
@@ -174,9 +166,9 @@ def create(self, machine: Machine) -> None:
         """
         logging.debug("Creating device `%s`..." % machine.name)
 
-        machines = self.get_machines_api_objects_by_filters(machine_name=machine.name, lab_hash=machine.lab.hash,
-                                                            user=utils.get_current_user_name())
-        if machines:
+        containers = self.get_machines_api_objects_by_filters(machine_name=machine.name, lab_hash=machine.lab.hash,
+                                                              user=utils.get_current_user_name())
+        if containers:
             raise MachineAlreadyExistsError("Device with name `%s` already exists." % machine.name)
 
         image = machine.get_image()
@@ -197,6 +189,12 @@ def create(self, machine: Machine) -> None:
         if "bridged" in options and not machine.meta['bridged']:
             machine.add_meta("bridged", True)
 
+        if ports and not machine.meta['bridged']:
+            logging.warning(
+                "To expose ports of device `%s` on the host, "
+                "you have to specify the `bridged` option on that device." % machine.name
+            )
+
         # If any exec command is passed in command line, add it.
         if "exec" in options:
             machine.add_meta("exec", options["exec"])
@@ -258,6 +256,7 @@ def create(self, machine: Machine) -> None:
                                                               privileged=privileged,
                                                               network=first_network.name if first_network else None,
                                                               network_mode="bridge" if first_network else "none",
+                                                              environment=machine.meta['envs'],
                                                               sysctls=sysctl_parameters,
                                                               mem_limit=memory,
                                                               nano_cpus=cpus,
@@ -288,8 +287,8 @@ def create(self, machine: Machine) -> None:
     def update(self, machine: Machine) -> None:
         """Update the Docker container representing the machine.
 
-        Create a new Docker network for each link contained in
-        machine.interfaces that is not already attached to the container.
+        Create a new Docker network for each collision domain contained in machine.interfaces that is not already
+        attached to the container.
 
         Args:
             machine (Kathara.model.Machine.Machine): A Kathara device.
@@ -297,13 +296,14 @@ def update(self, machine: Machine) -> None:
         Returns:
             None
         """
-        machines = self.get_machines_api_objects_by_filters(machine_name=machine.name, lab_hash=machine.lab.hash,
-                                                            user=utils.get_current_user_name())
+        containers = self.get_machines_api_objects_by_filters(
+            machine_name=machine.name, lab_hash=machine.lab.hash, user=utils.get_current_user_name()
+        )
 
-        if not machines:
-            raise Exception("Device `%s` not found." % machine.name)
+        if not containers:
+            raise Exception("The specified device `%s` is not running." % machine.name)
 
-        machine.api_object = machines.pop()
+        machine.api_object = containers.pop()
         attached_networks = machine.api_object.attrs["NetworkSettings"]["Networks"]
 
         # Connect the container to its new networks
@@ -369,10 +369,6 @@ def start(machine: Machine) -> None:
                                     detach=True
                                     )
 
-        if Setting.get_instance().open_terminals:
-            for i in range(0, machine.get_num_terms()):
-                machine.connect(Setting.get_instance().terminal)
-
     def undeploy(self, lab_hash: str, selected_machines: Set[str] = None) -> None:
         """Undeploy the devices contained in the network scenario defined by the lab_hash.
 
@@ -385,32 +381,23 @@ def undeploy(self, lab_hash: str, selected_machines: Set[str] = None) -> None:
         Returns:
             None
         """
-        machines = self.get_machines_api_objects_by_filters(lab_hash=lab_hash, user=utils.get_current_user_name())
+        containers = self.get_machines_api_objects_by_filters(lab_hash=lab_hash, user=utils.get_current_user_name())
 
         if selected_machines is not None and len(selected_machines) > 0:
-            machines = [item for item in machines if item.labels["name"] in selected_machines]
+            containers = [item for item in containers if item.labels["name"] in selected_machines]
 
-        if len(machines) > 0:
+        if len(containers) > 0:
             pool_size = utils.get_pool_size()
             machines_pool = Pool(pool_size)
 
-            items = utils.chunk_list(machines, pool_size)
+            items = utils.chunk_list(containers, pool_size)
 
-            progress_bar = None
-            if utils.CLI_ENV:
-                progress_bar = progressbar.ProgressBar(
-                    widgets=['Deleting devices... ', progressbar.Bar(),
-                             ' ', progressbar.Counter(format='%(value)d/%(max_value)d')],
-                    redirect_stdout=True,
-                    max_value=len(machines)
-                )
+            EventDispatcher.get_instance().dispatch("machines_undeploy_started", items=containers)
 
             for chunk in items:
-                machines_pool.map(func=partial(self._undeploy_machine, progress_bar),
-                                  iterable=chunk
-                                  )
-            if utils.CLI_ENV:
-                progress_bar.finish()
+                machines_pool.map(func=self._undeploy_machine, iterable=chunk)
+
+            EventDispatcher.get_instance().dispatch("machines_undeploy_ended")
 
     def wipe(self, user: str = None) -> None:
         """Undeploy all the running devices of the specified user. If user is None, it undeploy all the running devices.
@@ -421,22 +408,20 @@ def wipe(self, user: str = None) -> None:
         Returns:
             None
         """
-        machines = self.get_machines_api_objects_by_filters(user=user)
+        containers = self.get_machines_api_objects_by_filters(user=user)
 
         pool_size = utils.get_pool_size()
         machines_pool = Pool(pool_size)
 
-        items = utils.chunk_list(machines, pool_size)
+        items = utils.chunk_list(containers, pool_size)
 
         for chunk in items:
-            machines_pool.map(func=partial(self._undeploy_machine, None), iterable=chunk)
+            machines_pool.map(func=self._undeploy_machine, iterable=chunk)
 
-    def _undeploy_machine(self, progress_bar: Optional[progressbar.ProgressBar],
-                          machine_api_object: docker.models.containers.Container) -> None:
+    def _undeploy_machine(self, machine_api_object: docker.models.containers.Container) -> None:
         """Undeploy a Docker container.
 
         Args:
-            progress_bar (Optional[progressbar.ProgressBar]): A progress bar object to display if used from cli.
             machine_api_object (docker.models.containers.Container): The Docker container to undeploy.
 
         Returns:
@@ -444,8 +429,7 @@ def _undeploy_machine(self, progress_bar: Optional[progressbar.ProgressBar],
         """
         self._delete_machine(machine_api_object)
 
-        if progress_bar is not None:
-            progress_bar += 1
+        EventDispatcher.get_instance().dispatch("machine_undeployed", item=machine_api_object)
 
     def connect(self, lab_hash: str, machine_name: str, user: str = None, shell: str = None,
                 logs: bool = False) -> None:
@@ -461,7 +445,10 @@ def connect(self, lab_hash: str, machine_name: str, user: str = None, shell: str
         Returns:
             None
         """
-        container = self.get_machine_api_object(lab_hash=lab_hash, machine_name=machine_name, user=user)
+        containers = self.get_machines_api_objects_by_filters(lab_hash=lab_hash, machine_name=machine_name, user=user)
+        if not containers:
+            raise Exception("The specified device `%s` is not running." % machine_name)
+        container = containers.pop()
 
         if not shell:
             shell = shlex.split(container.labels['shell'])
@@ -531,7 +518,10 @@ def exec(self, lab_hash: str, machine_name: str, command: str, user: str = None,
         """
         logging.debug("Executing command `%s` to device with name: %s" % (command, machine_name))
 
-        container = self.get_machine_api_object(lab_hash=lab_hash, machine_name=machine_name, user=user)
+        containers = self.get_machines_api_objects_by_filters(lab_hash=lab_hash, machine_name=machine_name, user=user)
+        if not containers:
+            raise Exception("The specified device `%s` is not running." % machine_name)
+        container = containers.pop()
 
         exec_result = container.exec_run(cmd=command,
                                          stdout=True,
@@ -561,7 +551,7 @@ def copy_files(machine_api_object: docker.models.containers.Container, path: str
 
     def get_machines_api_objects_by_filters(self, lab_hash: str = None, machine_name: str = None, user: str = None) -> \
             List[docker.models.containers.Container]:
-        """Return the Docker containers objects specified by lab_hash, machine_name and user.
+        """Return the Docker containers objects specified by lab_hash and user.
 
         Args:
             lab_hash (str): The hash of a network scenario. If specified, return all the devices in the scenario.
@@ -569,7 +559,7 @@ def get_machines_api_objects_by_filters(self, lab_hash: str = None, machine_name
             user (str): The name of a user on the host. If specified, return only the containers of the user.
 
         Returns:
-            List[docker.models.containers.Container]: A list of Docker containers.
+            List[docker.models.containers.Container]: A list of Docker containers objects.
         """
         filters = {"label": ["app=kathara"]}
         if user:
@@ -581,147 +571,42 @@ def get_machines_api_objects_by_filters(self, lab_hash: str = None, machine_name
 
         return self.client.containers.list(all=True, filters=filters)
 
-    def get_machine_api_object(self, lab_hash: str, machine_name: str,
-                               user: str = None) -> docker.models.containers.Container:
-        """Return the Docker container object specified by lab_hash and machine_name.
+    def get_machines_stats(self, lab_hash: str = None, machine_name: str = None, user: str = None) -> \
+            Generator[Dict[str, DockerMachineStats], None, None]:
+        """Return a generator containing the Docker devices' stats.
 
         Args:
-            lab_hash (str): The hash of a network scenario.
-            machine_name (str): The name of a device.
-            user (str): The name of a current user on the host.
+            lab_hash (str): The hash of a network scenario. If specified, return all the stats of the devices in the
+                scenario.
+            machine_name (str): The name of a device. If specified, return the specified device stats.
+            user (str): The name of a user on the host. If specified, return only the stats of the specified user.
 
         Returns:
-            docker.models.containers.Container: A Docker container.
+            Generator[Dict[str, DockerMachineStats], None, None]: A generator containing device names as keys and
+            DockerMachineStats as values.
         """
-        logging.debug("Searching container `%s` with lab hash `%s` for user `%s`" % (machine_name, lab_hash, user))
-
         containers = self.get_machines_api_objects_by_filters(lab_hash=lab_hash, machine_name=machine_name, user=user)
-        logging.debug("Found containers: %s" % str(containers))
-
-        if len(containers) != 1:
-            raise Exception("Error getting the device `%s` inside the lab." % machine_name)
-        else:
-            return containers[0]
-
-    def get_machine_info(self, machine_name: str, lab_hash: str = None, user: str = None) -> List[Dict[str, Any]]:
-        """Return a list of dicts containing the devices info.
-
-        Args:
-            machine_name (str): The name of a device
-            lab_hash (str): The hash of a network scenario. If specified, search only the devices in the corresponding
-            scenario.
-            user (str): The name of a user on the host. If specified, search only the containers of the user.
-
-        Returns:
-            List[Dict[str, Any]]: A list of dicts containing the devices info.
-        """
-        machines_api_objects = self.get_machines_api_objects_by_filters(machine_name=machine_name, lab_hash=lab_hash,
-                                                                        user=user)
-        if not machines_api_objects:
-            raise Exception("The specified device `%s` is not running." % machine_name)
-
-        all_stats = []
-        for machine_api_object in machines_api_objects:
-            machine_stats = machine_api_object.stats(stream=False)
-            all_stats.append(self._get_stats_by_machine(machine_api_object, machine_stats))
-
-        return all_stats
-
-    def get_machines_info(self, lab_hash: str = None, machine_name: str = None, user: str = None) -> \
-            Generator[Dict[str, Any], None, None]:
-        """Return a generator containing the info of the specified device.
-
-        Args:
-            lab_hash (str): The hash of a network scenario. If specified, return all the devices in the scenario.
-            machine_name (str): The name of a device. If specified, return the specified container of the scenario.
-            user (str): The name of a user on the host. If specified, return only the containers of the user.
-
-        Returns:
-            Generator[Dict[str, Any], None, None]: A generator containing the info of the specified device.
-        """
-        machines = self.get_machines_api_objects_by_filters(lab_hash=lab_hash, machine_name=machine_name, user=user)
-        if not machines:
-            if not lab_hash:
-                raise Exception("No devices running.")
+        if not containers:
+            if not machine_name:
+                raise Exception("No devices found.")
             else:
-                raise Exception("Lab is not started.")
+                raise Exception(f"Devices with name {machine_name} not found.")
 
-        machines = sorted(machines, key=lambda x: x.name)
+        containers = sorted(containers, key=lambda x: x.name)
 
         machine_streams = {}
 
-        for machine in machines:
-            machine_streams[machine] = machine.stats(stream=True, decode=True)
+        for machine in containers:
+            machine_streams[machine.name] = DockerMachineStats(machine)
 
         while True:
-            machines_data = []
-
-            for (machine, machine_stats) in machine_streams.items():
+            for machine_stats in machine_streams.values():
                 try:
-                    result = next(machine_stats)
+                    machine_stats.update()
                 except StopIteration:
                     continue
 
-                machines_data.append(self._get_stats_by_machine(machine, result))
-
-            yield machines_data
-
-    def _get_stats_by_machine(self, machine_api_object: docker.models.containers.Container,
-                              machine_stats: Dict[str, Any]) -> Dict[str, Any]:
-        """Return the stats of the specified Docker container.
-
-        Args:
-            machine_api_object (docker.models.containers.Container): A Docker container.
-            machine_stats (Dict[str, Any]): A dict containing the stats of the container from Docker.
-
-        Returns:
-            Dict[str, Any]: A dict containing formatted Kathara device stats.
-
-        """
-        stats = self._get_aggregate_machine_info(machine_stats)
-
-        return {
-            "real_lab_hash": machine_api_object.labels['lab_hash'],
-            "name": machine_api_object.labels['name'],
-            "real_name": machine_api_object.name,
-            "user": machine_api_object.labels['user'],
-            "status": machine_api_object.status,
-            "image": machine_api_object.image.tags[0],
-            "pids": machine_stats['pids_stats']['current'] if 'current' in machine_stats['pids_stats'] else 0,
-            "cpu_usage": stats['cpu_usage'],
-            "mem_usage": stats['mem_usage'],
-            "mem_percent": stats['mem_percent'],
-            "net_usage": stats['net_usage']
-        }
-
-    @staticmethod
-    def _get_aggregate_machine_info(stats: Dict[str, Any]) -> Dict[str, str]:
-        """Return a dict containing the aggregate Kathara device info.
-
-        Args:
-             stats (Dict[str, Any]): A dict containing the stats of the container from Docker.
-
-        Returns:
-            Dict[str, Any]: A dict containing formatted Docker container stats.
-        """
-        network_stats = stats["networks"] if "networks" in stats else {}
-
-        return {
-            "cpu_usage": "{0:.2f}%".format(stats["cpu_stats"]["cpu_usage"]["total_usage"] /
-                                           stats["cpu_stats"]["system_cpu_usage"]
-                                           ) if "system_cpu_usage" in stats["cpu_stats"] else "-",
-            "mem_usage": utils.human_readable_bytes(stats["memory_stats"]["usage"]) + " / " +
-                         utils.human_readable_bytes(stats["memory_stats"]["limit"])
-            if "usage" in stats["memory_stats"] else "- / -",
-            "mem_percent": "{0:.2f}%".format((stats["memory_stats"]["usage"] / stats["memory_stats"]["limit"]) * 100)
-            if "usage" in stats["memory_stats"] else "-",
-            "net_usage": utils.human_readable_bytes(sum([net_stats["rx_bytes"]
-                                                         for (_, net_stats) in network_stats.items()])
-                                                    ) + " / " +
-                         utils.human_readable_bytes(sum([net_stats["tx_bytes"]
-                                                         for (_, net_stats) in network_stats.items()])
-                                                    )
-        }
+            yield machine_streams
 
     @staticmethod
     def get_container_name(name: str, lab_hash: str) -> str:
diff --git a/src/Kathara/manager/docker/DockerManager.py b/src/Kathara/manager/docker/DockerManager.py
index 0bbcf8a0..ceddbdcb 100644
--- a/src/Kathara/manager/docker/DockerManager.py
+++ b/src/Kathara/manager/docker/DockerManager.py
@@ -1,18 +1,18 @@
 import io
 import logging
-from copy import copy
-from datetime import datetime
-from typing import Set, Dict, Generator, Any, Tuple, List
+from typing import Set, Dict, Generator, Tuple, List, Optional
 
 import docker
 import docker.models.containers
+import docker.models.networks
 from requests.exceptions import ConnectionError as RequestsConnectionError
-from terminaltables import DoubleTable
 
 from .DockerImage import DockerImage
 from .DockerLink import DockerLink
 from .DockerMachine import DockerMachine
 from .DockerPlugin import DockerPlugin
+from .stats.DockerLinkStats import DockerLinkStats
+from .stats.DockerMachineStats import DockerMachineStats
 from ... import utils
 from ...decorators import privileged
 from ...exceptions import DockerDaemonConnectionError
@@ -131,16 +131,29 @@ def update_lab(self, lab: Lab) -> None:
                 self.docker_machine.update(machine)
 
     @privileged
-    def undeploy_lab(self, lab_hash: str, selected_machines: Set[str] = None) -> None:
+    def undeploy_lab(self, lab_hash: Optional[str] = None, lab_name: Optional[str] = None,
+                     selected_machines: Optional[Set[str]] = None) -> None:
         """Undeploy a Kathara network scenario.
 
         Args:
-            lab_hash (str): The hash of the network scenario to undeploy.
-            selected_machines (Set[str]): If not None, undeploy only the specified devices.
+            lab_hash (Optional[str]): The hash of the network scenario. Can be used as an alternative to lab_name.
+                If None, lab_name should be set.
+            lab_name (Optional[str]): The name of the network scenario. Can be used as an alternative to lab_hash.
+                If None, lab_hash should be set.
+            selected_machines (Optional[Set[str]]): If not None, undeploy only the specified devices.
 
         Returns:
             None
+
+        Raises:
+            Exception: You must specify a running network scenario hash or name.
         """
+        if not lab_hash and not lab_name:
+            raise Exception("You must specify a running network scenario hash or name.")
+
+        if lab_name:
+            lab_hash = utils.generate_urlsafe_hash(lab_name)
+
         self.docker_machine.undeploy(lab_hash, selected_machines=selected_machines)
 
         self.docker_link.undeploy(lab_hash)
@@ -152,8 +165,8 @@ def wipe(self, all_users: bool = False) -> None:
         If multiuser scenarios are active, undeploy only current user devices.
 
         Args:
-        all_users (bool): If false, undeploy only the current user network scenarios. If true, undeploy the
-           running network scenarios of all users.
+            all_users (bool): If false, undeploy only the current user network scenarios. If true, undeploy the
+                running network scenarios of all users.
 
         Returns:
             None
@@ -168,18 +181,29 @@ def wipe(self, all_users: bool = False) -> None:
         self.docker_link.wipe(user=user_name)
 
     @privileged
-    def connect_tty(self, lab_hash: str, machine_name: str, shell: str = None, logs: bool = False) -> None:
+    def connect_tty(self, machine_name: str, lab_hash: Optional[str] = None, lab_name: Optional[str] = None,
+                    shell: str = None, logs: bool = False) -> None:
         """Connect to a device in a running network scenario, using the specified shell.
 
         Args:
-            lab_hash (str): The hash of the network scenario to undeploy.
             machine_name (str): The name of the device to connect.
+            lab_hash (str): The hash of the network scenario where the device is deployed.
+            lab_name (str): The name of the network scenario where the device is deployed.
             shell (str): The name of the shell to use for connecting.
             logs (bool): If True, print startup logs on stdout.
 
         Returns:
             None
+
+        Raises:
+            Exception: You must specify a running network scenario hash or name.
         """
+        if not lab_hash and not lab_name:
+            raise Exception("You must specify a running network scenario hash or name.")
+
+        if lab_name:
+            lab_hash = utils.generate_urlsafe_hash(lab_name)
+
         user_name = utils.get_current_user_name()
 
         self.docker_machine.connect(lab_hash=lab_hash,
@@ -190,18 +214,28 @@ def connect_tty(self, lab_hash: str, machine_name: str, shell: str = None, logs:
                                     )
 
     @privileged
-    def exec(self, lab_hash: str, machine_name: str, command: str) -> Generator[Tuple[bytes, bytes], None, None]:
+    def exec(self, machine_name: str, command: str, lab_hash: Optional[str] = None, lab_name: Optional[str] = None) -> \
+            Generator[Tuple[bytes, bytes], None, None]:
         """Exec a command on a device in a running network scenario.
 
         Args:
-            lab_hash (str): The hash of the network scenario to undeploy.
             machine_name (str): The name of the device to connect.
             command (str): The command to exec on the device.
+            lab_hash (Optional[str]): The hash of the network scenario where the device is deployed.
+            lab_name (Optional[str]): The name of the network scenario where the device is deployed.
 
         Returns:
             Generator[Tuple[bytes, bytes]]: A generator of tuples containing the stdout and stderr in bytes.
+
+        Raises:
+            Exception: You must specify a running network scenario hash or name.
         """
+        if not lab_hash and not lab_name:
+            raise Exception("You must specify a running network scenario hash or name.")
+
         user_name = utils.get_current_user_name()
+        if lab_name:
+            lab_hash = utils.generate_urlsafe_hash(lab_name)
 
         return self.docker_machine.exec(lab_hash, machine_name, command, user=user_name, tty=False)
 
@@ -210,7 +244,7 @@ def copy_files(self, machine: Machine, guest_to_host: Dict[str, io.IOBase]) -> N
         """Copy files on a running device in the specified paths.
 
         Args:
-            machine (Kathara.model.Machine): A running machine object. It must have the api_object field populated.
+            machine (Kathara.model.Machine): A running device object. It must have the api_object field populated.
             guest_to_host (Dict[str, io.IOBase]): A dict containing the device path as key and
                 fileobj to copy in path as value.
 
@@ -225,137 +259,211 @@ def copy_files(self, machine: Machine, guest_to_host: Dict[str, io.IOBase]) -> N
                                        )
 
     @privileged
-    def get_lab_info(self, lab_hash: str = None, machine_name: str = None, all_users: bool = False) -> \
-            Generator[Dict[str, Any], None, None]:
-        """Return information about the running devices.
+    def get_machine_api_object(self, machine_name: str, lab_hash: str = None, lab_name: str = None,
+                               all_users: bool = False) -> docker.models.containers.Container:
+        """Return the corresponding API object of a running device in a network scenario.
 
         Args:
-            lab_hash (str): If not None, return information of the corresponding network scenario.
-            machine_name (str): If not None, return information of the specified device.
-            all_users (bool): If True, return information about the device of all users.
+            machine_name (str): The name of the device.
+            lab_hash (str): The hash of the network scenario. Can be used as an alternative to lab_name.
+                If None, lab_name should be set.
+            lab_name (str): The name of the network scenario. Can be used as an alternative to lab_name.
+                If None, lab_hash should be set.
+            all_users (bool): If True, return information about devices of all users.
 
         Returns:
-              Generator[Dict[str, Any], None, None]: A generator containing dicts containing device names as keys and
-              their info as values.
+            docker.models.containers.Container: Docker API object of devices.
+
+        Raises:
+            Exception: You must specify a running network scenario hash or name.
         """
+        if not lab_hash and not lab_name:
+            raise Exception("You must specify a running network scenario hash or name.")
+
         user_name = utils.get_current_user_name() if not all_users else None
+        if lab_name:
+            lab_hash = utils.generate_urlsafe_hash(lab_name)
 
-        lab_info = self.docker_machine.get_machines_info(lab_hash, machine_name=machine_name, user=user_name)
+        containers = self.docker_machine.get_machines_api_objects_by_filters(
+            lab_hash=lab_hash, machine_name=machine_name, user=user_name
+        )
+        if containers:
+            return containers.pop()
 
-        return lab_info
+        raise Exception(f"Device {machine_name} not found.")
 
-    @privileged
-    def get_formatted_lab_info(self, lab_hash: str = None, machine_name: str = None, all_users: bool = False) -> str:
-        """Return a formatted string with the information about the running devices.
+    def get_machines_api_objects(self, lab_hash: str = None, lab_name: str = None, all_users: bool = False) -> \
+            List[docker.models.containers.Container]:
+        """Return API objects of running devices.
 
         Args:
-            lab_hash (str): If not None, return information of the corresponding network scenario.
-            machine_name (str): If not None, return information of the specified device.
-            all_users (bool): If True, return information about the device of all users.
+            lab_hash (str): The hash of the network scenario. Can be used as an alternative to lab_name.
+            lab_name (str): The name of the network scenario. Can be used as an alternative to lab_name.
+            all_users (bool): If True, return information about devices of all users.
 
         Returns:
-             str: String containing devices info
+            List[docker.models.containers.Container]: Docker API objects of devices.
         """
-        table_header = ["LAB HASH", "USER", "DEVICE NAME", "STATUS", "CPU %", "MEM USAGE / LIMIT", "MEM %", "NET I/O"]
-        stats_table = DoubleTable([])
-        stats_table.inner_row_border = True
+        user_name = utils.get_current_user_name() if not all_users else None
+        if lab_name:
+            lab_hash = utils.generate_urlsafe_hash(lab_name)
 
-        lab_info = self.get_lab_info(lab_hash, machine_name, all_users)
+        return self.docker_machine.get_machines_api_objects_by_filters(lab_hash=lab_hash, user=user_name)
 
-        while True:
-            machines_data = [
-                table_header
-            ]
+    def get_link_api_object(self, link_name: str, lab_hash: str = None, lab_name: str = None,
+                            all_users: bool = False) -> docker.models.networks.Network:
+        """Return the corresponding API object of a collision domain in a network scenario.
 
-            try:
-                result = next(lab_info)
-            except StopIteration:
-                return
+        Args:
+            link_name (str): The name of the collision domain.
+            lab_hash (str): The hash of the network scenario. Can be used as an alternative to lab_name.
+                If None, lab_name should be set.
+            lab_name (str): The name of the network scenario. Can be used as an alternative to lab_name.
+                If None, lab_hash should be set.
+            all_users (bool): If True, return information about collision domains of all users.
 
-            if not result:
-                return
+        Returns:
+            docker.models.networks.Network: Docker API object of the network.
 
-            for machine_stats in result:
-                machines_data.append([machine_stats['real_lab_hash'],
-                                      machine_stats['user'],
-                                      machine_stats['name'],
-                                      machine_stats['status'],
-                                      machine_stats['cpu_usage'],
-                                      machine_stats['mem_usage'],
-                                      machine_stats['mem_percent'],
-                                      machine_stats['net_usage']
-                                      ])
+        Raises:
+            Exception: You must specify a running network scenario hash or name.
+            Exception: Collision Domain not found.
+        """
+        if not lab_hash and not lab_name:
+            raise Exception("You must specify a running network scenario hash or name.")
 
-            stats_table.table_data = machines_data
+        user_name = utils.get_current_user_name() if not all_users else None
+        if lab_name:
+            lab_hash = utils.generate_urlsafe_hash(lab_name)
 
-            yield "TIMESTAMP: %s" % datetime.now() + "\n\n" + stats_table.table
+        networks = self.docker_link.get_links_api_objects_by_filters(
+            lab_hash=lab_hash, link_name=link_name, user=user_name
+        )
+        if networks:
+            return networks.pop()
 
-    @privileged
-    def get_machine_api_object(self, lab_hash: str, machine_name: str) -> docker.models.containers.Container:
-        """Return the corresponding API object of a running device in a network scenario.
+        raise Exception(f"Collision Domain {link_name} not found.")
+
+    def get_links_api_objects(self, lab_hash: str = None, lab_name: str = None, all_users: bool = False) -> \
+            List[docker.models.networks.Network]:
+        """Return API objects of collision domains in a network scenario.
 
         Args:
-            lab_hash (str): The hash of the network scenario.
-            machine_name (str): The name of the device.
+            lab_hash (str): The hash of the network scenario. Can be used as an alternative to lab_name.
+            lab_name (str): The name of the network scenario. Can be used as an alternative to lab_name.
+            all_users (bool): If True, return information about collision domains of all users.
 
         Returns:
-            docker.models.containers.Container: docker machine api object.
+            List[docker.models.networks.Network]: Docker API objects of networks.
         """
-        user_name = utils.get_current_user_name()
+        user_name = utils.get_current_user_name() if not all_users else None
+        if lab_name:
+            lab_hash = utils.generate_urlsafe_hash(lab_name)
 
-        return self.docker_machine.get_machine_api_object(lab_hash, machine_name, user=user_name)
+        return self.docker_link.get_links_api_objects_by_filters(lab_hash=lab_hash, user=user_name)
 
     @privileged
-    def get_machine_info(self, machine_name: str, lab_hash: str = None, all_users: bool = False) \
-            -> List[Dict[str, Any]]:
-        """Return information of running devices with a specified name.
+    def get_machines_stats(self, lab_hash: str = None, lab_name: str = None, machine_name: str = None,
+                           all_users: bool = False) -> Generator[Dict[str, DockerMachineStats], None, None]:
+        """Return information about the running devices.
 
         Args:
-            machine_name (str): The device name.
-            lab_hash (str): If not None, search the device in the specified network scenario.
-            all_users (bool): If True, search the device among all the users devices.
+            lab_hash (str): The hash of the network scenario. Can be used as an alternative to lab_name.
+            lab_name (str): The name of the network scenario. Can be used as an alternative to lab_hash.
+            machine_name (str): If specified return all the devices with machine_name.
+            all_users (bool): If True, return information about the device of all users.
 
         Returns:
-            List[Dict[str, Any]]: A list of dicts containing the devices info.
+              Generator[Dict[str, DockerMachineStats], None, None]: A generator containing dicts that has API Object
+              identifier as keys and DockerMachineStats objects as values.
         """
         user_name = utils.get_current_user_name() if not all_users else None
+        if lab_name:
+            lab_hash = utils.generate_urlsafe_hash(lab_name)
 
-        machines_stats = self.docker_machine.get_machine_info(machine_name, lab_hash=lab_hash, user=user_name)
-
-        return machines_stats
+        return self.docker_machine.get_machines_stats(lab_hash=lab_hash, machine_name=machine_name,
+                                                      user=user_name)
 
     @privileged
-    def get_formatted_machine_info(self, machine_name: str, lab_hash: str = None, all_users: bool = False) -> str:
-        """Return formatted information of running devices with a specified name.
+    def get_machine_stats(self, machine_name: str, lab_hash: str = None,
+                          lab_name: str = None, all_users: bool = False) -> Generator[DockerMachineStats, None, None]:
+        """Return information of the specified device in a specified network scenario.
 
         Args:
             machine_name (str): The device name.
-            lab_hash (str): If not None, search the device in the specified network scenario.
+            lab_hash (str): The hash of the network scenario. Can be used as an alternative to lab_name.
+                If None, lab_name should be set.
+            lab_name (str): The name of the network scenario. Can be used as an alternative to lab_hash.
+                If None, lab_hash should be set.
             all_users (bool): If True, search the device among all the users devices.
 
         Returns:
-            str: The formatted devices properties.
+            Generator[DockerMachineStats, None, None]: A generator containing DockerMachineStats objects with
+            the device info.
+
+        Raises:
+            Exception: You must specify a running network scenario hash or name.
+        """
+        if not lab_hash and not lab_name:
+            raise Exception("You must specify a running network scenario hash or name.")
+
+        if lab_name:
+            lab_hash = utils.generate_urlsafe_hash(lab_name)
+
+        machines_stats = self.get_machines_stats(lab_hash=lab_hash, machine_name=machine_name, all_users=all_users)
+        (_, machine_stats) = next(machines_stats).popitem()
+
+        yield machine_stats
+
+    def get_links_stats(self, lab_hash: str = None, lab_name: str = None, link_name: str = None,
+                        all_users: bool = False) -> Generator[Dict[str, DockerLinkStats], None, None]:
+        """Return information about deployed Docker networks.
+
+        Args:
+           lab_hash (str): The hash of the network scenario. Can be used as an alternative to lab_name.
+           lab_name (str): The name of the network scenario. Can be used as an alternative to lab_hash.
+           link_name (str): If specified return all the networks with link_name.
+           all_users (bool): If True, return information about the networks of all users.
+
+        Returns:
+             Generator[Dict[str, DockerLinkStats], None, None]: A generator containing dicts that has API Object
+                identifier as keys and DockerLinksStats objects as values.
         """
-        machines_stats = self.get_machine_info(machine_name, lab_hash, all_users)
-
-        machines_info = []
-
-        for machine_stats in machines_stats:
-            machine_info = utils.format_headers("Device information") + "\n"
-            machine_info += "Lab Hash: %s\n" % machine_stats['real_lab_hash']
-            machine_info += "Device Name: %s\n" % machine_stats['name']
-            machine_info += "Real Device Name: %s\n" % machine_stats['real_name']
-            machine_info += "Status: %s\n" % machine_stats['status']
-            machine_info += "Image: %s\n\n" % machine_stats['image']
-            machine_info += "PIDs: %s\n" % machine_stats['pids']
-            machine_info += "CPU Usage: %s\n" % machine_stats["cpu_usage"]
-            machine_info += "Memory Usage: %s\n" % machine_stats["mem_usage"]
-            machine_info += "Network Usage (DL/UL): %s\n" % machine_stats["net_usage"]
-            machine_info += utils.format_headers()
-
-            machines_info.append(machine_info)
-
-        return "\n\n".join(machines_info)
+        user_name = utils.get_current_user_name() if not all_users else None
+        if lab_name:
+            lab_hash = utils.generate_urlsafe_hash(lab_name)
+
+        return self.docker_link.get_links_stats(lab_hash=lab_hash, link_name=link_name, user=user_name)
+
+    def get_link_stats(self, link_name: str, lab_hash: str = None, lab_name: str = None, all_users: bool = False) -> \
+            Generator[DockerLinkStats, None, None]:
+        """Return information of the specified deployed network in a specified network scenario.
+
+        Args:
+            link_name (str): The link name.
+            lab_hash (str): The hash of the network scenario. Can be used as an alternative to lab_name.
+                If None, lab_name should be set.
+            lab_name (str): The name of the network scenario. Can be used as an alternative to lab_hash.
+                If None, lab_hash should be set.
+            all_users (bool): If True, search the network among all the users networks.
+
+        Returns:
+            Generator[DockerLinkStats, None, None]: A generator containing DockerLinkStats objects with the network
+                statistics.
+
+        Raises:
+            Exception: You must specify a running network scenario hash or name.
+        """
+        if not lab_hash and not lab_name:
+            raise Exception("You must specify a running network scenario hash or name.")
+
+        if lab_name:
+            lab_hash = utils.generate_urlsafe_hash(lab_name)
+
+        links_stats = self.get_links_stats(lab_hash=lab_hash, link_name=link_name, all_users=all_users)
+        (_, link_stats) = next(links_stats).popitem()
+
+        yield link_stats
 
     @privileged
     def check_image(self, image_name: str) -> None:
diff --git a/src/Kathara/manager/docker/DockerPlugin.py b/src/Kathara/manager/docker/DockerPlugin.py
index 7b116695..93c3bb4e 100644
--- a/src/Kathara/manager/docker/DockerPlugin.py
+++ b/src/Kathara/manager/docker/DockerPlugin.py
@@ -26,7 +26,6 @@ def check_and_download_plugin(self) -> None:
         Returns:
             None
         """
-
         try:
             logging.debug("Checking plugin `%s`..." % PLUGIN_NAME)
             plugin = self.client.plugins.get(PLUGIN_NAME)
diff --git a/src/Kathara/manager/docker/stats/DockerLinkStats.py b/src/Kathara/manager/docker/stats/DockerLinkStats.py
new file mode 100644
index 00000000..6658ff0b
--- /dev/null
+++ b/src/Kathara/manager/docker/stats/DockerLinkStats.py
@@ -0,0 +1,84 @@
+from typing import Dict, Any, List
+
+from docker.models.containers import Container
+from docker.models.networks import Network
+
+from ....foundation.manager.stats.ILinkStats import ILinkStats
+
+
+class DockerLinkStats(ILinkStats):
+    """The class responsible to handle Docker Networks statistics.
+
+    Attributes:
+        link_api_object (Network): The Docker Network associated with this statistics.
+        lab_hash (str): The hash identifier of the network scenario of the Docker Network.
+        name (str): The name of the collision domain.
+        network_name (str): The Docker Network Name.
+        user (str): The user that deployed the associated Docker Network.
+        enable_ipv6 (bool): True if ipv6 is enabled, else None.
+        external (List[str]): A list with the name of the attached external networks.
+        containers (List[Container]): A list of the Docker Container associated with the Docker Network.
+    """
+    __slots__ = ['link_api_object', 'lab_hash', 'name', 'network_name', 'user', 'enable_ipv6', 'external', 'containers']
+
+    def __init__(self, link_api_object: Network):
+        self.link_api_object: Network = link_api_object
+        self.lab_hash: str = link_api_object.attrs.get('Labels')['lab_hash']
+        self.name: str = link_api_object.attrs.get('Labels')['name']
+        self.network_name: str = link_api_object.name
+        self.user: str = link_api_object.attrs.get('Labels')['user']
+        self.enable_ipv6: bool = link_api_object.attrs.get('EnableIPv6')
+        external = link_api_object.attrs.get('Labels')['external']
+        self.external: List[str] = external.split(";") if external else []
+        self.containers: List[Container] = []
+        self.update()
+
+    def update(self) -> None:
+        """Update dynamic statistics with the current ones.
+
+        Returns:
+            None
+        """
+        self.link_api_object.reload()
+
+        self.containers = [container for container in self.link_api_object.containers]
+
+    def to_dict(self) -> Dict[str, Any]:
+        """Transform statistics into a dict representation.
+
+        Returns:
+            Dict[str, Any]: Dict containing statistics.
+        """
+        return {
+            "network_scenario_id": self.lab_hash,
+            "name": self.name,
+            "network_name": self.network_name,
+            "user": self.user,
+            "enable_ipv6": self.enable_ipv6,
+            "external": self.external,
+            "containers": self.containers
+        }
+
+    def __repr__(self) -> str:
+        return str(self.to_dict())
+
+    def __str__(self) -> str:
+        """Return a formatted string with the link statistics.
+
+        Returns:
+           str: A formatted string with the link statistics
+        """
+        formatted_stats = f"Network Scenario ID: {self.lab_hash}"
+        formatted_stats += f"\nLink Name: {self.name}"
+        formatted_stats += f"\nNetwork Name: {self.network_name}"
+        formatted_stats += f"\nEnable IPv6: {self.enable_ipv6}"
+        if self.external:
+            formatted_stats += f"\nExternal Interfaces:"
+            for ext in self.external:
+                formatted_stats += f"\n\t- {ext}\n"
+        if self.containers:
+            formatted_stats += f"\nAttached Devices:"
+            for container in self.containers:
+                formatted_stats += f"\n\t- {container.labels['name']}"
+
+        return formatted_stats
diff --git a/src/Kathara/manager/docker/stats/DockerMachineStats.py b/src/Kathara/manager/docker/stats/DockerMachineStats.py
new file mode 100644
index 00000000..5c5c88f8
--- /dev/null
+++ b/src/Kathara/manager/docker/stats/DockerMachineStats.py
@@ -0,0 +1,115 @@
+from typing import Dict, Any, Generator, Optional
+
+from docker.models.containers import Container
+
+from ....foundation.manager.stats.IMachineStats import IMachineStats
+from ....utils import human_readable_bytes
+
+
+class DockerMachineStats(IMachineStats):
+    """The class responsible to handle Docker Machine statistics.
+
+    Attributes:
+        machine_api_object (Container): The Docker Container associated with this statistics.
+        stats (Generator[Dict[str, Any], None, None]): A generator containing dicts with the Docker statistics
+        lab_hash (str): The hash identifier of the network scenario of the Docker Container.
+        name (str): The name of the device.
+        container_name (str): The Docker Container Name.
+        user (str): The user that deployed the associated Docker Network.
+        image (str): The Docker Image used for deploying the Docker Container.
+        status (Optional[str]): The status of the Docker Container.
+        pids (Optional[int]): The number of PIDs associated with the Docker Container.
+        cpu_usage (str): The cpu usage of the Docker Container.
+        mem_usage (str): The memory usage of the Docker Container.
+        mem_percent (str): The memory usage of the Docker Container as a percentage.
+        net_usage (str): The network usage of the Docker Container.
+    """
+    __slots__ = ['machine_api_object', 'stats', 'lab_hash', 'name', 'container_name', 'user', 'status', 'image',
+                 'pids', 'cpu_usage', 'mem_usage', 'mem_percent', 'net_usage']
+
+    def __init__(self, machine_api_object: Container):
+        self.machine_api_object: Container = machine_api_object
+        self.stats: Generator[Dict[str, Any], None, None] = machine_api_object.stats(stream=True, decode=True)
+        # Static Information
+        self.lab_hash: str = machine_api_object.labels['lab_hash']
+        self.name: str = machine_api_object.labels['name']
+        self.container_name: str = machine_api_object.name
+        self.user: Optional[str] = machine_api_object.labels['user']
+        self.image: str = machine_api_object.image.tags[0]
+        # Dynamic Information
+        self.status: Optional[str] = None
+        self.pids: Optional[int] = None
+        self.cpu_usage: str = "-"
+        self.mem_usage: str = "- / -"
+        self.mem_percent: str = "-"
+        self.net_usage: str = "-"
+
+        self.update()
+
+    def update(self) -> None:
+        """Update dynamic statistics with the current ones.
+
+        Returns:
+            None
+        """
+        updated_stats = next(self.stats)
+
+        self.status = self.machine_api_object.status
+        self.pids = updated_stats['pids_stats']['current'] if 'current' in updated_stats['pids_stats'] else 0
+        if "system_cpu_usage" in updated_stats["cpu_stats"]:
+            cpu_usage = updated_stats["cpu_stats"]["cpu_usage"]["total_usage"] / \
+                        updated_stats["cpu_stats"]["system_cpu_usage"]
+            self.cpu_usage = f"{cpu_usage:.2f}%"
+
+        if "usage" in updated_stats["memory_stats"]:
+            usage = updated_stats["memory_stats"]["usage"]
+            limit = updated_stats["memory_stats"]["limit"]
+            self.mem_usage = human_readable_bytes(usage) + " / " + human_readable_bytes(limit)
+            self.mem_percent = f"{((usage / limit) * 100):.2f} %"
+
+        if "networks" in updated_stats:
+            network_stats = updated_stats["networks"] if "networks" in updated_stats else {}
+            rx_bytes = sum([net_stats["rx_bytes"] for (_, net_stats) in network_stats.items()])
+            tx_bytes = sum([net_stats["tx_bytes"] for (_, net_stats) in network_stats.items()])
+            self.net_usage = human_readable_bytes(rx_bytes) + " / " + human_readable_bytes(tx_bytes)
+
+    def to_dict(self) -> Dict[str, Any]:
+        """Transform statistics into a dict representation.
+
+        Returns:
+            Dict[str, Any]: Dict containing statistics.
+        """
+        return {
+            "network_scenario_id": self.lab_hash,
+            "name": self.name,
+            "container_name": self.container_name,
+            "user": self.user,
+            "status": self.status,
+            "image": self.image,
+            "pids": self.pids,
+            "cpu_usage": self.cpu_usage,
+            "mem_usage": self.mem_usage,
+            "mem_percent": self.mem_percent,
+            "net_usage": self.net_usage
+        }
+
+    def __repr__(self) -> str:
+        return str(self.to_dict())
+
+    def __str__(self) -> str:
+        """Return a formatted string with the device statistics.
+
+        Returns:
+           str: A formatted string with the device statistics
+        """
+        formatted_stats = f"Network Scenario ID: {self.lab_hash}\n"
+        formatted_stats += f"Device Name: {self.name}\n"
+        formatted_stats += f"Container Name: {self.container_name}\n"
+        formatted_stats += f"Status: {self.status}\n"
+        formatted_stats += f"Image: {self.image}\n"
+        formatted_stats += f"PIDs: {self.pids}\n"
+        formatted_stats += f"CPU Usage: {self.cpu_usage}\n"
+        formatted_stats += f"Memory Usage: {self.mem_usage}\n"
+        formatted_stats += f"Network Usage (DL/UL): {self.net_usage}"
+
+        return formatted_stats
diff --git a/src/Kathara/manager/docker/stats/__init__.py b/src/Kathara/manager/docker/stats/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/Kathara/manager/kubernetes/KubernetesConfig.py b/src/Kathara/manager/kubernetes/KubernetesConfig.py
index 9826f9e0..f40bea6e 100644
--- a/src/Kathara/manager/kubernetes/KubernetesConfig.py
+++ b/src/Kathara/manager/kubernetes/KubernetesConfig.py
@@ -10,7 +10,7 @@ def get_cluster_user() -> str:
         """Return the name of the current cluster user.
 
         Returns:
-            str:
+            str: The name of the current cluster user.
         """
         try:
             # Remote configuration is present, use the API Token as user
@@ -23,7 +23,7 @@ def get_cluster_user() -> str:
 
     @staticmethod
     def load_kube_config() -> None:
-        """Load a Kubernetes Configuration if Kathara is launched on a k8s master.
+        """Load the in-cluster config if launched on a master. Otherwise, setup the config for a remote master.
 
         Returns:
             None
diff --git a/src/Kathara/manager/kubernetes/KubernetesConfigMap.py b/src/Kathara/manager/kubernetes/KubernetesConfigMap.py
index ddfaaac7..c7da3b3e 100644
--- a/src/Kathara/manager/kubernetes/KubernetesConfigMap.py
+++ b/src/Kathara/manager/kubernetes/KubernetesConfigMap.py
@@ -18,7 +18,7 @@ def __init__(self) -> None:
         self.client: core_v1_api.CoreV1Api = core_v1_api.CoreV1Api()
 
     def deploy_for_machine(self, machine: Machine) -> Optional[client.V1ConfigMap]:
-        """Deploy and return a Kubernetes ConfigMap for the machine.
+        """Deploy and return a Kubernetes ConfigMap for the device.
 
         Args:
             machine (Kathara.model.Machine.Machine): A Kathara device.
diff --git a/src/Kathara/manager/kubernetes/KubernetesLink.py b/src/Kathara/manager/kubernetes/KubernetesLink.py
index c7193ce6..56722c73 100644
--- a/src/Kathara/manager/kubernetes/KubernetesLink.py
+++ b/src/Kathara/manager/kubernetes/KubernetesLink.py
@@ -4,15 +4,17 @@
 from functools import partial
 from multiprocessing import Manager
 from multiprocessing.dummy import Pool
-from typing import Dict, Optional, Set, Any, List
+from typing import Dict, Optional, Set, Any, List, Generator
 
-import progressbar
 from kubernetes import client
 from kubernetes.client.api import custom_objects_api
 from kubernetes.client.rest import ApiException
 
 from .KubernetesConfig import KubernetesConfig
+from .KubernetesNamespace import KubernetesNamespace
+from .stats.KubernetesLinkStats import KubernetesLinkStats
 from ... import utils
+from ...event.EventDispatcher import EventDispatcher
 from ...model.Lab import Lab
 from ...model.Link import Link
 from ...setting.Setting import Setting
@@ -25,12 +27,14 @@
 
 
 class KubernetesLink(object):
-    """Class responsible for interacting with Kubernetes."""
-    __slots__ = ['client', 'seed']
+    """The class responsible for deploying Kathara collision domains as Kubernetes networks and interact with them."""
+    __slots__ = ['client', 'kubernetes_namespace', 'seed']
 
-    def __init__(self) -> None:
+    def __init__(self, kubernetes_namespace: KubernetesNamespace) -> None:
         self.client: custom_objects_api.CustomObjectsApi = custom_objects_api.CustomObjectsApi()
 
+        self.kubernetes_namespace: KubernetesNamespace = kubernetes_namespace
+
         self.seed: str = KubernetesConfig.get_cluster_user()
 
     def deploy_links(self, lab: Lab, selected_links: Dict[str, Link] = None) -> None:
@@ -38,7 +42,7 @@ def deploy_links(self, lab: Lab, selected_links: Dict[str, Link] = None) -> None
 
         Args:
             lab (Kathara.model.Lab.Lab): A Kathara network scenario.
-            selected_links (Dict[str, Link]): Keys are Link names, values are Link objects.
+            selected_links (Dict[str, Link]): Keys are collision domains names, values are Link objects.
 
         Returns:
             None
@@ -51,31 +55,23 @@ def deploy_links(self, lab: Lab, selected_links: Dict[str, Link] = None) -> None
 
             items = utils.chunk_list(links, pool_size)
 
-            progress_bar = None
-            if utils.CLI_ENV:
-                progress_bar = progressbar.ProgressBar(
-                    widgets=['Deploying collision domains... ', progressbar.Bar(),
-                             ' ', progressbar.Counter(format='%(value)d/%(max_value)d')],
-                    redirect_stdout=True,
-                    max_value=len(links)
-                )
+            EventDispatcher.get_instance().dispatch("links_deploy_started", items=links)
 
             with Manager() as manager:
                 network_ids = manager.dict()
 
                 for chunk in items:
-                    link_pool.map(func=partial(self._deploy_link, progress_bar, network_ids), iterable=chunk)
+                    link_pool.map(func=partial(self._deploy_link, network_ids), iterable=chunk)
 
-            if utils.CLI_ENV:
-                progress_bar.finish()
+            EventDispatcher.get_instance().dispatch("links_deploy_ended")
 
-    def _deploy_link(self, progress_bar: progressbar.ProgressBar, network_ids: Dict, link_item: (str, Link)) -> None:
+    def _deploy_link(self, network_ids: Dict, link_item: (str, Link)) -> None:
         """Deploy the Link contained in the link_item.
 
         Args:
-            progress_bar (Optional[progressbar.ProgressBar]): A progress bar object to display if used from cli.
-            network_ids (Dict):
-            link_item (Tuple[str, Link]): A tuple composed by the name of the link and a Link object
+            network_ids (Dict): A Dict for reserving network IDs. The Key is the network_id, value is 1 if it
+                is currently used.
+            link_item (Tuple[str, Link]): A tuple composed by the name of the collision domain and a Link object
 
         Returns:
             None
@@ -85,11 +81,10 @@ def _deploy_link(self, progress_bar: progressbar.ProgressBar, network_ids: Dict,
         network_id = self._get_unique_network_id(link.name, network_ids)
         self.create(link, network_id)
 
-        if progress_bar is not None:
-            progress_bar += 1
+        EventDispatcher.get_instance().dispatch("link_deployed", item=link)
 
     def create(self, link: Link, network_id: int) -> None:
-        """Create a Docker Network representing the link object and assign it to link.api_object.
+        """Create a Kubernetes Network representing the collision domain object and assign it to link.api_object.
 
         Args:
             link (Kathara.model.Link.Link): A Kathara collision domain.
@@ -99,9 +94,9 @@ def create(self, link: Link, network_id: int) -> None:
             None
         """
         # If a network with the same name exists, return it instead of creating a new one.
-        network_objects = self.get_links_api_objects_by_filters(lab_hash=link.lab.hash, link_name=link.name)
-        if network_objects:
-            link.api_object = network_objects.pop()
+        networks = self.get_links_api_objects_by_filters(lab_hash=link.lab.hash, link_name=link.name)
+        if networks:
+            link.api_object = networks.pop()
             return
 
         link.api_object = self.client.create_namespaced_custom_object(group=K8S_NET_GROUP,
@@ -125,30 +120,22 @@ def undeploy(self, lab_hash: str, networks_to_delete: Optional[Set] = None) -> N
         Returns:
             None
         """
-        links = self.get_links_api_objects_by_filters(lab_hash=lab_hash)
+        networks = self.get_links_api_objects_by_filters(lab_hash=lab_hash)
         if networks_to_delete is not None and len(networks_to_delete) > 0:
-            links = [item for item in links if item["metadata"]["name"] in networks_to_delete]
+            networks = [item for item in networks if item["metadata"]["name"] in networks_to_delete]
 
-        if len(links) > 0:
+        if len(networks) > 0:
             pool_size = utils.get_pool_size()
             links_pool = Pool(pool_size)
 
-            items = utils.chunk_list(links, pool_size)
+            items = utils.chunk_list(networks, pool_size)
 
-            progress_bar = None
-            if utils.CLI_ENV:
-                progress_bar = progressbar.ProgressBar(
-                    widgets=['Deleting collision domains... ', progressbar.Bar(),
-                             ' ', progressbar.Counter(format='%(value)d/%(max_value)d')],
-                    redirect_stdout=True,
-                    max_value=len(links)
-                )
+            EventDispatcher.get_instance().dispatch("links_undeploy_started", items=networks)
 
             for chunk in items:
-                links_pool.map(func=partial(self._undeploy_link, progress_bar), iterable=chunk)
+                links_pool.map(func=self._undeploy_link, iterable=chunk)
 
-            if utils.CLI_ENV:
-                progress_bar.finish()
+            EventDispatcher.get_instance().dispatch("links_undeploy_ended")
 
     def wipe(self) -> None:
         """Undeploy all the running networks of the specified user.
@@ -156,22 +143,21 @@ def wipe(self) -> None:
         Returns:
             None
         """
-        links = self.get_links_api_objects_by_filters()
+        networks = self.get_links_api_objects_by_filters()
 
         pool_size = utils.get_pool_size()
         links_pool = Pool(pool_size)
 
-        items = utils.chunk_list(links, pool_size)
+        items = utils.chunk_list(networks, pool_size)
 
         for chunk in items:
-            links_pool.map(func=partial(self._undeploy_link, None), iterable=chunk)
+            links_pool.map(func=self._undeploy_link, iterable=chunk)
 
-    def _undeploy_link(self, progress_bar: progressbar.ProgressBar, link_item: Any) -> None:
+    def _undeploy_link(self, link_item: Any) -> None:
         """Undeploy a Kubernetes network.
 
         Args:
-            progress_bar (Optional[progressbar.ProgressBar]): A progress bar object to display if used from cli.
-            link_item (): The Kubernetes network to undeploy.
+            link_item (Any): The Kubernetes network to undeploy.
 
         Returns:
             None
@@ -190,16 +176,15 @@ def _undeploy_link(self, progress_bar: progressbar.ProgressBar, link_item: Any)
         except ApiException:
             pass
 
-        if progress_bar is not None:
-            progress_bar += 1
+        EventDispatcher.get_instance().dispatch("link_undeployed", item=link_item)
 
     def get_links_api_objects_by_filters(self, lab_hash: str = None, link_name: str = None) -> List[Any]:
         """Return the List of Kubernetes networks.
 
         Args:
             lab_hash (str): The hash of a network scenario. If specified, return only the networks in the scenario, else
-            return the networks in the 'default' namespace.
-            link_name (str): The name of a network. If specified, return the specified networks of the scenario.
+                return the networks in the 'default' namespace.
+            link_name (str): The name of a network. If specified, return the specified network of the scenario.
 
         Returns:
             List[Any]: A list of Kubernetes networks.
@@ -208,13 +193,57 @@ def get_links_api_objects_by_filters(self, lab_hash: str = None, link_name: str
         if link_name:
             filters.append("name=%s" % link_name)
 
-        return self.client.list_namespaced_custom_object(group=K8S_NET_GROUP,
-                                                         version=K8S_NET_VERSION,
-                                                         namespace=lab_hash if lab_hash else "default",
-                                                         plural=K8S_NET_PLURAL,
-                                                         label_selector=",".join(filters),
-                                                         timeout_seconds=9999
-                                                         )["items"]
+        # Get all Kathara namespaces if lab_hash is None
+        namespaces = list(map(lambda x: x.metadata.name, self.kubernetes_namespace.get_all())) \
+            if not lab_hash else [lab_hash]
+
+        networks = []
+        for namespace in namespaces:
+            networks.extend(
+                self.client.list_namespaced_custom_object(group=K8S_NET_GROUP,
+                                                          version=K8S_NET_VERSION,
+                                                          namespace=namespace,
+                                                          plural=K8S_NET_PLURAL,
+                                                          label_selector=",".join(filters),
+                                                          timeout_seconds=9999
+                                                          )["items"]
+            )
+
+        return networks
+
+    def get_links_stats(self, lab_hash: str = None, link_name: str = None) -> \
+            Generator[Dict[str, KubernetesLinkStats], None, None]:
+        """Return a generator containing the Kubernetes networks' stats.
+
+        Args:
+           lab_hash (str): The hash of a network scenario. If specified, return all the stats of the networks in the
+                scenario.
+           link_name (str): The name of a device. If specified, return the specified network stats.
+
+        Returns:
+           Generator[Dict[str, KubernetesLinkStats], None, None]: A generator containing network names as keys and
+                KubernetesLinkStats as values.
+        """
+        while True:
+            networks = self.get_links_api_objects_by_filters(lab_hash=lab_hash, link_name=link_name)
+            if not networks:
+                if not link_name:
+                    raise Exception("No collision domains found.")
+                else:
+                    raise Exception(f"Collision domains with name {link_name} not found.")
+
+            networks_stats = {}
+
+            for network in networks:
+                networks_stats[network['metadata']['name']] = KubernetesLinkStats(network)
+
+            for network_stats in networks_stats.values():
+                try:
+                    network_stats.update()
+                except StopIteration:
+                    continue
+
+            yield networks_stats
 
     def _build_definition(self, link: Link, network_id: int) -> Dict[str, str]:
         """Return a Dict containing the network definition for Kubernetes API corresponding to link.
@@ -253,7 +282,7 @@ def _get_unique_network_id(self, name: str, network_ids: Dict[int, int]) -> int:
         Args:
             name (str): The name of the network.
             network_ids (Dict[int, int]): A Dict for reserving network IDs. The Key is the network_id, value is 1 if it
-            is currently used.
+                is currently used.
 
         Returns:
             int: A network ID.
@@ -287,7 +316,7 @@ def get_network_name(name: str) -> str:
         """Return the name of a Kubernetes Network.
 
         Args:
-            name (str): The name of a Kathara Link.
+            name (str): The name of a Kathara collision domain.
 
         Returns:
             str: The name of the Kubernetes Network in the format "<net_prefix>-<name><suffix>".
diff --git a/src/Kathara/manager/kubernetes/KubernetesMachine.py b/src/Kathara/manager/kubernetes/KubernetesMachine.py
index e111a561..8f2702a9 100644
--- a/src/Kathara/manager/kubernetes/KubernetesMachine.py
+++ b/src/Kathara/manager/kubernetes/KubernetesMachine.py
@@ -5,11 +5,9 @@
 import shlex
 import sys
 import uuid
-from functools import partial
 from multiprocessing.dummy import Pool
-from typing import Optional, Set, List, Union, Dict, Generator, Tuple, Any
+from typing import Optional, Set, List, Union, Generator, Tuple, Dict
 
-import progressbar
 from kubernetes import client
 from kubernetes.client.api import apps_v1_api
 from kubernetes.client.api import core_v1_api
@@ -18,7 +16,9 @@
 
 from .KubernetesConfigMap import KubernetesConfigMap
 from .KubernetesNamespace import KubernetesNamespace
+from .stats.KubernetesMachineStats import KubernetesMachineStats
 from ... import utils
+from ...event.EventDispatcher import EventDispatcher
 from ...exceptions import MachineAlreadyExistsError
 from ...model.Lab import Lab
 from ...model.Machine import Machine
@@ -53,6 +53,9 @@
     "if [ -d \"/hostlab/{machine_name}\" ]; then "
     "(cd /hostlab/{machine_name} && tar c .) | (cd / && tar xhf -); fi",
 
+    # If /etc/hosts is not configured by the user, add the localhost mapping
+    "if [ ! -s \"/etc/hosts\" ]; then echo '127.0.0.1 localhost' > /etc/hosts; fi",
+
     # Give proper permissions to /var/www
     "if [ -d \"/var/www\" ]; then "
     "chmod -R 777 /var/www/*; fi",
@@ -127,7 +130,6 @@ def deploy_machines(self, lab: Lab, selected_machines: Set[str] = None) -> None:
         Returns:
             None
         """
-
         machines = {k: v for (k, v) in lab.machines.items() if k in selected_machines}.items() if selected_machines \
             else lab.machines.items()
 
@@ -136,14 +138,10 @@ def deploy_machines(self, lab: Lab, selected_machines: Set[str] = None) -> None:
         if privileged:
             logging.warning('Privileged option is not supported on Megalos. It will be ignored.')
 
-        progress_bar = None
-        if utils.CLI_ENV:
-            progress_bar = progressbar.ProgressBar(
-                widgets=['Deploying devices... ', progressbar.Bar(),
-                         ' ', progressbar.Counter(format='%(value)d/%(max_value)d')],
-                redirect_stdout=True,
-                max_value=len(machines)
-            )
+        # Do not open terminals on Megalos
+        Setting.get_instance().open_terminals = False
+
+        EventDispatcher.get_instance().dispatch("machines_deploy_started", items=machines)
 
         # Deploy all lab machines.
         # If there is no lab.dep file, machines can be deployed using multithreading.
@@ -155,21 +153,17 @@ def deploy_machines(self, lab: Lab, selected_machines: Set[str] = None) -> None:
             items = utils.chunk_list(machines, pool_size)
 
             for chunk in items:
-                machines_pool.map(func=partial(self._deploy_machine, progress_bar),
-                                  iterable=chunk
-                                  )
+                machines_pool.map(func=self._deploy_machine, iterable=chunk)
         else:
             for item in machines:
-                self._deploy_machine(progress_bar, item)
+                self._deploy_machine(item)
 
-        if utils.CLI_ENV:
-            progress_bar.finish()
+        EventDispatcher.get_instance().dispatch("machines_deploy_ended")
 
-    def _deploy_machine(self, progress_bar: progressbar.ProgressBar, machine_item: Tuple[str, Machine]) -> None:
+    def _deploy_machine(self, machine_item: Tuple[str, Machine]) -> None:
         """Deploy a Kubernetes deployment from the Kathara device contained in machine_item.
 
         Args:
-           progress_bar (Optional[progressbar.ProgressBar]): A progress bar object to display if used from cli.
            machine_item (Tuple[str, Machine]): A tuple composed by the name of the device and a device object
 
         Returns:
@@ -179,11 +173,10 @@ def _deploy_machine(self, progress_bar: progressbar.ProgressBar, machine_item: T
 
         self.create(machine)
 
-        if progress_bar is not None:
-            progress_bar += 1
+        EventDispatcher.get_instance().dispatch("machine_deployed", item=machine)
 
     def create(self, machine: Machine) -> None:
-        """Create a Kubernetes deployment and a PoD representing the device and assign it to machine.api_object.
+        """Create a Kubernetes deployment and a Pod representing the device and assign it to machine.api_object.
 
         Args:
             machine (Kathara.model.Machine.Machine): a Kathara device.
@@ -293,17 +286,21 @@ def _build_definition(machine: Machine, config_map: client.V1ConfigMap) -> clien
                     machine_commands="; ".join(machine.startup_commands)
                     )
 
-        post_start = client.V1Handler(
+        post_start = client.V1LifecycleHandler(
             _exec=client.V1ExecAction(
                 command=[Setting.get_instance().device_shell, "-c", startup_commands_string]
             )
         )
         lifecycle = client.V1Lifecycle(post_start=post_start)
 
-        env = [client.V1EnvVar("_MEGALOS_SHELL",
-                               machine.meta["shell"] if "shell" in machine.meta else Setting.get_instance().device_shell
-                               )
-               ]
+        env = [
+            client.V1EnvVar("_MEGALOS_SHELL",
+                            machine.meta["shell"] if "shell" in machine.meta else Setting.get_instance().device_shell
+                            )
+        ]
+
+        for env_var_name, env_var_value in machine.meta['envs'].items():
+            env.append(client.V1EnvVar(env_var_name, env_var_value))
 
         container_definition = client.V1Container(
             name=machine.meta['real_name'],
@@ -385,7 +382,7 @@ def _build_definition(machine: Machine, config_map: client.V1ConfigMap) -> clien
                                    )
 
     def undeploy(self, lab_hash: str, selected_machines: Optional[Set[str]] = None) -> None:
-        """Undeploy all the running Kubernetes deployments and PoDs contained in the scenario defined by the lab_hash.
+        """Undeploy all the running Kubernetes deployments and Pods contained in the scenario defined by the lab_hash.
 
         If selected_machines is not None, undeploy only the specified devices.
 
@@ -396,53 +393,43 @@ def undeploy(self, lab_hash: str, selected_machines: Optional[Set[str]] = None)
         Returns:
             None
         """
-        machines = self.get_machines_api_objects_by_filters(lab_hash=lab_hash)
+        pods = self.get_machines_api_objects_by_filters(lab_hash=lab_hash)
         if selected_machines is not None and len(selected_machines) > 0:
-            machines = [item for item in machines if item.metadata.labels["name"] in selected_machines]
+            pods = [item for item in pods if item.metadata.labels["name"] in selected_machines]
 
-        if len(machines) > 0:
+        if len(pods) > 0:
             pool_size = utils.get_pool_size()
             machines_pool = Pool(pool_size)
 
-            items = utils.chunk_list(machines, pool_size)
+            items = utils.chunk_list(pods, pool_size)
 
-            progress_bar = None
-            if utils.CLI_ENV:
-                progress_bar = progressbar.ProgressBar(
-                    widgets=['Deleting devices... ', progressbar.Bar(),
-                             ' ', progressbar.Counter(format='%(value)d/%(max_value)d')],
-                    redirect_stdout=True,
-                    max_value=len(machines)
-                )
+            EventDispatcher.get_instance().dispatch("machines_undeploy_started", items=pods)
 
             for chunk in items:
-                machines_pool.map(func=partial(self._undeploy_machine, progress_bar),
-                                  iterable=chunk
-                                  )
-            if utils.CLI_ENV:
-                progress_bar.finish()
+                machines_pool.map(func=self._undeploy_machine, iterable=chunk)
+
+            EventDispatcher.get_instance().dispatch("machines_undeploy_ended")
 
     def wipe(self) -> None:
-        """Undeploy all the running Kubernetes deployments and PoDs.
+        """Undeploy all the running Kubernetes deployments and Pods.
 
         Returns:
             None
         """
-        machines = self.get_machines_api_objects_by_filters()
+        pods = self.get_machines_api_objects_by_filters()
 
         pool_size = utils.get_pool_size()
         machines_pool = Pool(pool_size)
 
-        items = utils.chunk_list(machines, pool_size)
+        items = utils.chunk_list(pods, pool_size)
 
         for chunk in items:
-            machines_pool.map(func=partial(self._undeploy_machine, None), iterable=chunk)
+            machines_pool.map(func=self._undeploy_machine, iterable=chunk)
 
-    def _undeploy_machine(self, progress_bar: progressbar.ProgressBar, pod_api_object: client.V1Pod) -> None:
+    def _undeploy_machine(self, pod_api_object: client.V1Pod) -> None:
         """Undeploy a Kubernetes pod.
 
         Args:
-            progress_bar (Optional[progressbar.ProgressBar]): A progress bar object to display if used from cli.
             pod_api_object (client.V1Pod): The Kubernetes pod to undeploy.
 
         Returns:
@@ -451,14 +438,13 @@ def _undeploy_machine(self, progress_bar: progressbar.ProgressBar, pod_api_objec
 
         self._delete_machine(pod_api_object)
 
-        if progress_bar is not None:
-            progress_bar += 1
+        EventDispatcher.get_instance().dispatch("machine_undeployed", item=pod_api_object)
 
     def _delete_machine(self, pod_api_object: client.V1Pod) -> None:
-        """Delete the Kubernetes deployment and PoD associated to pod_api_object.
+        """Delete the Kubernetes deployment and Pod associated to pod_api_object.
 
         Args:
-            pod_api_object (client.V1Pod): A Kubernetes PoD API object.
+            pod_api_object (client.V1Pod): A Kubernetes Pod API object.
 
         Returns:
             None
@@ -491,24 +477,27 @@ def _delete_machine(self, pod_api_object: client.V1Pod) -> None:
 
     def connect(self, lab_hash: str, machine_name: str, shell: Union[str, List[str]] = None, logs: bool = False) \
             -> None:
-        """Open a stream to the Kubernetes PoD specified by machine_name using the specified shell.
+        """Open a stream to the Kubernetes Pod specified by machine_name using the specified shell.
 
         Args:
             lab_hash (str): The hash of the network scenario containing the device.
             machine_name (str): The name of the device to connect.
-            shell (Union[str, List[str]): The path to the desired shell.
+            shell (Union[str, List[str]]): The path to the desired shell.
             logs (bool): If True, print the logs of the startup command.
 
         Returns:
             None
         """
-        pod = self.get_machine_api_object(lab_hash=lab_hash, machine_name=machine_name)
+        pods = self.get_machines_api_objects_by_filters(lab_hash=lab_hash, machine_name=machine_name)
+        if not pods:
+            raise Exception("The specified device `%s` is not running." % machine_name)
+        deployment = pods.pop()
 
-        if 'Running' not in pod.status.phase:
+        if 'Running' not in deployment.status.phase:
             raise Exception('Device `%s` is not ready.' % machine_name)
 
         if not shell:
-            shell_env_value = self.get_env_var_value_from_pod(pod, "_MEGALOS_SHELL")
+            shell_env_value = self.get_env_var_value_from_pod(deployment, "_MEGALOS_SHELL")
             shell = shlex.split(shell_env_value if shell_env_value else Setting.get_instance().device_shell)
         else:
             shell = shlex.split(shell) if type(shell) == str else shell
@@ -531,7 +520,7 @@ def connect(self, lab_hash: str, machine_name: str, shell: Union[str, List[str]]
                 pass
 
         resp = stream(self.core_client.connect_get_namespaced_pod_exec,
-                      name=pod.metadata.name,
+                      name=deployment.metadata.name,
                       namespace=lab_hash,
                       command=shell,
                       stdout=True,
@@ -546,10 +535,10 @@ def connect(self, lab_hash: str, machine_name: str, shell: Union[str, List[str]]
 
     @staticmethod
     def get_env_var_value_from_pod(pod: client.V1Pod, var_name: str) -> Optional[str]:
-        """Return the value of an environment variable of the Kubernetes PoD.
+        """Return the value of an environment variable of the Kubernetes Pod.
 
         Args:
-            pod (client.V1Pod): A Kubernetes PoD.
+            pod (client.V1Pod): A Kubernetes Pod.
             var_name (str): The name of the environment variable.
 
         Returns:
@@ -577,7 +566,7 @@ def get_env_var_value_from_pod(pod: client.V1Pod, var_name: str) -> Optional[str
     def exec(self, lab_hash: str, machine_name: str, command: Union[str, List], tty: bool = False, stdin: bool = False,
              stdin_buffer: List[Union[str, bytes]] = None, stderr: bool = False, is_stream: bool = False) \
             -> Generator[Tuple[bytes, bytes], None, None]:
-        """Execute the command on the Kubernetes PoD specified by the lab_hash and the machine_name.
+        """Execute the command on the Kubernetes Pod specified by the lab_hash and the machine_name.
 
         Args:
             lab_hash (str): The hash of the network scenario containing the device.
@@ -599,7 +588,10 @@ def exec(self, lab_hash: str, machine_name: str, command: Union[str, List], tty:
 
         try:
             # Retrieve the pod of current Deployment
-            pod = self.get_machine_api_object(lab_hash=lab_hash, machine_name=machine_name)
+            pods = self.get_machines_api_objects_by_filters(lab_hash=lab_hash, machine_name=machine_name)
+            if not pods:
+                raise Exception("The specified device `%s` is not running." % machine_name)
+            pod = pods.pop()
 
             response = stream(self.core_client.connect_get_namespaced_pod_exec,
                               name=pod.metadata.name,
@@ -647,9 +639,19 @@ def exec(self, lab_hash: str, machine_name: str, command: Union[str, List], tty:
         if not is_stream:
             yield result['stdout'].encode('utf-8'), result['stderr'].encode('utf-8')
 
-    def copy_files(self, deployment: client.V1Deployment, path: str, tar_data: bytes) -> None:
-        machine_name = deployment.metadata.labels["name"]
-        machine_namespace = deployment.metadata.namespace
+    def copy_files(self, machine_api_object: client.V1Deployment, path: str, tar_data: bytes) -> None:
+        """Copy the files contained in tar_data in the Kubernetes deployment path specified by the machine_api_object.
+
+        Args:
+            machine_api_object (client.V1Deployment): A Kubernetes deployment.
+            path (str): The path of where copy the tar_data.
+            tar_data (bytes): The data to copy in the deployment.
+
+        Returns:
+            None
+        """
+        machine_name = machine_api_object.metadata.labels["name"]
+        machine_namespace = machine_api_object.metadata.namespace
 
         exec_output = self.exec(machine_namespace,
                                 machine_name,
@@ -664,14 +666,14 @@ def copy_files(self, deployment: client.V1Deployment, path: str, tar_data: bytes
             pass
 
     def get_machines_api_objects_by_filters(self, lab_hash: str = None, machine_name: str = None) -> List[client.V1Pod]:
-        """Return the List of Kubernetes PoD.
+        """Return the List of Kubernetes Pods.
 
         Args:
-            lab_hash (str): The hash of a network scenario. If specified, return all the Kubernetes PoD in the scenario.
-            machine_name (str): The name of a device. If specified, return the specified Kubernetes PoD of the scenario.
+            lab_hash (str): The hash of a network scenario. If specified, return all the Kubernetes Pod in the scenario.
+            machine_name (str): The name of a device. If specified, return the specified Kubernetes Pod of the scenario.
 
         Returns:
-            List[client.V1Pod]: A list of Kubernetes PoDs.
+            List[client.V1Pod]: A list of Kubernetes Pods objects.
         """
         filters = ["app=kathara"]
         if machine_name:
@@ -691,122 +693,39 @@ def get_machines_api_objects_by_filters(self, lab_hash: str = None, machine_name
 
         return machines
 
-    def get_machine_api_object(self, lab_hash: str, machine_name: str) -> client.V1Pod:
-        """Return the Kubernetes PoD specified by lab_hash and machine_name.
-
-        Args:
-            lab_hash (str): The hash of a network scenario.
-            machine_name (str): The name of a device.
-
-        Returns:
-            client.V1Pod: A Kubernets PoD.
-        """
-        pods = self.get_machines_api_objects_by_filters(lab_hash=lab_hash, machine_name=machine_name)
-
-        logging.debug("Found pods: %s" % len(pods))
-
-        if len(pods) != 1:
-            raise Exception("Error getting the device `%s` inside the lab." % machine_name)
-        else:
-            return pods[0]
-
-    def get_machine_info(self, machine_name: str, lab_hash: str = None) -> List[Dict[str, Any]]:
-        """Return a list of dicts containing the devices info.
-
-        Args:
-            machine_name (str): The name of a device
-            lab_hash (str): The hash of a network scenario. If specified, search only the devices in the corresponding
-            scenario.
-
-        Returns:
-            List[Dict[str, Any]]: A list of dicts containing the devices info.
-        """
-        machines_api_objects = self.get_machines_api_objects_by_filters(machine_name=machine_name, lab_hash=lab_hash)
-
-        if not machines_api_objects:
-            raise Exception("The specified device is not running.")
-
-        all_stats = []
-        for machine_api_object in machines_api_objects:
-            all_stats.append(self._get_stats_by_machine(machine_api_object))
-
-        return all_stats
-
-    def get_machines_info(self, lab_hash: str, machine_name: str = None) -> Generator[Dict[str, Any], None, None]:
-        """Return a generator containing the info of the specified device.
+    def get_machines_stats(self, lab_hash: str = None, machine_name: str = None) -> \
+            Generator[Dict[str, KubernetesMachineStats], None, None]:
+        """Return a generator containing the Kubernetes devices' stats.
 
         Args:
-            lab_hash (str): The hash of a network scenario. If specified, return all the devices in the scenario.
-            machine_name (str): The name of a device. If specified, return the specified container of the scenario.
+            lab_hash (str): The hash of a network scenario. If specified, return all the stats of the devices in the
+                scenario.
+            machine_name (str): The name of a device. If specified, return the specified device stats.
 
         Returns:
-            Generator[Dict[str, Any], None, None]: A generator containing the info of the specified device.
+            Generator[Dict[str, KubernetesMachineStats], None, None]: A generator containing device name as keys and
+                KubernetesMachineStats as values.
         """
         while True:
-            machines = self.get_machines_api_objects_by_filters(lab_hash=lab_hash, machine_name=machine_name)
-
-            if not machines:
-                if not lab_hash:
-                    raise Exception("No devices running.")
+            pods = self.get_machines_api_objects_by_filters(lab_hash=lab_hash, machine_name=machine_name)
+            if not pods:
+                if not machine_name:
+                    raise Exception("No devices found.")
                 else:
-                    raise Exception("Lab is not started.")
+                    raise Exception(f"Devices with name {machine_name} not found.")
 
-            machines = sorted(machines, key=lambda x: x.metadata.labels["name"])
+            machines_stats = {}
 
-            machines_stats = []
-            for machine in machines:
-                machines_stats.append(self._get_stats_by_machine(machine))
+            for pod in pods:
+                machines_stats[pod.metadata.name] = KubernetesMachineStats(pod)
 
-            yield machines_stats
-
-    def _get_stats_by_machine(self, pod_api_object: client.V1Pod) -> Dict[str, str]:
-        """Return the stats of the specified Kubernetes PoD.
-
-        Args:
-            pod_api_object (client.V1Pod): A Docker container.
-
-        Returns:
-            Dict[str, Any]: A dict containing formatted Kathara device stats.
-        """
+            for machine_stats in machines_stats.values():
+                try:
+                    machine_stats.update()
+                except StopIteration:
+                    continue
 
-        container_statuses = pod_api_object.status.container_statuses
-        image_name = container_statuses[0].image.replace('docker.io/', '') if container_statuses else "N/A"
-
-        return {
-            "real_lab_hash": pod_api_object.metadata.namespace,
-            "name": pod_api_object.metadata.labels["name"],
-            "real_name": pod_api_object.metadata.name,
-            "status": self._get_detailed_machine_status(pod_api_object),
-            "image": image_name,
-            "assigned_node": pod_api_object.spec.node_name
-        }
-
-    @staticmethod
-    def _get_detailed_machine_status(pod_api_object: client.V1Pod) -> str:
-        """Return a string containing the Kubernetes PoD status.
-
-        Args:
-            pod_api_object (client.V1Pod): A Kubernetes PoD.
-
-        Returns:
-            str: A string containing the Kubernetes PoD status.
-        """
-        container_statuses = pod_api_object.status.container_statuses
-
-        if not container_statuses:
-            return pod_api_object.status.phase
-
-        container_status = container_statuses[0].state
-
-        string_status = None
-        if container_status.terminated is not None:
-            string_status = container_status.terminated.reason if container_status.terminated.reason is not None \
-                else "Terminating"
-        elif container_status.waiting is not None:
-            string_status = container_status.waiting.reason
-
-        # In case the status contains an error message, split it to the first ": " and take the left part
-        return string_status.split(': ')[0] if string_status is not None else pod_api_object.status.phase
+            yield machines_stats
 
     @staticmethod
     def get_deployment_name(name: str) -> str:
diff --git a/src/Kathara/manager/kubernetes/KubernetesManager.py b/src/Kathara/manager/kubernetes/KubernetesManager.py
index 5d82ffa9..03b7d65a 100644
--- a/src/Kathara/manager/kubernetes/KubernetesManager.py
+++ b/src/Kathara/manager/kubernetes/KubernetesManager.py
@@ -1,16 +1,17 @@
 import io
 import json
-from datetime import datetime
-from typing import Set, Dict, Generator, Any, List, Tuple
+import logging
+from typing import Set, Dict, Generator, Any, List, Tuple, Optional
 
 from kubernetes import client
 from kubernetes.client.rest import ApiException
-from terminaltables import DoubleTable
 
 from .KubernetesConfig import KubernetesConfig
 from .KubernetesLink import KubernetesLink
 from .KubernetesMachine import KubernetesMachine
 from .KubernetesNamespace import KubernetesNamespace
+from .stats.KubernetesLinkStats import KubernetesLinkStats
+from .stats.KubernetesMachineStats import KubernetesMachineStats
 from ... import utils
 from ...exceptions import NotSupportedError
 from ...foundation.manager.IManager import IManager
@@ -29,7 +30,7 @@ def __init__(self) -> None:
 
         self.k8s_namespace: KubernetesNamespace = KubernetesNamespace()
         self.k8s_machine: KubernetesMachine = KubernetesMachine(self.k8s_namespace)
-        self.k8s_link: KubernetesLink = KubernetesLink()
+        self.k8s_link: KubernetesLink = KubernetesLink(self.k8s_namespace)
 
     def deploy_lab(self, lab: Lab, selected_machines: Set[str] = None) -> None:
         """Deploy a Kathara network scenario.
@@ -71,16 +72,29 @@ def update_lab(self, lab: Lab) -> None:
         """
         raise NotSupportedError("Unable to update a running lab.")
 
-    def undeploy_lab(self, lab_hash: str, selected_machines: Set[str] = None) -> None:
+    def undeploy_lab(self, lab_hash: Optional[str] = None, lab_name: Optional[str] = None,
+                     selected_machines: Optional[Set[str]] = None) -> None:
         """Undeploy a Kathara network scenario.
 
         Args:
-            lab_hash (str): The hash of the network scenario to undeploy.
-            selected_machines (Set[str]): If not None, undeploy only the specified devices.
+            lab_hash (Optional[str]): The hash of the network scenario. Can be used as an alternative to lab_name.
+                If None, lab_name should be set.
+            lab_name (Optional[str]): The name of the network scenario. Can be used as an alternative to lab_hash.
+                If None, lab_hash should be set.
+            selected_machines (Optional[Set[str]]): If not None, undeploy only the specified devices.
 
         Returns:
             None
+
+        Raises:
+            Exception: You must specify a running network scenario hash or name.
         """
+        if not lab_hash and not lab_name:
+            raise Exception("You must specify a running network scenario hash or name.")
+
+        if lab_name:
+            lab_hash = utils.generate_urlsafe_hash(lab_name)
+
         lab_hash = lab_hash.lower()
 
         # When only some machines should be undeployed, special checks are required.
@@ -124,32 +138,43 @@ def wipe(self, all_users: bool = False) -> None:
         """Undeploy all the running network scenarios.
 
         Args:
-        all_users (bool): If false, undeploy only the current user network scenarios. If true, undeploy the
-           running network scenarios of all users.
+            all_users (bool): If false, undeploy only the current user network scenarios. If true, undeploy the
+                running network scenarios of all users.
 
         Returns:
             None
         """
         if all_users:
-            raise NotSupportedError("Cannot use `--all` flag.")
+            logging.warning("User-specific options have no effect on Megalos.")
 
         self.k8s_machine.wipe()
         self.k8s_link.wipe()
 
         self.k8s_namespace.wipe()
 
-    def connect_tty(self, lab_hash: str, machine_name: str, shell: str = None, logs: bool = False) -> None:
+    def connect_tty(self, machine_name: str, lab_hash: Optional[str] = None, lab_name: Optional[str] = None,
+                    shell: str = None, logs: bool = False) -> None:
         """Connect to a device in a running network scenario, using the specified shell.
 
         Args:
-            lab_hash (str): The hash of the network scenario to undeploy.
             machine_name (str): The name of the device to connect.
+            lab_hash (str): The hash of the network scenario where the device is deployed.
+            lab_name (str): The name of the network scenario where the device is deployed.
             shell (str): The name of the shell to use for connecting.
             logs (bool): If True, print startup logs on stdout.
 
         Returns:
             None
+
+        Raises:
+            Exception: You must specify a running network scenario hash or name.
         """
+        if not lab_hash and not lab_name:
+            raise Exception("You must specify a running network scenario hash or name.")
+
+        if lab_name:
+            lab_hash = utils.generate_urlsafe_hash(lab_name)
+
         lab_hash = lab_hash.lower()
 
         self.k8s_machine.connect(lab_hash=lab_hash,
@@ -158,17 +183,28 @@ def connect_tty(self, lab_hash: str, machine_name: str, shell: str = None, logs:
                                  logs=logs
                                  )
 
-    def exec(self, lab_hash: str, machine_name: str, command: str) -> Generator[Tuple[bytes, bytes], None, None]:
+    def exec(self, machine_name: str, command: str, lab_hash: Optional[str] = None, lab_name: Optional[str] = None) -> \
+            Generator[Tuple[bytes, bytes], None, None]:
         """Exec a command on a device in a running network scenario.
 
         Args:
-            lab_hash (str): The hash of the network scenario to undeploy.
             machine_name (str): The name of the device to connect.
-            command (str): The command to exec on the device
+            command (str): The command to exec on the device.
+            lab_hash (Optional[str]): The hash of the network scenario where the device is deployed.
+            lab_name (Optional[str]): The name of the network scenario where the device is deployed.
 
         Returns:
             Generator[Tuple[bytes, bytes]]: A generator of tuples containing the stdout and stderr in bytes.
+
+        Raises:
+            Exception: You must specify a running network scenario hash or name.
         """
+        if not lab_hash and not lab_name:
+            raise Exception("You must specify a running network scenario hash or name.")
+
+        if lab_name:
+            lab_hash = utils.generate_urlsafe_hash(lab_name)
+
         lab_hash = lab_hash.lower()
 
         return self.k8s_machine.exec(lab_hash, machine_name, command, stderr=True, tty=False, is_stream=True)
@@ -186,138 +222,223 @@ def copy_files(self, machine: Machine, guest_to_host: Dict[str, io.IOBase]) -> N
         """
         tar_data = pack_files_for_tar(guest_to_host)
 
-        self.k8s_machine.copy_files(machine.api_object,
-                                    path="/",
-                                    tar_data=tar_data
-                                    )
+        self.k8s_machine.copy_files(machine.api_object, path="/", tar_data=tar_data)
 
-    def get_lab_info(self, lab_hash: str = None, machine_name: str = None, all_users: bool = False) -> \
-            Generator[Dict[str, Any], None, None]:
-        """Return information about the running devices.
+    def get_machine_api_object(self, machine_name: str, lab_hash: str = None, lab_name: str = None,
+                               all_users: bool = False) -> client.V1Pod:
+        """Return the corresponding API object of a running device in a network scenario.
 
         Args:
-            lab_hash (str): If not None, return information of the corresponding network scenario.
-            machine_name (str): If not None, return information of the specified device.
-            all_users (bool): If True, return information about the device of all users.
+            machine_name (str): The name of the device.
+            lab_hash (str): The hash of the network scenario. Can be used as an alternative to lab_name.
+                If None, lab_name should be set.
+            lab_name (str): The name of the network scenario. Can be used as an alternative to lab_name.
+                If None, lab_hash should be set.
+            all_users (bool): If True, return information about devices of all users.
 
         Returns:
-              Generator[Dict[str, Any], None, None]: A generator containing dicts containing device names as keys and
-              their info as values.
+            client.V1Pod: A Kubernetes Pod.
         """
-        if lab_hash:
-            lab_hash = lab_hash.lower()
+        if all_users:
+            logging.warning("User-specific options have no effect on Megalos.")
 
-        lab_info = self.k8s_machine.get_machines_info(lab_hash=lab_hash, machine_name=machine_name)
+        if not lab_hash and not lab_name:
+            raise Exception("You must specify a running network scenario hash or name.")
 
-        return lab_info
+        if lab_name:
+            lab_hash = utils.generate_urlsafe_hash(lab_name)
 
-    def get_formatted_lab_info(self, lab_hash: str = None, machine_name: str = None, all_users: bool = False) -> str:
-        """Return a formatted string with the information about the running devices.
+        lab_hash = lab_hash.lower()
+
+        pods = self.k8s_machine.get_machines_api_objects_by_filters(lab_hash=lab_hash, machine_name=machine_name)
+        if pods:
+            return pods.pop()
+
+        raise Exception(f"Device {machine_name} not found.")
+
+    def get_machines_api_objects(self, lab_hash: str = None, lab_name: str = None, all_users: bool = False) -> \
+            List[client.V1Pod]:
+        """Return API objects of running devices.
 
         Args:
-            lab_hash (str): If not None, return information of the corresponding network scenario.
-            machine_name (str): If not None, return information of the specified device.
-            all_users (bool): If True, return information about the device of all users.
+            lab_hash (str): The hash of the network scenario. Can be used as an alternative to lab_name.
+            lab_name (str): The name of the network scenario. Can be used as an alternative to lab_name.
+            all_users (bool): If True, return information about devices of all users.
 
         Returns:
-             str: String containing devices info
+            List[client.V1Pod]: Kubernetes Pod objects of devices.
         """
         if all_users:
-            raise NotSupportedError("Cannot use `--all` flag on Megalos.")
+            logging.warning("User-specific options have no effect on Megalos.")
+
+        if lab_name:
+            lab_hash = utils.generate_urlsafe_hash(lab_name)
+
+        lab_hash = lab_hash.lower() if lab_hash else None
+
+        return self.k8s_machine.get_machines_api_objects_by_filters(lab_hash=lab_hash)
 
-        table_header = ["LAB HASH", "DEVICE NAME", "STATUS", "ASSIGNED NODE"]
-        stats_table = DoubleTable([])
-        stats_table.inner_row_border = True
+    def get_link_api_object(self, link_name: str, lab_hash: str = None, lab_name: str = None,
+                            all_users: bool = False) -> Any:
+        """Return the corresponding API object of a collision domain in a network scenario.
 
-        lab_info = self.get_lab_info(lab_hash=lab_hash, machine_name=machine_name)
+        Args:
+            link_name (str): The name of the collision domain.
+            lab_hash (str): The hash of the network scenario. Can be used as an alternative to lab_name.
+                If None, lab_name should be set.
+            lab_name (str): The name of the network scenario. Can be used as an alternative to lab_name.
+                If None, lab_hash should be set.
+            all_users (bool): If True, return information about collision domains of all users.
 
-        while True:
-            machines_data = [
-                table_header
-            ]
+        Returns:
+            Any: Kubernetes API object of the network.
+        """
+        if all_users:
+            logging.warning("User-specific options have no effect on Megalos.")
 
-            try:
-                result = next(lab_info)
-            except StopIteration:
-                return
+        if not lab_hash and not lab_name:
+            raise Exception("You must specify a running network scenario hash or name.")
 
-            if not result:
-                return
+        if lab_name:
+            lab_hash = utils.generate_urlsafe_hash(lab_name)
 
-            for machine_stats in result:
-                machines_data.append([machine_stats["real_lab_hash"],
-                                      machine_stats["name"],
-                                      machine_stats["status"],
-                                      machine_stats["assigned_node"]
-                                      ])
+        lab_hash = lab_hash.lower()
 
-            stats_table.table_data = machines_data
+        networks = self.k8s_link.get_links_api_objects_by_filters(lab_hash=lab_hash, link_name=link_name)
+        if networks:
+            return networks.pop()
 
-            yield "TIMESTAMP: %s" % datetime.now() + "\n\n" + stats_table.table
+        raise Exception(f"Collision Domain {link_name} not found.")
 
-    def get_machine_api_object(self, lab_hash: str, machine_name: str) -> client.V1Pod:
-        """Return the corresponding API object of a running device in a network scenario.
+    def get_links_api_objects(self, lab_hash: str = None, lab_name: str = None, all_users: bool = False) -> \
+            List[Any]:
+        """Return API objects of collision domains in a network scenario.
 
         Args:
-            lab_hash (str): The hash of the network scenario.
-            machine_name (str): The name of the device.
+            lab_hash (str): The hash of the network scenario. Can be used as an alternative to lab_name.
+            lab_name (str): The name of the network scenario. Can be used as an alternative to lab_name.
+            all_users (bool): If True, return information about collision domains of all users.
 
         Returns:
-            client.V1Pod: A Kubernetes PoD.
+            List[Any]: Kubernetes API objects of networks.
         """
-        lab_hash = lab_hash.lower()
-        return self.k8s_machine.get_machine_api_object(lab_hash, machine_name)
+        if all_users:
+            logging.warning("User-specific options have no effect on Megalos.")
+
+        if lab_name:
+            lab_hash = utils.generate_urlsafe_hash(lab_name)
+
+        lab_hash = lab_hash.lower() if lab_hash else None
+
+        return self.k8s_link.get_links_api_objects_by_filters(lab_hash=lab_hash)
 
-    def get_machine_info(self, machine_name: str, lab_hash: str = None, all_users: bool = False) \
-            -> List[Dict[str, Any]]:
-        """Return information of running devices with a specified name.
+    def get_machines_stats(self, lab_hash: str = None, lab_name: str = None, machine_name: str = None,
+                           all_users: bool = False) -> Generator[Dict[str, KubernetesMachineStats], None, None]:
+        """Return information about the running devices.
 
         Args:
-            machine_name (str): The device name.
-            lab_hash (str): If not None, search the device in the specified network scenario.
-            all_users (bool): If True, search the device among all the users devices.
+            lab_hash (str): The hash of the network scenario. Can be used as an alternative to lab_name.
+            lab_name (str): The name of the network scenario. Can be used as an alternative to lab_hash.
+            machine_name (str): If specified return all the devices with machine_name.
+            all_users (bool): If True, return information about the device of all users.
 
         Returns:
-            List[Dict[str, Any]]: A list of dicts containing the devices info.
+              Generator[Dict[str, KubernetesMachineStats], None, None]: A generator containing dicts that has API Object
+                identifier as keys and KubernetesMachineStats objects as values.
         """
-        if lab_hash:
-            lab_hash = lab_hash.lower()
+        if all_users:
+            logging.warning("User-specific options have no effect on Megalos.")
+
+        if lab_name:
+            lab_hash = utils.generate_urlsafe_hash(lab_name)
 
-        machine_stats = self.k8s_machine.get_machine_info(machine_name=machine_name, lab_hash=lab_hash)
+        lab_hash = lab_hash.lower() if lab_hash else None
 
-        return machine_stats
+        return self.k8s_machine.get_machines_stats(lab_hash=lab_hash, machine_name=machine_name)
 
-    def get_formatted_machine_info(self, machine_name: str, lab_hash: str = None, all_users: bool = False) -> str:
-        """Return formatted information of running devices with a specified name.
+    def get_machine_stats(self, machine_name: str, lab_hash: str = None, lab_name: str = None,
+                          all_users: bool = False) -> Generator[KubernetesMachineStats, None, None]:
+        """Return information of the specified device in a specified network scenario.
 
         Args:
             machine_name (str): The device name.
-            lab_hash (str): If not None, search the device in the specified network scenario.
+            lab_hash (str): The hash of the network scenario. Can be used as an alternative to lab_name.
+                If None, lab_name should be set.
+            lab_name (str): The name of the network scenario. Can be used as an alternative to lab_hash.
+                If None, lab_hash should be set.
             all_users (bool): If True, search the device among all the users devices.
 
         Returns:
-            str: The formatted devices properties.
+            KubernetesMachineStats: KubernetesMachineStats object containing the device info.
+        """
+        if not lab_hash and not lab_name:
+            raise Exception("You must specify a running network scenario hash or name.")
+
+        if lab_name:
+            lab_hash = utils.generate_urlsafe_hash(lab_name)
+
+        lab_hash = lab_hash.lower()
+
+        machines_stats = self.get_machines_stats(lab_hash=lab_hash, machine_name=machine_name)
+        (_, machine_stats) = next(machines_stats).popitem()
+
+        yield machine_stats
+
+    def get_links_stats(self, lab_hash: str = None, lab_name: str = None, link_name: str = None,
+                        all_users: bool = False) -> Generator[Dict[str, KubernetesLinkStats], None, None]:
+        """Return information about deployed Kubernetes networks.
+
+        Args:
+           lab_hash (str): The hash of the network scenario. Can be used as an alternative to lab_name.
+           lab_name (str): The name of the network scenario. Can be used as an alternative to lab_hash.
+           link_name (str): If specified return all the networks with link_name.
+           all_users (bool): If True, return information about the networks of all users.
+
+        Returns:
+             Generator[Dict[str, KubernetesLinkStats], None, None]: A generator containing dicts that has API Object
+                identifier as keys and KubernetesLinkStats objects as values.
         """
         if all_users:
-            raise NotSupportedError("Cannot use `--all` flag.")
+            logging.warning("User-specific options have no effect on Megalos.")
+
+        if lab_name:
+            lab_hash = utils.generate_urlsafe_hash(lab_name)
+
+        lab_hash = lab_hash.lower() if lab_hash else None
+
+        return self.k8s_link.get_links_stats(lab_hash=lab_hash, link_name=link_name)
 
-        machines_stats = self.get_machine_info(machine_name, lab_hash=lab_hash)
+    def get_link_stats(self, link_name: str, lab_hash: str = None, lab_name: str = None, all_users: bool = False) -> \
+            Generator[KubernetesLinkStats, None, None]:
+        """Return information of the specified deployed network in a specified network scenario.
 
-        machines_info = []
+        Args:
+            link_name (str): The link name.
+            lab_hash (str): The hash of the network scenario. Can be used as an alternative to lab_name.
+                If None, lab_name should be set.
+            lab_name (str): The name of the network scenario. Can be used as an alternative to lab_hash.
+                If None, lab_hash should be set.
+            all_users (bool): If True, search the network among all the users networks.
+
+        Returns:
+            Generator[KubernetesLinkStats, None, None]: A generator containing KubernetesLinkStats objects with
+                the network statistics.
+
+        Raises:
+            Exception: You must specify a running network scenario hash or name.
+        """
+        if not lab_hash and not lab_name:
+            raise Exception("You must specify a running network scenario hash or name.")
 
-        for machine_stats in machines_stats:
-            machine_info = utils.format_headers("Device information") + "\n"
-            machine_info += "Lab Hash: %s\n" % machine_stats["real_lab_hash"]
-            machine_info += "Device Name: %s\n" % machine_stats["name"]
-            machine_info += "Real Device Name: %s\n" % machine_stats["real_name"]
-            machine_info += "Status: %s\n" % machine_stats["status"]
-            machine_info += "Image: %s\n" % machine_stats["image"]
-            machine_info += "Assigned Node: %s\n" % machine_stats["assigned_node"]
-            machine_info += utils.format_headers()
+        if lab_name:
+            lab_hash = utils.generate_urlsafe_hash(lab_name)
+
+        lab_hash = lab_hash.lower()
 
-            machines_info.append(machine_info)
+        links_stats = self.get_links_stats(lab_hash=lab_hash, link_name=link_name, all_users=all_users)
+        (_, link_stats) = next(links_stats).popitem()
 
-        return "\n\n".join(machines_info)
+        yield link_stats
 
     def check_image(self, image_name: str) -> None:
         """Useless. The Check of the image is delegated to Kubernetes.
diff --git a/src/Kathara/manager/kubernetes/stats/KubernetesLinkStats.py b/src/Kathara/manager/kubernetes/stats/KubernetesLinkStats.py
new file mode 100644
index 00000000..43dec030
--- /dev/null
+++ b/src/Kathara/manager/kubernetes/stats/KubernetesLinkStats.py
@@ -0,0 +1,61 @@
+import json
+from typing import Dict, Any
+
+from ....foundation.manager.stats.ILinkStats import ILinkStats
+
+
+class KubernetesLinkStats(ILinkStats):
+    """The class responsible to handle Kubernetes Networks statistics.
+
+    Attributes:
+        link_api_object (Any): The Kubernetes Network associated with this statistics.
+        lab_hash (str): The hash identifier of the network scenario of the Kubernetes Network.
+        name (str): The name of the collision domain.
+        network_name (str): The Kubernetes Network Name.
+        vxlan_id (str): The VXLAN Identifier associated to this network.
+    """
+    __slots__ = ['link_api_object', 'lab_hash', 'name', 'network_name', 'vxlan_id']
+
+    def __init__(self, link_api_object: Any):
+        self.link_api_object: Any = link_api_object
+        self.lab_hash: str = link_api_object['metadata']['namespace']
+        self.name: str = link_api_object['metadata']['labels']['name']
+        self.network_name: str = link_api_object['metadata']['name']
+        self.vxlan_id = json.loads(link_api_object['spec']['config'])['vxlanId']
+
+    def update(self) -> None:
+        """Update dynamic statistics with the current ones.
+
+        Returns:
+            None
+        """
+        return
+
+    def to_dict(self) -> Dict[str, Any]:
+        """Transform statistics into a dict representation.
+
+        Returns:
+            Dict[str, Any]: Dict containing statistics.
+        """
+        return {
+            "network_scenario_id": self.lab_hash,
+            "name": self.name,
+            "network_name": self.network_name,
+            "vxlan_id": self.vxlan_id
+        }
+
+    def __repr__(self) -> str:
+        return str(self.to_dict())
+
+    def __str__(self) -> str:
+        """Return a formatted string with the link statistics.
+
+        Returns:
+           str: A formatted string with the link statistics
+        """
+        formatted_stats = f"Network Scenario ID: {self.lab_hash}"
+        formatted_stats += f"\nLink Name: {self.name}"
+        formatted_stats += f"\nNetwork Name: {self.network_name}"
+        formatted_stats += f"\nVXLAN ID: {self.vxlan_id}"
+
+        return formatted_stats
diff --git a/src/Kathara/manager/kubernetes/stats/KubernetesMachineStats.py b/src/Kathara/manager/kubernetes/stats/KubernetesMachineStats.py
new file mode 100644
index 00000000..ae21bf50
--- /dev/null
+++ b/src/Kathara/manager/kubernetes/stats/KubernetesMachineStats.py
@@ -0,0 +1,95 @@
+from typing import Dict, Any
+
+from kubernetes.client import V1Pod
+
+from ....foundation.manager.stats.IMachineStats import IMachineStats
+
+
+class KubernetesMachineStats(IMachineStats):
+    """The class responsible to handle Kubernetes Machine statistics."""
+    __slots__ = ['machine_api_object', 'lab_hash', 'name', 'pod_name', 'image', 'status', 'assigned_node']
+
+    def __init__(self, machine_api_object: V1Pod):
+        self.machine_api_object = machine_api_object
+        # Static Information
+        self.lab_hash = machine_api_object.metadata.namespace
+        self.name = machine_api_object.metadata.labels["name"]
+        self.pod_name = machine_api_object.metadata.name
+
+        container_statuses = machine_api_object.status.container_statuses
+        self.image = container_statuses[0].image.replace('docker.io/', '') if container_statuses else "N/A"
+
+        # Dynamic Information
+        self.status = None
+        self.assigned_node = None
+
+        self.update()
+
+    def update(self) -> None:
+        """Update dynamic statistics with the current ones.
+
+        Returns:
+            None
+        """
+        self.status = self._get_detailed_machine_status(self.machine_api_object)
+        self.assigned_node = self.machine_api_object.spec.node_name
+
+    @staticmethod
+    def _get_detailed_machine_status(pod_api_object: V1Pod) -> str:
+        """Return a string containing the Kubernetes Pod status.
+
+        Args:
+            pod_api_object (client.V1Pod): A Kubernetes Pod.
+
+        Returns:
+            str: A string containing the Kubernetes Pod status.
+        """
+        container_statuses = pod_api_object.status.container_statuses
+
+        if not container_statuses:
+            return pod_api_object.status.phase
+
+        container_status = container_statuses[0].state
+
+        string_status = None
+        if container_status.terminated is not None:
+            string_status = container_status.terminated.reason if container_status.terminated.reason is not None \
+                else "Terminating"
+        elif container_status.waiting is not None:
+            string_status = container_status.waiting.reason
+
+        # In case the status contains an error message, split it to the first ": " and take the left part
+        return string_status.split(': ')[0] if string_status is not None else pod_api_object.status.phase
+
+    def to_dict(self) -> Dict[str, Any]:
+        """Transform statistics into a dict representation.
+
+        Returns:
+            Dict[str, Any]: Dict containing statistics.
+        """
+        return {
+            "network_scenario_id": self.lab_hash,
+            "name": self.name,
+            "pod_name": self.pod_name,
+            "image": self.image,
+            "status": self.status,
+            "assigned_node": self.assigned_node
+        }
+
+    def __repr__(self) -> str:
+        return str(self.to_dict())
+
+    def __str__(self) -> str:
+        """Return a formatted string with the device statistics.
+
+        Returns:
+           str: a formatted string with the device statistics
+        """
+        formatted_stats = f"Network Scenario ID: {self.lab_hash}\n"
+        formatted_stats += f"Device Name: {self.name}\n"
+        formatted_stats += f"Pod Name: {self.pod_name}\n"
+        formatted_stats += f"Image: {self.image}\n"
+        formatted_stats += f"Status: {self.status}\n"
+        formatted_stats += f"Assigned Node: {self.assigned_node}"
+
+        return formatted_stats
diff --git a/src/Kathara/manager/kubernetes/stats/__init__.py b/src/Kathara/manager/kubernetes/stats/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/Kathara/model/Lab.py b/src/Kathara/model/Lab.py
index 4db5cf91..35449f85 100644
--- a/src/Kathara/model/Lab.py
+++ b/src/Kathara/model/Lab.py
@@ -3,9 +3,9 @@
 from itertools import chain
 from typing import Dict, Set, Any, List, Union, Optional
 
+from . import Machine as MachinePackage
 from .ExternalLink import ExternalLink
 from .Link import Link
-from .Machine import Machine
 from .. import utils
 
 
@@ -52,7 +52,7 @@ def __init__(self, name: Optional[str], path: str = None) -> None:
         self.email: Optional[str] = None
         self.web: Optional[str] = None
 
-        self.machines: Dict[str, Machine] = {}
+        self.machines: Dict[str, 'MachinePackage.Machine'] = {}
         self.links: Dict[str, Link] = {}
 
         self.general_options: Dict[str, Any] = {}
@@ -66,7 +66,6 @@ def __init__(self, name: Optional[str], path: str = None) -> None:
         self.hash: str = ""
 
         if self.path:
-            self.path = path
             self.hash = utils.generate_urlsafe_hash(self.path)
             shared_startup_file = os.path.join(self.path, 'shared.startup')
             self.shared_startup_path = shared_startup_file if os.path.exists(shared_startup_file) else None
@@ -99,7 +98,7 @@ def connect_machine_to_link(self, machine_name: str, link_name: str, machine_ifa
         machine.add_interface(link, number=machine_iface_number)
 
     def assign_meta_to_machine(self, machine_name: str, meta_name: str, meta_value: str) -> None:
-        """Assign a meta information to the specified device.
+        """Assign meta information to the specified device.
 
         Args:
             machine_name (str): The name of the device.
@@ -134,18 +133,22 @@ def attach_external_links(self, external_links: Dict[str, List[ExternalLink]]) -
             self.links[link_name].external += link_external_links
 
     def check_integrity(self) -> None:
-        """Check if the network interfaces numbers of all the devices in the network scenario are correctly assigned."""
+        """Check if the network interfaces numbers of all the devices in the network scenario are correctly assigned.
+
+        Returns:
+            None
+        """
         for machine in self.machines:
             self.machines[machine].check()
 
     def get_links_from_machines(self, selected_machines: Union[List[str], Set[str]]) -> Dict[str, Link]:
-        """Return the name of the links connected to the selected_machines.
+        """Return the name of the collision domains connected to the selected_machines.
 
         Args:
             selected_machines (Set[str]): A set with selected devices names.
 
         Returns:
-            Dict[str, Link]: Keys are Link names, values are Link objects.
+            Dict[str, Link]: Keys are collision domains names, values are Link objects.
         """
         # Intersect selected machines names with self.machines keys
         selected_machines = set(self.machines.keys()) & set(selected_machines)
@@ -167,7 +170,6 @@ def apply_dependencies(self, dependencies: List[str]) -> None:
         Returns:
             None
         """
-
         def dep_sort(item: str) -> int:
             try:
                 return dependencies.index(item) + 1
@@ -177,7 +179,7 @@ def dep_sort(item: str) -> int:
         self.machines = collections.OrderedDict(sorted(self.machines.items(), key=lambda t: dep_sort(t[0])))
         self.has_dependencies = True
 
-    def get_or_new_machine(self, name: str, **kwargs: Dict[str, Any]) -> Machine:
+    def get_or_new_machine(self, name: str, **kwargs: Dict[str, Any]) -> 'MachinePackage.Machine':
         """Get the specified device. If it not exists, create and add it to the devices list.
 
         Args:
@@ -189,7 +191,7 @@ def get_or_new_machine(self, name: str, **kwargs: Dict[str, Any]) -> Machine:
             Kathara.model.Machine: A Kathara device.
         """
         if name not in self.machines:
-            self.machines[name] = Machine(self, name, **kwargs)
+            self.machines[name] = MachinePackage.Machine(self, name, **kwargs)
 
         return self.machines[name]
 
diff --git a/src/Kathara/model/Machine.py b/src/Kathara/model/Machine.py
index 828ac2fd..8770e922 100644
--- a/src/Kathara/model/Machine.py
+++ b/src/Kathara/model/Machine.py
@@ -3,16 +3,15 @@
 import os
 import re
 import shutil
-import subprocess
-import sys
 import tarfile
 import tempfile
 from distutils.util import strtobool
 from pathlib import Path
-from typing import Dict, Any, Tuple, Optional
+from typing import Dict, Any, Tuple, Optional, List, OrderedDict
 
 from . import Lab as LabPackage
 from . import Link as LinkPackage
+from .Link import Link
 from .. import utils
 from ..exceptions import NonSequentialMachineInterfaceError, MachineOptionError, MachineCollisionDomainConflictError
 from ..setting.Setting import Setting
@@ -26,7 +25,7 @@ class Machine(object):
     Attributes:
         lab (Kathara.model.Lab): The Kathara network Scenario of the device.
         name (str): The name of the device.
-        interfaces (List[Kathara.model.Link]): A list of the collision domains of the device.
+        interfaces (collection.OrderedDict[int, Kathara.model.Link]): A list of the collision domains of the device.
         meta (Dict[str, Any]): Keys are meta properties name, values are meta properties values.
         startup_commands (List[str]): A list of commands to execute at the device startup.
         api_object (Any): To interact with the current Kathara Manager.
@@ -55,25 +54,26 @@ def __init__(self, lab: 'LabPackage.Lab', name: str, **kwargs) -> None:
             raise Exception("Invalid device name `%s`." % name)
 
         self.lab: LabPackage.Lab = lab
-        self.name = name
+        self.name: str = name
 
-        self.interfaces = {}
+        self.interfaces: OrderedDict[int, Link] = collections.OrderedDict()
 
         self.meta: Dict[str, Any] = {
             'sysctls': {},
+            'envs': {},
             'bridged': False,
             'ports': {}
         }
 
-        self.startup_commands = []
+        self.startup_commands: List[str] = []
 
-        self.api_object = None
+        self.api_object: Any = None
 
-        self.capabilities = ["NET_ADMIN", "NET_RAW", "NET_BROADCAST", "NET_BIND_SERVICE", "SYS_ADMIN"]
+        self.capabilities: List[str] = ["NET_ADMIN", "NET_RAW", "NET_BROADCAST", "NET_BIND_SERVICE", "SYS_ADMIN"]
 
-        self.startup_path = None
-        self.shutdown_path = None
-        self.folder = None
+        self.startup_path: Optional[str] = None
+        self.shutdown_path: Optional[str] = None
+        self.folder: Optional[str] = None
 
         if lab.has_path():
             startup_file = os.path.join(lab.path, '%s.startup' % self.name)
@@ -135,21 +135,33 @@ def add_meta(self, name: str, value: Any) -> None:
             return
 
         if name == "sysctl":
+            matches = re.search(r"^(?P<key>net\.(\w+\.)+\w+)=(?P<value>\w+)$", value)
+
+            # Check for valid kv-pair
+            if matches:
+                key = matches.group("key").strip()
+                val = matches.group("value").strip()
+
+                # Convert to int if possible
+                self.meta['sysctls'][key] = int(val) if val.isdigit() else val
+            else:
+                raise MachineOptionError(
+                    "Invalid sysctl value (`%s`) on `%s`, missing `=` or value not in `net.` namespace."
+                    % (value, self.name)
+                )
+            return
+
+        if name == "env":
+            matches = re.search(r"^(?P<key>\w+)=(?P<value>\w+)$", value)
+
             # Check for valid kv-pair
-            if '=' in value:
-                (key, val) = value.split('=')
-                key = key.strip()
-                val = val.strip()
-                # Only allow `net.` namespace
-                if key.startswith('net.'):
-                    # Convert to int if possible
-                    self.meta['sysctls'][key] = int(val) if val.isdigit() else val
-                else:
-                    raise MachineOptionError(
-                        "Invalid sysctl value (`%s`) on `%s`, only `net.` namespace is allowed." % (value, self.name)
-                    )
+            if matches:
+                key = matches.group("key").strip()
+                val = matches.group("value").strip()
+
+                self.meta['envs'][key] = val
             else:
-                raise MachineOptionError("Invalid sysctl value (`%s`) on `%s`, missing `=`." % (value, self.name))
+                raise MachineOptionError("Invalid env value (`%s`) on `%s`." % (value, self.name))
             return
 
         if name == "port":
@@ -268,86 +280,8 @@ def pack_data(self) -> Optional[bytes]:
 
         return tar_data
 
-    def connect(self, terminal_name: str) -> None:
-        """Connect to the device with the specified terminal.
-
-        Args:
-            terminal_name (str): The name of the terminal to use for the connection.
-                The application must be correctly installed in the host system.
-                This option is only visible on Linux and macOS.
-                On Linux, options are /usr/bin/xterm, TMUX or an user-defined path.
-                On macOS, options are Terminal (default system terminal), iTerm or TMUX.
-
-        Returns:
-            None
-        """
-        logging.debug("Opening terminal for device %s.", self.name)
-
-        executable_path = utils.get_executable_path(sys.argv[0])
-
-        if not executable_path:
-            raise Exception("Unable to find Kathara.")
-
-        is_vmachine = "-v" if self.lab.path is None else ""
-        connect_command = "%s connect %s -l %s" % (executable_path, is_vmachine, self.name)
-        terminal = terminal_name if terminal_name else Setting.get_instance().terminal
-
-        logging.debug("Terminal will open in directory %s." % self.lab.path)
-
-        def unix_connect() -> None:
-            if terminal == "TMUX":
-                from ..trdparty.libtmux.tmux import TMUX
-
-                logging.debug("Attaching `%s` to TMUX session `%s` with command `%s`" % (self.name, self.lab.name,
-                                                                                         connect_command))
-
-                TMUX.get_instance().add_window(self.lab.name, self.name, connect_command, cwd=self.lab.path)
-            else:
-                logging.debug("Opening Linux terminal with command: %s." % connect_command)
-
-                # Command should be passed as an array
-                # https://stackoverflow.com/questions/9935151/popen-error-errno-2-no-such-file-or-directory/9935511
-                subprocess.Popen([terminal, "-e", connect_command],
-                                 cwd=self.lab.path,
-                                 start_new_session=True
-                                 )
-
-        def windows_connect() -> None:
-            complete_win_command = "& %s" % connect_command
-            logging.debug("Opening Windows terminal with command: %s." % complete_win_command)
-            subprocess.Popen(["powershell.exe",
-                              '-Command',
-                              complete_win_command
-                              ],
-                             creationflags=subprocess.CREATE_NEW_CONSOLE,
-                             cwd=self.lab.path
-                             )
-
-        def osx_connect() -> None:
-            cd_to_lab_path = "cd \"%s\" &&" % self.lab.path if self.lab.path is not None else ""
-            complete_osx_command = "%s clear && %s && exit" % (cd_to_lab_path, connect_command)
-
-            if terminal == "TMUX":
-                from ..trdparty.libtmux.tmux import TMUX
-
-                logging.debug("Attaching `%s` to TMUX session `%s` with command `%s`" % (self.name, self.lab.name,
-                                                                                         complete_osx_command))
-
-                TMUX.get_instance().add_window(self.lab.name, self.name, complete_osx_command, cwd=self.lab.path)
-            else:
-                import appscript
-                logging.debug("Opening OSX terminal with command: %s." % complete_osx_command)
-                terminal_app = appscript.app(terminal)
-                if terminal == 'iTerm':
-                    window = terminal_app.create_window_with_default_profile()
-                    window.current_session.write(text=complete_osx_command)
-                elif terminal == 'Terminal':
-                    terminal_app.do_script(complete_osx_command)
-
-        utils.exec_by_platform(unix_connect, windows_connect, osx_connect)
-
     def get_image(self) -> str:
-        """Get the image of the device, if defined in options or machine meta. If not, use default one.
+        """Get the image of the device, if defined in options or device meta. If not, use default one.
 
         Returns:
             str: The name of the device image.
@@ -383,10 +317,10 @@ def get_cpu(self, multiplier: int = 1) -> Optional[int]:
         """Get the CPU limit, multiplied by a specific multiplier.
 
         User should pass a float value ranging from 0 to max user CPUs.
-        Try to took it from options, or machine meta. Otherwise, return None.
+        Try to took it from options, or device meta. Otherwise, return None.
 
         Args:
-            multiplier (int):
+            multiplier (int): A numeric multiplier for the CPU limit value.
 
         Returns:
             Optional[int]: The CPU limit of the device.
@@ -486,5 +420,32 @@ def update_meta(self, args: Dict[str, Any]) -> None:
             for sysctl in args['sysctls']:
                 self.add_meta("sysctl", sysctl)
 
+        if 'envs' in args and args['envs'] is not None:
+            for envs in args['envs']:
+                self.add_meta("env", envs)
+
     def __repr__(self) -> str:
         return "Machine(%s, %s, %s)" % (self.name, self.interfaces, self.meta)
+
+    def __str__(self) -> str:
+        formatted_machine = f"Name: {self.name}"
+        formatted_machine += f"\nImage: {self.get_image()}"
+
+        if self.interfaces:
+            formatted_machine += "\nInterfaces: "
+            for (iface_num, link) in self.interfaces.items():
+                formatted_machine += f"\n\t- {iface_num}: {link.name}"
+
+        formatted_machine += f"\nBridged Connection: {self.meta['bridged']}"
+
+        if self.meta["sysctls"]:
+            formatted_machine += "\nSysctls:"
+            for (key, value) in self.meta["sysctls"].items():
+                formatted_machine += f"\n\t- {key} = {value}"
+
+        if self.meta["ports"]:
+            formatted_machine += "\nExposed Ports:"
+            for (key, value) in self.meta["ports"].items():
+                formatted_machine += f"\n\t- Host: {key} -> Guest: {value}"
+
+        return formatted_machine
diff --git a/src/Kathara/os/Networking.py b/src/Kathara/os/Networking.py
index 84c333be..c2578bd3 100644
--- a/src/Kathara/os/Networking.py
+++ b/src/Kathara/os/Networking.py
@@ -1,5 +1,6 @@
 import logging
 import os
+import shutil
 from typing import Optional
 
 
@@ -10,8 +11,7 @@ class Networking(object):
 
     @staticmethod
     def get_or_new_interface(full_interface_name: str, vlan_interface_name: str, vlan_id: Optional[int] = None) -> int:
-        """
-        Get or create an interface on the host. Return the link index.
+        """Get or create an interface on the host. Return the OS link index.
 
         Args:
             full_interface_name (str): The name of the network interface of the host.
@@ -74,8 +74,7 @@ def get_or_new_interface(full_interface_name: str, vlan_interface_name: str, vla
 
     @staticmethod
     def attach_interface_to_bridge(interface_index: int, bridge_name: str) -> None:
-        """
-        Attach an interface to the bridge.
+        """Attach an interface to the bridge.
 
         Args:
             interface_index (int): The interface index of the interface to attach.
@@ -103,8 +102,7 @@ def attach_interface_to_bridge(interface_index: int, bridge_name: str) -> None:
 
     @staticmethod
     def remove_interface(interface_name: str) -> None:
-        """
-        Remove an interface from the host.
+        """Remove an interface from the host.
 
         Args:
             interface_name (str): The name of the interface to remove.
@@ -137,10 +135,21 @@ def remove_interface(interface_name: str) -> None:
 
     @staticmethod
     def get_iptables_version() -> str:
-        """
-        Return the iptables version.
+        """Return the iptables version on a Linux host.
 
         Returns:
             str: The iptables version.
         """
-        return os.popen("/sbin/iptables --version").read().strip()
+        iptables_binary = shutil.which("iptables")
+        if iptables_binary is None:
+            if os.path.exists("/sbin/iptables"):
+                iptables_binary = "/sbin/iptables"
+            elif os.path.exists("/usr/sbin/iptables"):
+                iptables_binary = "/usr/sbin/iptables"
+
+        if iptables_binary is None:
+            raise Exception("Cannot find `iptables` in the host.")
+
+        logging.debug("Found iptables binary in `%s`." % iptables_binary)
+
+        return os.popen("%s --version" % iptables_binary).read().strip()
diff --git a/src/Kathara/parser/netkit/DepParser.py b/src/Kathara/parser/netkit/DepParser.py
index d815ddc1..9a4f1cc4 100644
--- a/src/Kathara/parser/netkit/DepParser.py
+++ b/src/Kathara/parser/netkit/DepParser.py
@@ -1,20 +1,19 @@
 import logging
-import mmap
 import os
 import re
 from typing import List, Optional
 
+import mmap
+
 from ...trdparty.depgen import depgen
 
 
 class DepParser(object):
-    """
-    Class responsible for parsing the lab.dep file.
-    """
+    """Class responsible for parsing the lab.dep file."""
+
     @staticmethod
     def parse(path: str) -> Optional[List[str]]:
-        """
-        Parse the lab.dep file and return a List of string containing the names of the device ordered considering the
+        """Parse the lab.dep file and return a List of string containing the names of the device ordered considering the
         dependencies.
 
         Args:
@@ -22,7 +21,7 @@ def parse(path: str) -> Optional[List[str]]:
 
         Returns:
             Optional[List[str]]: A List of string containing the names of the device ordered considering the
-            dependencies.
+                dependencies.
         """
         lab_dep_path = os.path.join(path, 'lab.dep')
 
@@ -46,22 +45,21 @@ def parse(path: str) -> Optional[List[str]]:
         line = dep_mem_file.readline().decode('utf-8')
         while line:
             line = line.strip()
-            if not line or line.startswith('#'):
-                continue
-            # E.g. MACHINE: MACHINE1 MACHINE2 MACHINE3
-            # Or MACHINE:MACHINE1 MACHINE2 MACHINE3
-            matches = re.search(r"^(?P<key>\w+):\s?(?P<deps>(\w+ ?)+)$",
-                                line
-                                )
-
-            if matches:
-                key = matches.group("key").strip()
-                deps = matches.group("deps").strip().split(" ")
-
-                # Dependencies are saved as dependencies[machine3] = [machine1, machine2]
-                dependencies[key] = deps
-            else:
-                raise Exception("[ERROR] In lab.dep - line %d: Syntax error." % line_number)
+            if line and not line.startswith('#'):
+                # E.g. MACHINE: MACHINE1 MACHINE2 MACHINE3
+                # Or MACHINE:MACHINE1 MACHINE2 MACHINE3
+                matches = re.search(r"^(?P<key>\w+):\s?(?P<deps>(\w+ ?)+)$",
+                                    line
+                                    )
+
+                if matches:
+                    key = matches.group("key").strip()
+                    deps = list(map(lambda x: x.strip(), matches.group("deps").split(" ")))
+
+                    # Dependencies are saved as dependencies[machine3] = [machine1, machine2]
+                    dependencies[key] = deps
+                else:
+                    raise Exception("[ERROR] In lab.dep - line %d: Syntax error." % line_number)
 
             line_number += 1
             line = dep_mem_file.readline().decode('utf-8')
diff --git a/src/Kathara/parser/netkit/ExtParser.py b/src/Kathara/parser/netkit/ExtParser.py
index 4d6bc49b..a880700f 100644
--- a/src/Kathara/parser/netkit/ExtParser.py
+++ b/src/Kathara/parser/netkit/ExtParser.py
@@ -8,13 +8,11 @@
 
 
 class ExtParser(object):
-    """
-    Class responsible for parsing lab.ext file.
-    """
+    """Class responsible for parsing lab.ext file."""
+
     @staticmethod
     def parse(path: str) -> Optional[Dict[str, List[ExternalLink]]]:
-        """
-        Parse the lab.ext and return a Dict. Keys are name of collision domain and values are List of ExternalLink
+        """Parse the lab.ext and return a Dict. Keys are name of collision domain and values are List of ExternalLink
         attached to that interface.
 
         Args:
@@ -22,7 +20,7 @@ def parse(path: str) -> Optional[Dict[str, List[ExternalLink]]]:
 
         Returns:
             Optional[Dict[str, List[ExternalLink]]]: Keys are name of collision domain and values are List of
-            ExternalLink attached to that interface.
+                ExternalLink attached to that interface.
         """
         lab_ext_path = os.path.join(path, 'lab.ext')
 
diff --git a/src/Kathara/parser/netkit/FolderParser.py b/src/Kathara/parser/netkit/FolderParser.py
index 64e8bcda..90c820bf 100644
--- a/src/Kathara/parser/netkit/FolderParser.py
+++ b/src/Kathara/parser/netkit/FolderParser.py
@@ -6,13 +6,11 @@
 
 
 class FolderParser(object):
-    """
-    Class responsible for parsing the folder in the network scenario directory.
-    """
+    """Class responsible for parsing the folder in the network scenario directory."""
+
     @staticmethod
     def parse(path: str) -> Lab:
-        """
-        Parse the network scenario folders and return a network scenario containing the corresponding devices.
+        """Parse the network scenario folders and return a network scenario containing the corresponding devices.
 
         Args:
             path (str): The path to the network scenario directory.
diff --git a/src/Kathara/parser/netkit/LabParser.py b/src/Kathara/parser/netkit/LabParser.py
index 21e694e9..6d2e61fb 100644
--- a/src/Kathara/parser/netkit/LabParser.py
+++ b/src/Kathara/parser/netkit/LabParser.py
@@ -7,13 +7,11 @@
 
 
 class LabParser(object):
-    """
-    Class responsible for parsing the lab.conf file.
-    """
+    """Class responsible for parsing the lab.conf file."""
+
     @staticmethod
     def parse(path: str) -> Lab:
-        """
-        Parse the lab.conf and return the corresponding Kathara network scenario.
+        """Parse the lab.conf and return the corresponding Kathara network scenario.
 
         Args:
             path (str): The path to lab.conf file.
diff --git a/src/Kathara/parser/netkit/OptionParser.py b/src/Kathara/parser/netkit/OptionParser.py
index ae18b8a2..f63a9ab4 100644
--- a/src/Kathara/parser/netkit/OptionParser.py
+++ b/src/Kathara/parser/netkit/OptionParser.py
@@ -2,13 +2,11 @@
 
 
 class OptionParser(object):
-    """
-    Class responsible for parsing the network scenario options from CLI.
-    """
+    """Class responsible for parsing the network scenario options from CLI."""
+
     @staticmethod
     def parse(options: List[str]) -> Dict[str, Any]:
-        """
-        Parse the CLI options and return a Dict containing their values.
+        """Parse the CLI options and return a Dict containing their values.
         
         Args:
             options (List[str]): A list of string taken from CLI.
diff --git a/src/Kathara/setting/Setting.py b/src/Kathara/setting/Setting.py
index e8f1672b..ed37838b 100644
--- a/src/Kathara/setting/Setting.py
+++ b/src/Kathara/setting/Setting.py
@@ -4,21 +4,21 @@
 import logging
 import os
 import time
-from typing import Any, Dict, Optional
+from typing import Any, Dict, Optional, List
 
 from .. import utils
 from .. import version
-from ..connectors.GitHubApi import GitHubApi
-from ..exceptions import HTTPConnectionError, SettingsError
+from ..webhooks.GitHubApi import GitHubApi
+from ..exceptions import HTTPConnectionError, SettingsError, SettingsNotFound
 from ..foundation.setting.SettingsAddon import SettingsAddon
 from ..foundation.setting.SettingsAddonFactory import SettingsAddonFactory
 
-AVAILABLE_DEBUG_LEVELS = ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG", "EXCEPTION"]
-AVAILABLE_MANAGERS = ["docker", "kubernetes"]
+AVAILABLE_DEBUG_LEVELS: List[str] = ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG", "EXCEPTION"]
+AVAILABLE_MANAGERS: List[str] = ["docker", "kubernetes"]
 
-ONE_WEEK = 604800
+ONE_WEEK: int = 604800
 
-DEFAULTS = {
+DEFAULTS: Dict[str, Any] = {
     "image": 'kathara/quagga',
     "manager_type": 'docker',
     "terminal": utils.exec_by_platform(lambda: '/usr/bin/xterm', lambda: '', lambda: 'Terminal'),
@@ -30,6 +30,8 @@
     "print_startup_log": True,
     "enable_ipv6": False
 }
+SETTINGS_FILENAME = "kathara.conf"
+DEFAULT_SETTINGS_PATH: str = os.path.join(utils.get_current_user_home(), ".config", SETTINGS_FILENAME)
 
 
 class Setting(object):
@@ -38,9 +40,6 @@ class Setting(object):
     __slots__ = ['image', 'manager_type', 'terminal', 'open_terminals', 'device_shell', 'net_prefix',
                  'device_prefix', 'debug_level', 'print_startup_log', 'enable_ipv6', 'last_checked', 'addons']
 
-    SETTING_FOLDER: str = None
-    SETTING_PATH: str = None
-
     __instance: Setting = None
 
     @staticmethod
@@ -48,7 +47,7 @@ def get_instance() -> Setting:
         """Return an instance of Setting.
 
         Returns:
-            Kathara.setting.Setting: An instanche of Setting.
+            Kathara.setting.Setting: An instance of Setting.
         """
         if Setting.__instance is None:
             Setting()
@@ -59,9 +58,6 @@ def __init__(self) -> None:
         if Setting.__instance is not None:
             raise Exception("This class is a singleton!")
         else:
-            Setting.SETTING_FOLDER = os.path.join(utils.get_current_user_home(), ".config")
-            Setting.SETTING_PATH = os.path.join(Setting.SETTING_FOLDER, "kathara.conf")
-
             # Load default settings to use
             for (name, value) in DEFAULTS.items():
                 setattr(self, name, value)
@@ -69,7 +65,7 @@ def __init__(self) -> None:
             self.addons: Optional[SettingsAddon] = None
             self.last_checked: float = time.time() - ONE_WEEK
 
-            self.load()
+            self.load_settings_addon()  # Load default addon
 
             Setting.__instance = self
 
@@ -83,30 +79,25 @@ def __setattr__(self, name: str, value: Any) -> None:
 
         setattr(self.addons, name, value)
 
-    def load(self) -> None:
-        """Load the Kathara settings from disk.
+    def load_from_disk(self, path: Optional[str] = None) -> None:
+        """Load settings from a specific path on disk.
+
+        Args:
+            path (Optional[str]): A path where the kathara.conf file is stored. If None, default path is used.
 
         Returns:
             None
-        """
-        if not os.path.exists(Setting.SETTING_PATH):  # If settings file don't exist, create with defaults
-            if not os.path.isdir(Setting.SETTING_FOLDER):  # Create .config folder if doesn't exists, create it
-                os.mkdir(Setting.SETTING_FOLDER)
 
-            self.load_settings_addon()  # Load default manager addons
-            self.save()
-
-            def unix_permissions():
-                (uid, gid) = utils.get_current_user_uid_gid()
-
-                os.chmod(Setting.SETTING_PATH, 0o600)
-                os.chown(Setting.SETTING_PATH, uid, gid)
+        Raises:
+            SettingsNotFound: Settings file not found in specified path.
+        """
+        settings_path = os.path.join(path, SETTINGS_FILENAME) if path is not None else DEFAULT_SETTINGS_PATH
 
-            # If Linux or Mac, set the right permissions and ownership to the settings file.
-            utils.exec_by_platform(unix_permissions, lambda: None, unix_permissions)
-        else:  # If file exists, read it and check values
+        if not os.path.exists(settings_path):  # Requested settings file doesn't exist, throw exception
+            raise SettingsNotFound()
+        else:  # Requested settings file exists, read it and check values
             settings = {}
-            with open(Setting.SETTING_PATH, 'r') as settings_file:
+            with open(settings_path, 'r') as settings_file:
                 try:
                     settings = json.load(settings_file)
                 except ValueError:
@@ -119,44 +110,76 @@ def unix_permissions():
             self.load_settings_addon()  # Manager may be changed with loaded settings, reload addon
             self.addons.load(settings)  # Load values into the addon object
 
-    @staticmethod
-    def wipe() -> None:
-        """Remove the all saved settings from disk.
+    def save_to_disk(self, path: Optional[str] = None) -> None:
+        """Saves settings to a kathara.conf file in the specified path on disk.
+
+        Args:
+            path (Optional[str]): A path where the kathara.conf file will be stored. If None, default path is used.
 
         Returns:
             None
         """
-        if os.path.exists(Setting.SETTING_PATH):
-            os.remove(Setting.SETTING_PATH)
+        settings_path = os.path.join(path, SETTINGS_FILENAME) if path is not None else DEFAULT_SETTINGS_PATH
+        settings_dirname = os.path.dirname(settings_path)
+
+        if not os.path.isdir(settings_dirname):  # Create folder if it doesn't exist
+            os.mkdir(settings_dirname)
+
+        to_save = self.addons.merge(self._to_dict())
+
+        with open(settings_path, 'w') as settings_file:
+            settings_file.write(json.dumps(to_save, indent=True))
 
-    def save(self) -> None:
-        """Saves settings to a config.json file in the Kathara path.
+        def unix_permissions():
+            (uid, gid) = utils.get_current_user_uid_gid()
+
+            os.chmod(settings_path, 0o600)
+            os.chown(settings_path, uid, gid)
+
+        # If Linux or Mac, set the right permissions and ownership to the settings file.
+        utils.exec_by_platform(unix_permissions, lambda: None, unix_permissions)
+
+    def load_from_dict(self, settings: Dict[str, Any]) -> None:
+        """Load settings from a dict.
+
+        Args:
+            settings (Dict[str, Any]): A dict containing the settings name as key and its value.
 
         Returns:
             None
         """
-        to_save = self.addons.merge(self._to_dict())
+        for name, value in settings.items():
+            if hasattr(self, name):
+                setattr(self, name, value)
 
-        with open(Setting.SETTING_PATH, 'w') as settings_file:
-            settings_file.write(json.dumps(to_save, indent=True))
+        self.load_settings_addon()  # Manager may be changed with loaded settings, reload addon
+        self.addons.load(settings)  # Load values into the addon object
+
+    @staticmethod
+    def wipe_from_disk() -> None:
+        """Remove settings from the default settings path on disk.
+
+        Returns:
+            None
+        """
+        if os.path.exists(DEFAULT_SETTINGS_PATH):
+            os.remove(DEFAULT_SETTINGS_PATH)
 
     def check(self) -> None:
-        """Chek if Kathara is correctly working.
+        """Check if Kathara is correctly working.
 
-        Check if the the selected manager and terminal are available. Check the presence of Kathara updates.
+        Check if the selected manager is available. Check the presence of Kathara updates.
         Check the correctness and validity of the net_prefix, device_prefix and debug level.
 
         Returns:
             None
         """
-        self.check_manager()
-
-        self.check_terminal()
+        self._check_manager()
 
         current_time = time.time()
         # After 1 week, check if a new Kathara version has been released.
         if current_time - self.last_checked > ONE_WEEK:
-            logging.debug(utils.format_headers("Checking Updates"))
+            logging.debug("Checking Updates...")
             checked = True
 
             try:
@@ -175,9 +198,7 @@ def check(self) -> None:
 
             if checked:
                 self.last_checked = current_time
-                self.save()
-
-            logging.debug(utils.format_headers())
+                self.save_to_disk()
 
         try:
             utils.re_search_fail(r"^[a-z]+_?[a-z_]+$", self.net_prefix)
@@ -192,7 +213,7 @@ def check(self) -> None:
         if self.debug_level not in AVAILABLE_DEBUG_LEVELS:
             raise SettingsError("Debug Level must be one of the following: %s." % (", ".join(AVAILABLE_DEBUG_LEVELS)))
 
-    def check_manager(self) -> None:
+    def _check_manager(self) -> None:
         """Check if the selected manager is available.
 
         Returns:
@@ -234,7 +255,6 @@ def check_terminal(self, terminal: str = None) -> bool:
 
         Returns:
             bool: True if the selected terminal is TMUX (that do not require path), else False.
-
         """
         terminal = self.terminal if not terminal else terminal
 
diff --git a/src/Kathara/setting/addon/DockerSettingsAddon.py b/src/Kathara/setting/addon/DockerSettingsAddon.py
index 1aed8272..2d3b2e64 100644
--- a/src/Kathara/setting/addon/DockerSettingsAddon.py
+++ b/src/Kathara/setting/addon/DockerSettingsAddon.py
@@ -5,6 +5,7 @@
 DEFAULTS = {
     "hosthome_mount": False,
     "shared_mount": True,
+    "image_update_policy": "Prompt",
     "shared_cd": False,
     "remote_url": None,
     "cert_path": None
@@ -12,11 +13,12 @@
 
 
 class DockerSettingsAddon(SettingsAddon):
-    __slots__ = ['hosthome_mount', 'shared_mount', 'shared_cd', 'remote_url', 'cert_path']
+    __slots__ = ['hosthome_mount', 'shared_mount', 'image_update_policy', 'shared_cd', 'remote_url', 'cert_path']
 
     def __init__(self) -> None:
         self.hosthome_mount: bool = False
         self.shared_mount: bool = True
+        self.image_update_policy: str = 'Prompt'
         self.shared_cd: bool = False
         self.remote_url: Optional[str] = None
         self.cert_path: Optional[str] = None
@@ -25,6 +27,7 @@ def _to_dict(self) -> Dict[str, Any]:
         return {
             'hosthome_mount': self.hosthome_mount,
             'shared_mount': self.shared_mount,
+            'image_update_policy': self.image_update_policy,
             'shared_cd': self.shared_cd,
             'remote_url': self.remote_url,
             'cert_path': self.cert_path
diff --git a/src/Kathara/utils.py b/src/Kathara/utils.py
index 64198da3..248952af 100644
--- a/src/Kathara/utils.py
+++ b/src/Kathara/utils.py
@@ -20,8 +20,6 @@
 from binaryornot.check import is_binary
 from slug import slug
 
-from .trdparty.consolemenu import PromptUtils, Screen
-
 # Platforms constants definition.
 MAC_OS: str = "darwin"
 WINDOWS: str = "win32"
@@ -31,9 +29,6 @@
 # List of ignored files
 EXCLUDED_FILES: List[str] = ['.DS_Store']
 
-# True if launched from CLI
-CLI_ENV: bool = False
-
 # Reserved names for devices
 RESERVED_MACHINE_NAMES: List[str] = ['shared', '_test']
 
@@ -107,16 +102,6 @@ def chunk_list(iterable: Union[List, Iterable], size: int) -> List[List]:
     return [iterable] if len(iterable) < size else list_chunks(iterable, size)
 
 
-def confirmation_prompt(prompt_string: str, callback_yes: Callable, callback_no: Callable) -> Any:
-    prompt_utils = PromptUtils(Screen())
-    answer = prompt_utils.prompt_for_bilateral_choice(prompt_string, 'y', 'n')
-
-    if answer == "n":
-        return callback_no()
-
-    return callback_yes()
-
-
 def get_pool_size() -> int:
     return cpu_count()
 
@@ -227,18 +212,6 @@ def passwd_info():
 
 
 # Formatting Functions
-def format_headers(message: str = "") -> str:
-    footer = "=============================="
-    half_message = int((len(message) / 2) + 1)
-    second_half_message = half_message
-
-    if len(message) % 2 == 0:
-        second_half_message -= 1
-
-    message = " " + message + " " if message != "" else "=="
-    return footer[half_message:] + message + footer[second_half_message:]
-
-
 def human_readable_bytes(size_bytes: int) -> str:
     if size_bytes == 0:
         return "0 B"
diff --git a/src/Kathara/version.py b/src/Kathara/version.py
index 167dab5b..407a0f0a 100644
--- a/src/Kathara/version.py
+++ b/src/Kathara/version.py
@@ -1,6 +1,6 @@
 from typing import Tuple
 
-CURRENT_VERSION = "3.3.0"
+CURRENT_VERSION = "3.5.0"
 
 
 def parse(version: str) -> Tuple:
diff --git a/src/Kathara/connectors/DockerHubApi.py b/src/Kathara/webhooks/DockerHubApi.py
similarity index 100%
rename from src/Kathara/connectors/DockerHubApi.py
rename to src/Kathara/webhooks/DockerHubApi.py
diff --git a/src/Kathara/connectors/GitHubApi.py b/src/Kathara/webhooks/GitHubApi.py
similarity index 100%
rename from src/Kathara/connectors/GitHubApi.py
rename to src/Kathara/webhooks/GitHubApi.py
diff --git a/src/Kathara/webhooks/__init__.py b/src/Kathara/webhooks/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/kathara.py b/src/kathara.py
index 6ee313e4..dec7a92d 100755
--- a/src/kathara.py
+++ b/src/kathara.py
@@ -10,7 +10,8 @@
 
 from Kathara import utils
 from Kathara.auth.PrivilegeHandler import PrivilegeHandler
-from Kathara.exceptions import SettingsError, DockerDaemonConnectionError, ClassNotFoundError
+from Kathara.cli.ui.event.register import register_cli_events
+from Kathara.exceptions import SettingsError, DockerDaemonConnectionError, ClassNotFoundError, SettingsNotFound
 from Kathara.foundation.cli.command.CommandFactory import CommandFactory
 from Kathara.setting.Setting import Setting
 from Kathara.strings import formatted_strings
@@ -64,8 +65,9 @@ def __init__(self) -> None:
             sys.exit(1)
 
         try:
-            # Check settings
-            Setting.get_instance().check()
+            # Check settings only if the user is not executing "settings" command.
+            if "settings" not in args.command:
+                Setting.get_instance().check()
         except (SettingsError, DockerDaemonConnectionError) as e:
             logging.critical(str(e))
             sys.exit(1)
@@ -100,11 +102,17 @@ def __init__(self) -> None:
 if __name__ == '__main__':
     multiprocessing.freeze_support()
 
-    utils.CLI_ENV = True
+    register_cli_events()
+
     utils.check_python_version()
 
     utils.exec_by_platform(PrivilegeHandler.get_instance().drop_privileges, lambda: None, lambda: None)
 
+    try:
+        Setting.get_instance().load_from_disk()
+    except SettingsNotFound:
+        Setting.get_instance().save_to_disk()
+
     try:
         debug_level = Setting.get_instance().debug_level
         debug_level = debug_level if debug_level != "EXCEPTION" else "DEBUG"
diff --git a/src/requirements.txt b/src/requirements.txt
index 8f88d9c4..2c707231 100644
--- a/src/requirements.txt
+++ b/src/requirements.txt
@@ -1,6 +1,6 @@
 binaryornot>=0.4.4;
 docker>=4.4.4;
-kubernetes>=20.13.0;
+kubernetes>=23.3.0;
 requests>=2.22.0;
 coloredlogs>=10.0;
 terminaltables>=3.1.0;


Test patch:
diff --git a/src/Kathara/foundation/test/Test.py b/src/Kathara/foundation/test/Test.py
index 748def65..ce9337bb 100644
--- a/src/Kathara/foundation/test/Test.py
+++ b/src/Kathara/foundation/test/Test.py
@@ -32,7 +32,7 @@ def check_signature(self, signature: Union[Dict, str], status: Union[Dict, str])
 
     @staticmethod
     def _get_machine_command_output(lab_hash, machine_name, command):
-        exec_output = Kathara.get_instance().exec(lab_hash, machine_name, command)
+        exec_output = Kathara.get_instance().exec(machine_name, command, lab_hash=lab_hash)
 
         result = {
             'stdout': '',
diff --git a/src/Kathara/test/BuiltinTest.py b/src/Kathara/test/BuiltinTest.py
index dec69391..c3feb0ac 100644
--- a/src/Kathara/test/BuiltinTest.py
+++ b/src/Kathara/test/BuiltinTest.py
@@ -5,7 +5,6 @@
 
 from deepdiff import DeepDiff
 
-from .. import utils
 from ..exceptions import MachineSignatureNotFoundError
 from ..foundation.test.Test import Test
 from ..model.Lab import Lab
@@ -52,9 +51,8 @@ def test(self) -> bool:
 
             machine_diff_path = "%s/%s.diff" % (self.results_path, machine.name)
             with open(machine_diff_path, 'w') as machine_diff_file:
-                machine_diff_file.write(utils.format_headers("Builtin Test Result") + '\n')
+                machine_diff_file.write("Builtin Test Result:\n")
                 machine_diff_file.write(json.dumps(diff, indent=4) + "\n" if diff else "OK\n")
-                machine_diff_file.write(utils.format_headers() + "\n\n")
 
         return test_passed
 
diff --git a/src/Kathara/test/UserTest.py b/src/Kathara/test/UserTest.py
index b1b55a48..379d7936 100644
--- a/src/Kathara/test/UserTest.py
+++ b/src/Kathara/test/UserTest.py
@@ -5,7 +5,6 @@
 
 from deepdiff import DeepDiff
 
-from .. import utils
 from ..exceptions import MachineSignatureNotFoundError
 from ..foundation.test.Test import Test
 from ..manager.Kathara import Kathara
@@ -62,9 +61,8 @@ def test(self) -> bool:
 
                 machine_diff_path = "%s/%s.diff" % (self.results_path, machine.name)
                 with open(machine_diff_path, 'a') as machine_diff_file:
-                    machine_diff_file.write(utils.format_headers("User Test Result") + '\n')
+                    machine_diff_file.write("User Test Result:\n")
                     machine_diff_file.write("\n".join(diff) + "\n" if diff else "OK\n")
-                    machine_diff_file.write(utils.format_headers() + "\n\n")
 
         return test_passed
 
diff --git a/tests/manager/docker/docker_image_test.py b/tests/manager/docker/docker_image_test.py
index e616411a..ea70a492 100644
--- a/tests/manager/docker/docker_image_test.py
+++ b/tests/manager/docker/docker_image_test.py
@@ -6,9 +6,18 @@
 
 sys.path.insert(0, './')
 
+from src.Kathara.event.EventDispatcher import EventDispatcher
 from src.Kathara.manager.docker.DockerImage import DockerImage
 
 
+class MockPullEvent(object):
+    def run(self, docker_image, image_name):
+        docker_image.pull(image_name)
+
+
+EventDispatcher.get_instance().register("docker_image_update_found", MockPullEvent())
+
+
 @pytest.fixture()
 @mock.patch("docker.DockerClient")
 def docker_image(mock_client):
diff --git a/tests/manager/docker/docker_link_test.py b/tests/manager/docker/docker_link_test.py
index fed357c7..b8ebd24e 100644
--- a/tests/manager/docker/docker_link_test.py
+++ b/tests/manager/docker/docker_link_test.py
@@ -30,12 +30,6 @@ def docker_network(mock_network):
     return mock_network
 
 
-@pytest.fixture()
-@mock.patch("progressbar.ProgressBar")
-def progress_bar(mock_progress_bar):
-    return mock_progress_bar
-
-
 @mock.patch("src.Kathara.setting.Setting.Setting.get_instance")
 @mock.patch("src.Kathara.utils.get_current_user_name")
 def test_get_network_name(mock_get_current_user_name, mock_setting_get_instance):
@@ -125,26 +119,26 @@ def test_create_shared_cd(mock_get_current_user_name, mock_setting_get_instance,
 
 
 @mock.patch("src.Kathara.manager.docker.DockerLink.DockerLink.create")
-def test_deploy_link(mock_create, docker_link, progress_bar, default_link):
-    docker_link._deploy_link(progress_bar, ("", default_link))
+def test_deploy_link(mock_create, docker_link, default_link):
+    docker_link._deploy_link(("", default_link))
     mock_create.called_once_with(default_link)
 
 
 @mock.patch("src.Kathara.manager.docker.DockerLink.DockerLink._deploy_link")
-def test_deploy_links(mock_deploy_link, docker_link, progress_bar):
+def test_deploy_links(mock_deploy_link, docker_link):
     lab = Lab("Default scenario")
     link_a = lab.get_or_new_link("A")
     link_b = lab.get_or_new_link("B")
     link_c = lab.get_or_new_link("C")
     docker_link.deploy_links(lab)
-    mock_deploy_link.assert_any_call(None, ("A", link_a))
-    mock_deploy_link.assert_any_call(None, ("B", link_b))
-    mock_deploy_link.assert_any_call(None, ("C", link_c))
+    mock_deploy_link.assert_any_call(("A", link_a))
+    mock_deploy_link.assert_any_call(("B", link_b))
+    mock_deploy_link.assert_any_call(("C", link_c))
     assert mock_deploy_link.call_count == 3
 
 
 @mock.patch("src.Kathara.manager.docker.DockerLink.DockerLink._deploy_link")
-def test_deploy_links_no_link(mock_deploy_link, docker_link, progress_bar):
+def test_deploy_links_no_link(mock_deploy_link, docker_link):
     lab = Lab("Default scenario")
     docker_link.deploy_links(lab)
     assert not mock_deploy_link.called
@@ -157,8 +151,8 @@ def test_delete_link(docker_network):
 
 
 @mock.patch("src.Kathara.manager.docker.DockerLink.DockerLink._undeploy_link")
-def test_undeploy_link(mock_undeploy_link, docker_link, docker_network, progress_bar):
-    docker_link._undeploy_link(progress_bar, docker_network)
+def test_undeploy_link(mock_undeploy_link, docker_link, docker_network):
+    docker_link._undeploy_link(docker_network)
     mock_undeploy_link.called_once_with(docker_network)
 
 
diff --git a/tests/manager/docker/docker_machine_test.py b/tests/manager/docker/docker_machine_test.py
index b4eb9fcf..eb680142 100644
--- a/tests/manager/docker/docker_machine_test.py
+++ b/tests/manager/docker/docker_machine_test.py
@@ -83,6 +83,7 @@ def test_create(mock_get_current_user_name, mock_setting_get_instance, mock_copy
                  'net.ipv4.ip_forward': 1,
                  'net.ipv4.icmp_ratelimit': 0
                  },
+        environment={},
         mem_limit='64m',
         nano_cpus=2000000000,
         ports=None,
@@ -136,6 +137,7 @@ def test_create_ipv6(mock_get_current_user_name, mock_setting_get_instance, mock
                  'net.ipv6.conf.default.disable_ipv6': 0,
                  'net.ipv6.conf.all.disable_ipv6': 0
                  },
+        environment={},
         mem_limit='64m',
         nano_cpus=2000000000,
         ports=None,
@@ -188,6 +190,7 @@ def test_create_privileged(mock_get_current_user_name, mock_setting_get_instance
                  'net.ipv6.icmp.ratelimit': 0,
                  'net.ipv6.conf.default.disable_ipv6': 0,
                  'net.ipv6.conf.all.disable_ipv6': 0},
+        environment={},
         mem_limit='64m',
         nano_cpus=2000000000,
         ports=None,
@@ -201,20 +204,17 @@ def test_create_privileged(mock_get_current_user_name, mock_setting_get_instance
     assert not mock_copy_files.called
 
 
-@mock.patch("src.Kathara.model.Machine.Machine.connect")
-def test_start(mock_machine_connect, docker_machine, default_device, default_link, default_link_b):
+def test_start(docker_machine, default_device, default_link, default_link_b):
     # add two interfaces because interace 0 is excluded
     default_device.add_interface(default_link)
     default_device.add_interface(default_link_b)
     default_device.add_meta("num_terms", 3)
     default_device.api_object.start.return_value = True
     default_device.api_object.exec_run.return_value = True
-    mock_machine_connect.return_value = True
     docker_machine.start(default_device)
     default_device.api_object.start.assert_called_once()
     default_device.api_object.exec_run.assert_called_once()
     default_link_b.api_object.connect.assert_called_once()
-    assert mock_machine_connect.call_count == 3
 
 
 @mock.patch("src.Kathara.manager.docker.DockerMachine.DockerMachine.start")
@@ -223,7 +223,7 @@ def test_deploy_and_start_machine(mock_create, mock_start, docker_machine, defau
     machine_item = ("", default_device)
     mock_create.return_value = True
     mock_start.return_value = True
-    docker_machine._deploy_and_start_machine(None, machine_item)
+    docker_machine._deploy_and_start_machine(machine_item)
     mock_create.assert_called_once()
     mock_start.assert_called_once()
 
@@ -242,7 +242,7 @@ def test_deploy_machines(mock_deploy_and_start, docker_machine):
 
 @mock.patch("src.Kathara.manager.docker.DockerMachine.DockerMachine.get_machines_api_objects_by_filters")
 def test_update(mock_get_machines_api_objects_by_filters, docker_machine, default_device, default_link, default_link_b):
-    mock_get_machines_api_objects_by_filters.return_value = [default_device.api_object]
+    mock_get_machines_api_objects_by_filters.return_value = default_device.api_object
     default_device.api_object.connect.return_value = None
     default_device.api_object.attrs["NetworkSettings"] = {}
     default_device.api_object.attrs["NetworkSettings"]["Networks"] = ["A"]
@@ -263,7 +263,7 @@ def test_undeploy_one_device(mock_get_machines_api_objects_by_filters, mock_unde
     mock_undeploy_machine.return_value = None
     docker_machine.undeploy("lab_hash", selected_machines={default_device.name})
     mock_get_machines_api_objects_by_filters.assert_called_once()
-    mock_undeploy_machine.assert_called_once_with(None, default_device.api_object)
+    mock_undeploy_machine.assert_called_once_with(default_device.api_object)
 
 
 @mock.patch("src.Kathara.manager.docker.DockerMachine.DockerMachine._undeploy_machine")
@@ -323,13 +323,13 @@ def test_wipe_three_devices(mock_get_machines_api_objects_by_filters, mock_undep
 @mock.patch("src.Kathara.manager.docker.DockerMachine.DockerMachine._delete_machine")
 def test_undeploy_machine(mock_delete_machine, docker_machine, default_device):
     mock_delete_machine.return_value = None
-    docker_machine._undeploy_machine(None, default_device.api_object)
+    docker_machine._undeploy_machine(default_device.api_object)
     mock_delete_machine.assert_called_once()
 
 
-@mock.patch("src.Kathara.manager.docker.DockerMachine.DockerMachine.get_machine_api_object")
-def test_exec(mock_get_machine_api_object, docker_machine, default_device):
-    mock_get_machine_api_object.return_value = default_device.api_object
+@mock.patch("src.Kathara.manager.docker.DockerMachine.DockerMachine.get_machines_api_objects_by_filters")
+def test_exec(mock_get_machines_api_objects_by_filters, docker_machine, default_device):
+    mock_get_machines_api_objects_by_filters.return_value = [default_device.api_object]
     exec_run_mock = Mock()
     exec_run_mock.configure_mock(**{
         'output': (None, None),
@@ -349,7 +349,7 @@ def test_exec(mock_get_machine_api_object, docker_machine, default_device):
 
 
 def test_get_machines_api_objects_by_filters(docker_machine):
-    docker_machine.client.containers.list.return_value = None
+    docker_machine.client.containers.list.return_value = ["test_device"]
     docker_machine.get_machines_api_objects_by_filters("lab_hash_value", "test_device", "user_name_value")
     filters = {"label": ["app=kathara", "user=user_name_value", "lab_hash=lab_hash_value", "name=test_device"]}
     docker_machine.client.containers.list.assert_called_once_with(all=True, filters=filters)
@@ -370,7 +370,7 @@ def test_get_machines_api_objects_by_filters_lab_hash_filter(docker_machine):
 
 
 def test_get_machines_api_objects_by_filters_lab_device_name_filter(docker_machine):
-    docker_machine.client.containers.list.return_value = None
+    docker_machine.client.containers.list.return_value = ["test_device"]
     docker_machine.get_machines_api_objects_by_filters(None, "test_device", None)
     filters = {"label": ["app=kathara", "name=test_device"]}
     docker_machine.client.containers.list.assert_called_once_with(all=True, filters=filters)
@@ -383,23 +383,6 @@ def test_get_machines_api_objects_by_filters_user_filter(docker_machine):
     docker_machine.client.containers.list.assert_called_once_with(all=True, filters=filters)
 
 
-@mock.patch("src.Kathara.manager.docker.DockerMachine.DockerMachine.get_machines_api_objects_by_filters")
-def test_get_machine_api_object(mock_get_machines_api_objects_by_filters, docker_machine, default_device):
-    mock_get_machines_api_objects_by_filters.return_value = [default_device.api_object]
-    docker_machine.get_machine_api_object("lab_hash", "test_device")
-    mock_get_machines_api_objects_by_filters.assert_called_once_with(lab_hash="lab_hash",
-                                                                     machine_name="test_device",
-                                                                     user=None
-                                                                     )
-
-
-@mock.patch("src.Kathara.manager.docker.DockerMachine.DockerMachine.get_machines_api_objects_by_filters")
-def test_get_machine_api_object_exception(mock_get_machines_api_objects_by_filters, docker_machine, default_device):
-    mock_get_machines_api_objects_by_filters.return_value = []
-    with pytest.raises(Exception):
-        docker_machine.get_machine_api_object("lab_hash", "test_device")
-
-
 @mock.patch("src.Kathara.setting.Setting.Setting.get_instance")
 @mock.patch("src.Kathara.utils.get_current_user_name")
 def test_get_container_name_lab_hash(mock_get_current_user_name, mock_setting_get_instance):
@@ -415,33 +398,15 @@ def test_get_container_name_lab_hash(mock_get_current_user_name, mock_setting_ge
     assert "dev_prefix_kathara-user_test_device_lab_hash" == DockerMachine.get_container_name("test_device", "lab_hash")
 
 
-@mock.patch("src.Kathara.manager.docker.DockerMachine.DockerMachine._get_stats_by_machine")
-@mock.patch("src.Kathara.manager.docker.DockerMachine.DockerMachine.get_machines_api_objects_by_filters")
-def test_get_machine_info(mock_get_machines_api_objects_by_filters, mock_get_stats_by_machine,
-                          docker_machine, default_device):
-    mock_get_machines_api_objects_by_filters.return_value = [default_device.api_object]
-    mock_get_stats_by_machine.return_value = None
-    default_device.api_object.stats.return_value = {}
-    docker_machine.get_machine_info("test_device", "lab_hash", "user_value")
-    mock_get_machines_api_objects_by_filters.assert_called_once_with(lab_hash="lab_hash", machine_name="test_device",
-                                                                     user="user_value")
-    default_device.api_object.stats.assert_called_once()
-    mock_get_stats_by_machine.assert_called_once_with(default_device.api_object, {})
-
-
-@mock.patch("src.Kathara.manager.docker.DockerMachine.DockerMachine._get_stats_by_machine")
 @mock.patch("src.Kathara.manager.docker.DockerMachine.DockerMachine.get_machines_api_objects_by_filters")
-def test_get_machines_info(mock_get_machines_api_objects_by_filters, mock_get_stats_by_machine,
-                           docker_machine, default_device):
+def test_get_machines_info(mock_get_machines_api_objects_by_filters, docker_machine, default_device):
     default_device.api_object.name = "test_device"
     mock_get_machines_api_objects_by_filters.return_value = [default_device.api_object]
-    default_device.api_object.stats.return_value = map(lambda x: x, ['test-device-1'])
-    mock_get_stats_by_machine.return_value = {}
-    next(docker_machine.get_machines_info("lab_hash"))
+    default_device.api_object.stats.return_value = iter([{'pids_stats': {}, 'cpu_stats': {}, 'memory_stats': {}}])
+    next(docker_machine.get_machines_stats(lab_hash="lab_hash"))
 
     mock_get_machines_api_objects_by_filters.assert_called_once()
     default_device.api_object.stats.assert_called_once()
-    mock_get_stats_by_machine.assert_called_once()
 
 
 @mock.patch("src.Kathara.setting.Setting.Setting.get_instance")
diff --git a/tests/manager/kubernetes/kubernetes_link_test.py b/tests/manager/kubernetes/kubernetes_link_test.py
index 0088eb96..31826934 100644
--- a/tests/manager/kubernetes/kubernetes_link_test.py
+++ b/tests/manager/kubernetes/kubernetes_link_test.py
@@ -54,10 +54,11 @@ def default_link():
 @pytest.fixture()
 @mock.patch("kubernetes.client.api.custom_objects_api.CustomObjectsApi")
 @mock.patch("kubernetes.client.Configuration")
-def kubernetes_link(config_mock, _):
+@mock.patch("src.Kathara.manager.kubernetes.KubernetesNamespace")
+def kubernetes_link(kubernetes_namespace_mock, config_mock, _):
     config_mock.get_default_copy.return_value = FakeConfig()
 
-    return KubernetesLink()
+    return KubernetesLink(kubernetes_namespace_mock)
 
 
 @pytest.fixture()
@@ -85,12 +86,6 @@ def kubernetes_network():
     }
 
 
-@pytest.fixture()
-@mock.patch("progressbar.ProgressBar")
-def progress_bar(mock_progress_bar):
-    return mock_progress_bar
-
-
 @mock.patch("src.Kathara.setting.Setting.Setting.get_instance")
 def test_get_network_name(mock_setting_get_instance, kubernetes_link):
     setting_mock = Mock()
@@ -249,20 +244,20 @@ def test_create(mock_setting_get_instance, kubernetes_link, default_link):
 
 
 @mock.patch("src.Kathara.manager.kubernetes.KubernetesLink.KubernetesLink.create")
-def test_deploy_link(mock_create, kubernetes_link, progress_bar, default_link):
-    kubernetes_link._deploy_link(progress_bar, {}, ("", default_link))
+def test_deploy_link(mock_create, kubernetes_link, default_link):
+    kubernetes_link._deploy_link({}, ("", default_link))
     mock_create.called_once_with(default_link, EXPECTED_NETWORK_ID)
 
 
 @mock.patch("src.Kathara.manager.kubernetes.KubernetesLink.KubernetesLink.create")
-def test_deploy_link_collision(mock_create, kubernetes_link, progress_bar, default_link):
-    kubernetes_link._deploy_link(progress_bar, NETWORK_IDS_SINGLE, ("", default_link))
+def test_deploy_link_collision(mock_create, kubernetes_link, default_link):
+    kubernetes_link._deploy_link(NETWORK_IDS_SINGLE, ("", default_link))
     mock_create.called_once_with(default_link, EXPECTED_NETWORK_ID + 1)
 
 
 @mock.patch("src.Kathara.manager.kubernetes.KubernetesLink.KubernetesLink._deploy_link")
 @mock.patch("multiprocessing.managers.SyncManager", new=FakeManager)
-def test_deploy_links(mock_deploy_link, kubernetes_link, progress_bar):
+def test_deploy_links(mock_deploy_link, kubernetes_link):
     lab = Lab("Default scenario")
     link_a = lab.get_or_new_link("A")
     link_b = lab.get_or_new_link("B")
@@ -270,14 +265,14 @@ def test_deploy_links(mock_deploy_link, kubernetes_link, progress_bar):
 
     kubernetes_link.deploy_links(lab)
 
-    mock_deploy_link.assert_any_call(None, {}, ("A", link_a))
-    mock_deploy_link.assert_any_call(None, {}, ("B", link_b))
-    mock_deploy_link.assert_any_call(None, {}, ("C", link_c))
+    mock_deploy_link.assert_any_call({}, ("A", link_a))
+    mock_deploy_link.assert_any_call({}, ("B", link_b))
+    mock_deploy_link.assert_any_call({}, ("C", link_c))
     assert mock_deploy_link.call_count == 3
 
 
 @mock.patch("src.Kathara.manager.kubernetes.KubernetesLink.KubernetesLink._deploy_link")
-def test_deploy_links_no_link(mock_deploy_link, kubernetes_link, progress_bar):
+def test_deploy_links_no_link(mock_deploy_link, kubernetes_link):
     lab = Lab("Default scenario")
 
     kubernetes_link.deploy_links(lab)
@@ -286,7 +281,7 @@ def test_deploy_links_no_link(mock_deploy_link, kubernetes_link, progress_bar):
 
 
 @mock.patch("src.Kathara.setting.Setting.Setting.get_instance")
-def test_delete_link(mock_setting_get_instance, kubernetes_network, progress_bar, kubernetes_link, default_link):
+def test_delete_link(mock_setting_get_instance, kubernetes_network, kubernetes_link, default_link):
     setting_mock = Mock()
     setting_mock.configure_mock(**{
         'manager': 'kubernetes',
@@ -298,7 +293,7 @@ def test_delete_link(mock_setting_get_instance, kubernetes_network, progress_bar
         "items": []
     }
 
-    kubernetes_link._undeploy_link(progress_bar, kubernetes_network)
+    kubernetes_link._undeploy_link(kubernetes_network)
 
     kubernetes_link.client.delete_namespaced_custom_object.assert_called_once_with(
         body=client.V1DeleteOptions(grace_period_seconds=0),
diff --git a/tests/manager/kubernetes/kubernetes_machine_test.py b/tests/manager/kubernetes/kubernetes_machine_test.py
index a8534605..e951c059 100644
--- a/tests/manager/kubernetes/kubernetes_machine_test.py
+++ b/tests/manager/kubernetes/kubernetes_machine_test.py
@@ -51,7 +51,7 @@ def kubernetes_device_definition():
     startup_commands_string = "; ".join(STARTUP_COMMANDS) \
         .format(machine_name="test_device", sysctl_commands=sysctl_commands, machine_commands="ls")
 
-    post_start = client.V1Handler(
+    post_start = client.V1LifecycleHandler(
         _exec=client.V1ExecAction(
             command=["/bin/bash", "-c", startup_commands_string]
         )
@@ -157,7 +157,7 @@ def test_build_definition_no_config(mock_setting_get_instance, default_device, k
     startup_commands_string = "; ".join(STARTUP_COMMANDS) \
         .format(machine_name="test_device", sysctl_commands="", machine_commands="ls")
 
-    post_start = client.V1Handler(
+    post_start = client.V1LifecycleHandler(
         _exec=client.V1ExecAction(
             command=["/bin/bash", "-c", startup_commands_string]
         )
@@ -229,7 +229,7 @@ def test_build_definition(mock_setting_get_instance, config_map_mock, default_de
     startup_commands_string = "; ".join(STARTUP_COMMANDS) \
         .format(machine_name="test_device", sysctl_commands="", machine_commands="ls")
 
-    post_start = client.V1Handler(
+    post_start = client.V1LifecycleHandler(
         _exec=client.V1ExecAction(
             command=["/bin/bash", "-c", startup_commands_string]
         )
@@ -331,7 +331,7 @@ def test_create_ipv6(mock_setting_get_instance, kubernetes_machine, default_devi
     startup_commands_string = "; ".join(STARTUP_COMMANDS) \
         .format(machine_name="test_device", sysctl_commands=sysctl_commands, machine_commands="ls")
 
-    post_start = client.V1Handler(
+    post_start = client.V1LifecycleHandler(
         _exec=client.V1ExecAction(
             command=["/bin/bash", "-c", startup_commands_string]
         )
@@ -388,7 +388,7 @@ def test_deploy_machine(mock_create, kubernetes_machine, default_device):
 
     mock_create.return_value = True
 
-    kubernetes_machine._deploy_machine(None, machine_item)
+    kubernetes_machine._deploy_machine(machine_item)
 
     mock_create.assert_called_once()
 
@@ -418,7 +418,7 @@ def test_undeploy_one_device(mock_get_machines_api_objects_by_filters, mock_unde
     kubernetes_machine.undeploy("lab_hash", selected_machines={default_device.name})
 
     mock_get_machines_api_objects_by_filters.assert_called_once()
-    mock_undeploy_machine.assert_called_once_with(None, default_device.api_object)
+    mock_undeploy_machine.assert_called_once_with(default_device.api_object)
 
 
 @mock.patch("src.Kathara.manager.kubernetes.KubernetesMachine.KubernetesMachine._undeploy_machine")
@@ -489,6 +489,6 @@ def test_wipe_three_devices(mock_get_machines_api_objects_by_filters, mock_undep
 def test_undeploy_machine(mock_delete_machine, kubernetes_machine, default_device):
     mock_delete_machine.return_value = None
 
-    kubernetes_machine._undeploy_machine(None, default_device.api_object)
+    kubernetes_machine._undeploy_machine(default_device.api_object)
 
     mock_delete_machine.assert_called_once()
diff --git a/tests/model/machine_test.py b/tests/model/machine_test.py
index dfa73dc0..de985eac 100644
--- a/tests/model/machine_test.py
+++ b/tests/model/machine_test.py
@@ -28,6 +28,7 @@ def test_default_device_parameters(default_device: Machine):
     assert len(default_device.interfaces) == 0
     assert default_device.meta == {
         'sysctls': {},
+        'envs': {},
         'bridged': False,
         'ports': {}
     }
@@ -73,6 +74,21 @@ def test_add_meta_sysctl_not_format_exception(default_device: Machine):
         default_device.add_meta("sysctl", "kernel.shm_rmid_forced")
 
 
+def test_add_meta_env(default_device: Machine):
+    default_device.add_meta("env", "MY_ENV_VAR=test")
+    assert default_device.meta['envs']['MY_ENV_VAR'] == "test"
+
+
+def test_add_meta_env_number(default_device: Machine):
+    default_device.add_meta("env", "MY_ENV_VAR=1")
+    assert default_device.meta['envs']['MY_ENV_VAR'] == "1"
+
+
+def test_add_meta_env_not_format_exception(default_device: Machine):
+    with pytest.raises(MachineOptionError):
+        default_device.add_meta("env", "MY_ENV_VAR")
+
+
 def test_add_meta_port_only_guest(default_device: Machine):
     default_device.add_meta("port", "8080")
     assert default_device.meta['ports'][(3000, "tcp")] == 8080
diff --git a/tests/parser/dep_parser_test.py b/tests/parser/dep_parser_test.py
index f58f91f9..5db5439a 100644
--- a/tests/parser/dep_parser_test.py
+++ b/tests/parser/dep_parser_test.py
@@ -1,6 +1,5 @@
-import sys
-
 import pytest
+import sys
 
 sys.path.insert(0, './')
 
@@ -15,3 +14,13 @@ def test_three_devices_dependencies():
 def test_devices_loop():
     with pytest.raises(Exception):
         DepParser.parse("tests/parser/labdep/devices_loop")
+
+
+def test_with_comment():
+    dependencies = DepParser.parse("tests/parser/labdep/comments_empty_lines")
+    assert dependencies == ['r2', 'pc3', 'pc2', 'pc1']
+
+
+def test_syntax_error():
+    with pytest.raises(Exception):
+        DepParser.parse("tests/parser/labdep/syntax_error")
diff --git a/tests/parser/labdep/comments_empty_lines/lab.dep b/tests/parser/labdep/comments_empty_lines/lab.dep
new file mode 100644
index 00000000..61b7366d
--- /dev/null
+++ b/tests/parser/labdep/comments_empty_lines/lab.dep
@@ -0,0 +1,7 @@
+# This is a comment
+pc1: r1
+pc2: r2 pc3
+
+pc3: r2
+pc2: pc3
+pc1: pc2
\ No newline at end of file
diff --git a/tests/parser/labdep/syntax_error/lab.dep b/tests/parser/labdep/syntax_error/lab.dep
new file mode 100644
index 00000000..a16625e3
--- /dev/null
+++ b/tests/parser/labdep/syntax_error/lab.dep
@@ -0,0 +1,2 @@
+pc1:  pc2  pc3
+pc3:  pc2
\ No newline at end of file