diff --git a/.gitignore b/.gitignore
index f897a33..be0a29d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,9 @@
.venv/
tmp/
__pycache__/
-*.pyc
\ No newline at end of file
+*.pyc
+
+# Build artifacts
+*.egg-info/
+build/
+dist/
\ No newline at end of file
diff --git a/README.md b/README.md
index 936553b..1a1c6fc 100644
--- a/README.md
+++ b/README.md
@@ -11,30 +11,23 @@
## Installation
-To install `NSE2`, you need to set up a Python virtual environment, install the required dependencies, and make the `nse2` tools accessible in your PATH.
+Create a virtual environment and install the project:
-First, create a clean virtual environment:
-```
-$ python3 -m venv .venv
+```sh
+python3 -m venv .venv
+source .venv/bin/activate
+pip install .
```
-The recommended approach to activate the environment and set up your PATH is to source `load_env.sh`. Do this before installing the dependencies:
-```
-$ source load_env.sh
-Activating virtual environment
-Adding tools/bin and tools/helpers to PATH
-$ pip3 install -r requirements.txt
-```
+When developing, use an editable install so changes take effect immediately:
-**Alternative approach using symlinks:**
-If you prefer not to source `load_env.sh` every time you start a new shell session, you can manually activate the environment, install the dependencies, and use the `install_symlinks.sh` script. This script installs all tools into your `.venv/bin` directory (or a user-supplied path), so they are automatically available whenever the virtual environment is active:
-```
-$ source .venv/bin/activate
-$ pip3 install -r requirements.txt
-$ ./install_symlinks.sh
+```sh
+pip install -e ".[dev]"
```
-Afterwards, all `nse2` tools are available to run network simulations.
+To pin dependencies to the exact versions tested in this repo (recommended), add `-c constraints.txt` to either command.
+
+Once installed, all `nse2_*` tools and helpers are available on PATH whenever the venv is active.
## Documentation
diff --git a/constraints.txt b/constraints.txt
new file mode 100644
index 0000000..407e0f2
--- /dev/null
+++ b/constraints.txt
@@ -0,0 +1,68 @@
+aiofiles==25.1.0
+aiohappyeyeballs==2.7.1
+aiohttp==3.14.3
+aiosignal==1.4.0
+annotated-doc==0.0.4
+annotated-types==0.7.0
+anyio==4.14.2
+attrs==26.1.0
+basedpyright==1.39.9
+bidict==0.23.1
+certifi==2026.7.22
+click==8.4.2
+contourpy==1.3.3
+cycler==0.12.1
+docutils==0.23
+fastapi==0.139.2
+fonttools==4.63.0
+frozenlist==1.8.0
+h11==0.16.0
+httpcore==1.0.9
+httptools==0.8.0
+httpx==0.28.1
+idna==3.18
+ifaddr==0.2.0
+iniconfig==2.3.0
+itsdangerous==2.2.0
+Jinja2==3.1.6
+kiwisolver==1.5.0
+lxml==6.1.1
+lxml_html_clean==0.4.5
+markdown2==2.5.5
+MarkupSafe==3.0.3
+matplotlib==3.11.1
+multidict==6.7.1
+networkx==3.6.1
+nicegui==3.15.0
+nodejs-wheel-binaries==24.16.0
+numpy==2.5.1
+orjson==3.11.9
+packaging==26.2
+pillow==12.3.0
+pluggy==1.6.0
+propcache==0.5.2
+pyaml==26.7.0
+pydantic==2.13.4
+pydantic_core==2.46.4
+Pygments==2.20.0
+pyparsing==3.3.2
+pytest==9.1.1
+python-dateutil==2.9.0.post0
+python-dotenv==1.2.2
+python-engineio==4.13.3
+python-multipart==0.0.32
+python-socketio==5.16.3
+PyYAML==6.0.3
+simple-websocket==1.1.0
+six==1.17.0
+starlette==1.3.1
+tinycss2==1.5.1
+typing-inspection==0.4.2
+typing_extensions==4.16.0
+uvicorn==0.51.0
+uvloop==0.22.1
+watchfiles==1.2.0
+webencodings==0.5.1
+websockets==16.1.1
+wsproto==1.3.2
+yarl==1.24.5
diff --git a/doc/manual/chapters/installation.adoc b/doc/manual/chapters/installation.adoc
index 511927a..0433e5b 100644
--- a/doc/manual/chapters/installation.adoc
+++ b/doc/manual/chapters/installation.adoc
@@ -6,7 +6,7 @@ This section provides detailed instructions on how to install NSE2 on your syste
Before installing NSE2, ensure that you have the following prerequisites:
* A compatible operating system (Linux, macOS, or Windows)
-* Python 3.10 or higher
+* Python 3.11 or higher
* pip (Python package installer)
* Git (for cloning the repository)
* Docker (for containerized environments)
@@ -19,21 +19,22 @@ NOTE: In theory, podman can be used instead of Docker, but it is not tested. Als
. Clone the NSE2 repository from GitHub: `git clone https://github.com/esa/nse2.git`
. Navigate to the NSE2 directory: `cd nse2`
-. Create a virtual environment: `python3 -m venv .venv`
-. Activate the virtual environment and set up tools in your PATH. We recommend using `load_env.sh` before installing the dependencies:
+. Create and activate a virtual environment, then install the project and its dependencies:
+
[source,bash]
----
-source load_env.sh
-pip install -r requirements.txt
+python3 -m venv .venv
+source .venv/bin/activate
+pip install ".[dev]"
----
+
-**Alternative approach using symlinks:**
-If you prefer not to source `load_env.sh` in every new shell session, you can manually activate the environment, install the dependencies, and use the `install_symlinks.sh` script to install all tools into your `.venv/bin` directory:
+All `nse2_*` tools and helpers are installed into the virtual environment and available on PATH whenever the venv is active. `PYTHONPATH` is handled automatically.
++
+TIP: For pinning dependencies to the exact versions tested in this repository, add `-c constraints.txt` to the `pip install` command.
+
+. **For development:** use an editable install so that changes to the source code take effect immediately without reinstalling:
+
[source,bash]
----
-source .venv/bin/activate
-pip install -r requirements.txt
-./install_symlinks.sh
+pip install -e ".[dev]"
----
\ No newline at end of file
diff --git a/install_symlinks.sh b/install_symlinks.sh
deleted file mode 100755
index de9b01b..0000000
--- a/install_symlinks.sh
+++ /dev/null
@@ -1,30 +0,0 @@
-#!/bin/sh
-
-# This script is used to install symlinks to the sns scripts in the .venv or user supplied directory
-
-TARGET=.venv/bin
-
-if [ -n "$1" ]; then
- TARGET=$1
-fi
-
-if [ ! -d $TARGET ]; then
- echo "Target directory $TARGET does not exist"
- exit 1
-fi
-
-echo "Installing symlinks to sns scripts in $TARGET"
-for script in tools/bin/*; do
- echo "Linking $script to $TARGET/$(basename $script)"
- ln -sf $(pwd)/$script $TARGET/$(basename $script)
-done
-
-
-for script in tools/helpers/*; do
- echo "Linking $script to $TARGET/$(basename $script)"
- ln -sf $(pwd)/$script $TARGET/$(basename $script)
-done
-
-echo "export PYTHONPATH=\"$(pwd):\$PYTHONPATH\"" >> .venv/bin/activate
-
-echo "Done"
diff --git a/load_env.sh b/load_env.sh
deleted file mode 100644
index a00f144..0000000
--- a/load_env.sh
+++ /dev/null
@@ -1,15 +0,0 @@
-#!/bin/sh
-
-# check if there is a .venv folder
-if [ -d ".venv" ]; then
- echo "Activating virtual environment"
- . .venv/bin/activate
-else
- echo "No virtual environment found, assuming global python environment"
-fi
-
-echo "Adding tools/bin and tools/helpers to PATH"
-export PATH=$PATH:$(pwd)/tools/bin:$(pwd)/tools/helpers
-
-echo "Adding project root to PYTHONPATH"
-export PYTHONPATH=$(pwd):$PYTHONPATH
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..590ddc0
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,44 @@
+[build-system]
+requires = ["setuptools>=61"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "nse2"
+version = "0.1.0"
+requires-python = ">=3.11"
+dependencies = [
+ "matplotlib",
+ "networkx",
+ "nicegui>=3.3.0",
+ "pyaml",
+ "python-dateutil",
+]
+
+[project.optional-dependencies]
+dev = [
+ "pytest",
+ "basedpyright>=1.39",
+]
+
+[project.scripts]
+nse2_contacts = "tools.contact_player.contact_player:main"
+nse2_cmd = "tools.bin.nse2_cmd:main"
+nse2_mgr = "tools.mgr.mgr:main"
+nse2_netviz = "tools.netviz.netviz:main"
+csv_to_ccp = "tools.helpers.csv_to_ccp:main"
+csv_to_compose = "tools.helpers.csv_to_compose:main"
+random_contacts = "tools.helpers.random_contacts:main"
+
+[tool.setuptools]
+script-files = [
+ "tools/bin/nse2_actions",
+ "tools/bin/nse2_sh",
+ "tools/bin/nse2_topo",
+]
+
+[tool.setuptools.packages.find]
+include = ["tools", "tools.*"]
+
+[tool.basedpyright]
+reportUnusedCallResult = false
+reportImplicitStringConcatenation = false
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index c5b7268..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,7 +0,0 @@
-matplotlib==3.10.9
-networkx==3.6.1
-nicegui==3.13.0
-pyaml==26.2.1
-python-dateutil==2.9.0.post0
-pytest==9.1.1
-
diff --git a/tools/__init__.py b/tools/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tools/bin/__init__.py b/tools/bin/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tools/bin/nse2_cmd b/tools/bin/nse2_cmd.py
similarity index 100%
rename from tools/bin/nse2_cmd
rename to tools/bin/nse2_cmd.py
diff --git a/tools/bin/nse2_topo b/tools/bin/nse2_topo
index 9cd63cf..e319216 100755
--- a/tools/bin/nse2_topo
+++ b/tools/bin/nse2_topo
@@ -32,7 +32,7 @@ COMPOSE_FILE=$1
echo "Starting containers and network topology for scenario $COMPOSE_FILE"
# Ensure proper cleanup on exit or abort
-trap cleanup EXIT
+trap cleanup EXIT INT TERM
$DOCKERCMD compose -f $COMPOSE_FILE up --force-recreate --build --remove-orphans -d
diff --git a/tools/contact_player/__init__.py b/tools/contact_player/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tools/dslproxy/__init__.py b/tools/dslproxy/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tools/helpers/__init__.py b/tools/helpers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tools/helpers/get-fixed-links.py b/tools/helpers/get_fixed_links.py
similarity index 100%
rename from tools/helpers/get-fixed-links.py
rename to tools/helpers/get_fixed_links.py
diff --git a/tools/helpers/random-contacts.py b/tools/helpers/random_contacts.py
similarity index 100%
rename from tools/helpers/random-contacts.py
rename to tools/helpers/random_contacts.py
diff --git a/tools/mgr/__init__.py b/tools/mgr/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tools/mgr/mgr.py b/tools/mgr/mgr.py
index 68b2806..4a46bd9 100755
--- a/tools/mgr/mgr.py
+++ b/tools/mgr/mgr.py
@@ -9,7 +9,7 @@
import networkx as nx
import asyncio
-from helpers import *
+from tools.mgr.helpers import *
# regex to extract rate, delay, loss, jitter from tc output
tc_rate = re.compile(r"rate ([0-9]+[KMG]bit)")
@@ -301,8 +301,10 @@ async def draw_links(links_area: ui.scroll_area, compose_file: str):
print(e)
bg = "#f3f4f6" if is_active else "#fde8e8"
- with ui.row().classes("place-items-center w-full").style(
- f"background-color: {bg}"
+ with (
+ ui.row()
+ .classes("place-items-center w-full")
+ .style(f"background-color: {bg}")
):
if is_active:
ui.icon("cloud_done").classes("text-green-500").style(
@@ -347,7 +349,6 @@ def draw_map(map_area: ui.scroll_area):
map_area.clear()
with ui.matplotlib(figsize=(8, 5)).figure as fig:
-
# x = np.linspace(0.0, 5.0)
# y = np.cos(2 * np.pi * x) * np.exp(-x)
ax = fig.gca()
@@ -437,13 +438,6 @@ def ui_main(compose_file: str, contact_plan: str):
5.0,
lambda: linkstate_timer(compose_file, links_area, map_area),
)
- ui.run(
- reload=True,
- title="Docker TestBed Manager",
- show=False,
- port=8800,
- host="127.0.0.1",
- )
def main():
@@ -469,7 +463,18 @@ def main():
# s = time.time()
# print(get_container_interfaces_parallel(compose_file))
# print(f"Elapsed time: {time.time() - s}")
- ui_main(compose_file, contact_plan)
+
+ def build_page():
+ ui_main(compose_file, contact_plan)
+
+ ui.run(
+ root=build_page,
+ reload=False,
+ title="Docker TestBed Manager",
+ show=False,
+ port=8800,
+ host="127.0.0.1",
+ )
if __name__ in {"__main__", "__mp_main__"}:
diff --git a/tools/netviz/__init__.py b/tools/netviz/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tools/netviz/netviz.py b/tools/netviz/netviz.py
index 0ec61b5..1d056f7 100755
--- a/tools/netviz/netviz.py
+++ b/tools/netviz/netviz.py
@@ -40,268 +40,269 @@ def load_config(path: str) -> VisConfig:
return json.load(config_file)
-print("Starting Network Visualization")
-parser = argparse.ArgumentParser()
-parser.add_argument("config", help="network visualization config file to load")
-args = parser.parse_args()
-
-vizjson_filename = args.config
-config = load_config(vizjson_filename)
-if "background" in config:
- background = config["background"]
-else:
- background = "background.jpg"
-# print(config)
-
-if "links" not in config:
- print("WARNING: No links file provided in config")
- netmap_filename = None
-else:
- netmap_filename = config["links"]
-
-DOCKER_PATH = cast(str, shutil.which("docker"))
-assert DOCKER_PATH is not None, "docker executable not found in PATH"
-
-# load background file as base64 string
-# with open(background, "rb") as f:
-# background = f.read()
-# convert bytes to base64
-# background = "data:image/jpeg;base64," + base64.b64encode(background).decode("utf-8")
-
-
-def add_marker(x: int, y: int, label: str, color: str):
- marker = f''
- marker += f'{label}'
- return marker
-
-
-def add_link(
- node1: Node, node2: Node, color: str = "green", dashed: bool = False
-) -> str:
- if dashed:
- now = int(time.time() * 10) % 5
- return f''
+def main() -> None:
+ print("Starting Network Visualization")
+ parser = argparse.ArgumentParser()
+ parser.add_argument("config", help="network visualization config file to load")
+ args = parser.parse_args()
+
+ vizjson_filename = args.config
+ config = load_config(vizjson_filename)
+ if "background" in config:
+ background = config["background"]
else:
- return f''
+ background = "background.jpg"
+ # print(config)
+ if "links" not in config:
+ print("WARNING: No links file provided in config")
+ netmap_filename = None
+ else:
+ netmap_filename = config["links"]
+
+ DOCKER_PATH = cast(str, shutil.which("docker"))
+ assert DOCKER_PATH is not None, "docker executable not found in PATH"
+
+ def add_marker(x: int, y: int, label: str, color: str):
+ marker = f''
+ marker += f'{label}'
+ return marker
+
+ def add_link(
+ node1: Node, node2: Node, color: str = "green", dashed: bool = False
+ ) -> str:
+ if dashed:
+ now = int(time.time() * 10) % 5
+ return f''
+ else:
+ return f''
+
+ def attach_container_to_xterm(terminal: Xterm, node: Node) -> None:
+ """Attaches a xterm terminal in the UI to /bin/bash in a docker container.
+
+ Args:
+ terminal: xterm.js terminal to attach input/output to.
+ node: the specific node to attach to.
+ """
+ pty_pid, pty_fd = pty.fork()
+ if pty_pid == pty.CHILD:
+ os.execv(
+ DOCKER_PATH,
+ ["docker", "exec", "-it", node["name"], "/bin/bash"],
+ )
-def mouse_handler(e: events.MouseEventArguments):
- color = "SkyBlue" if e.type == "mousedown" else "SteelBlue"
- ii.content += f''
- ui.notify(f"{e.type} at ({e.image_x:.1f}, {e.image_y:.1f})")
- print(f"{e.type} at ({e.image_x:.1f}, {e.image_y:.1f})")
-
-
-def load_netmap():
- global links
- links = []
- if not netmap_filename:
- return
- with open(netmap_filename) as f:
- for line in f:
- node1, link_type, node2 = line.split()
- links.append((node1, node2, link_type))
-
-
-def draw_netmap():
- content = ""
-
- for node in config["nodes"]:
- content += add_marker(node["x"], node["y"], node["name"], node["color"])
- # content += add_marker(500, 500, "node1", "SkyBlue")
-
- global links
-
- for node1, node2, link_type in links:
- node1 = next((node for node in config["nodes"] if node["name"] == node1), None)
- node2 = next((node for node in config["nodes"] if node["name"] == node2), None)
- content += add_link(node1, node2, dashed=(link_type == "."))
-
- ii.content = content
-
-
-def attach_container_to_xterm(terminal: Xterm, node: Node) -> None:
- """Attaches a xterm terminal in the UI to /bin/bash in a docker container.
-
- Args:
- terminal: xterm.js terminal to attach input/output to.
- node: the specific node to attach to.
- """
- pty_pid, pty_fd = pty.fork()
- if pty_pid == pty.CHILD:
- os.execv(
- DOCKER_PATH,
- ["docker", "exec", "-it", node["name"], "/bin/bash"],
- )
+ if core.loop is not None:
+
+ @partial(core.loop.add_reader, pty_fd)
+ def pty_to_terminal(): # pyright: ignore[reportUnusedFunction]
+ try:
+ data = os.read(pty_fd, 1024)
+ except OSError:
+ print("Stopping reading from pty")
+ if core.loop is not None:
+ core.loop.remove_reader(pty_fd)
+ else:
+ terminal.write(data)
+
+ @terminal.on_data
+ def terminal_to_pty(event: events.XtermDataEventArguments) -> None: # pyright: ignore[reportUnusedFunction]
+ try:
+ os.write(pty_fd, event.data.encode("utf-8"))
+ except OSError:
+ pass
- if core.loop is not None:
+ @terminal.on_resize
+ def resize_terminal(event: events.XtermResizeEventArguments) -> None: # pyright: ignore[reportUnusedFunction]
+ try:
+ fcntl.ioctl(
+ pty_fd,
+ termios.TIOCSWINSZ,
+ struct.pack("HHHH", event.rows, event.cols, 0, 0),
+ )
+ except OSError:
+ pass
- @partial(core.loop.add_reader, pty_fd)
- def pty_to_terminal(): # pyright: ignore[reportUnusedFunction]
+ @ui.context.client.on_delete # pyright: ignore[reportUnknownMemberType]
+ def kill_bash() -> None: # pyright: ignore[reportUnusedFunction]
try:
- data = os.read(pty_fd, 1024)
+ os.close(pty_fd)
except OSError:
- print("Stopping reading from pty")
- if core.loop is not None:
- core.loop.remove_reader(pty_fd)
- else:
- terminal.write(data)
-
- @terminal.on_data
- def terminal_to_pty(event: events.XtermDataEventArguments) -> None: # pyright: ignore[reportUnusedFunction]
- try:
- os.write(pty_fd, event.data.encode("utf-8"))
- except OSError:
- pass
-
- @terminal.on_resize
- def resize_terminal(event: events.XtermResizeEventArguments) -> None: # pyright: ignore[reportUnusedFunction]
- try:
- fcntl.ioctl(
- pty_fd,
- termios.TIOCSWINSZ,
- struct.pack("HHHH", event.rows, event.cols, 0, 0),
- )
- except OSError:
- pass
-
- @ui.context.client.on_delete # pyright: ignore[reportUnknownMemberType]
- def kill_bash() -> None: # pyright: ignore[reportUnusedFunction]
- try:
- os.close(pty_fd)
- except OSError:
- pass
- os.kill(pty_pid, signal.SIGKILL)
- print("Terminal closed")
-
-
-def build_terminal_footer(nodes: list[Node]) -> None:
- """Creates the UI elements for the terminal panel in the footer.
-
- Args:
- nodes: list of nodes to create terminals for.
- """
- terminal_panel_expanded = False
- with ui.footer().classes("w-full p-0 flex-col gap-0"):
- # bar with the individual tabs for every node
- with ui.row().classes("w-full items-center bg-blue-500 px-2 relative"):
- with ui.tabs().classes("flex-1") as tabs:
- for node in nodes:
- ui.tab(node["name"], icon=node["type"])
- # chevron toggle button on the right
- chevron = ui.button(icon="expand_less").props("flat dense color=white")
-
- # invisible div element added on top of the tab bar that acts as a handle to resize the footer
- # js changes the height of the terminal-panel
- ui.element("div").classes(
- "absolute top-0 left-0 w-full cursor-row-resize"
- ).style("height: 6px; z-index: 10;").on(
- "mousedown",
- js_handler="""
- (e) => {
- e.preventDefault();
- const footer = e.target.closest('footer');
- const panel = footer.querySelector('.terminal-panel');
- const startY = e.clientY;
- const startH = panel.offsetHeight;
- const onMove = (e) => {
- const newH = startH - (e.clientY - startY);
- panel.style.height = Math.max(100, newH) + 'px';
- };
- const onUp = () => {
- window.removeEventListener('mousemove', onMove);
- window.removeEventListener('mouseup', onUp);
- };
- window.addEventListener('mousemove', onMove);
- window.addEventListener('mouseup', onUp);
- }
- """,
- )
- # content of each tab
- with (
- ui.column()
- .classes("w-full terminal-panel")
- .style("height: 300px; min-height: 100px;") as panel
- ):
- with ui.tab_panels(tabs, value=nodes[0]["name"]).classes(
- "w-full h-full p-0"
+ pass
+ os.kill(pty_pid, signal.SIGKILL)
+ print("Terminal closed")
+
+ def build_terminal_footer(nodes: list[Node]) -> None:
+ """Creates the UI elements for the terminal panel in the footer.
+
+ Args:
+ nodes: list of nodes to create terminals for.
+ """
+ terminal_panel_expanded = False
+ with ui.footer().classes("w-full p-0 flex-col gap-0"):
+ # bar with the individual tabs for every node
+ with ui.row().classes("w-full items-center bg-blue-500 px-2 relative"):
+ with ui.tabs().classes("flex-1") as tabs:
+ for node in nodes:
+ ui.tab(node["name"], icon=node["type"])
+ # chevron toggle button on the right
+ chevron = ui.button(icon="expand_less").props("flat dense color=white")
+
+ # invisible div element added on top of the tab bar that acts as a handle to resize the footer
+ # js changes the height of the terminal-panel
+ ui.element("div").classes(
+ "absolute top-0 left-0 w-full cursor-row-resize"
+ ).style("height: 6px; z-index: 10;").on(
+ "mousedown",
+ js_handler="""
+ (e) => {
+ e.preventDefault();
+ const footer = e.target.closest('footer');
+ const panel = footer.querySelector('.terminal-panel');
+ const startY = e.clientY;
+ const startH = panel.offsetHeight;
+ const onMove = (e) => {
+ const newH = startH - (e.clientY - startY);
+ panel.style.height = Math.max(100, newH) + 'px';
+ };
+ const onUp = () => {
+ window.removeEventListener('mousemove', onMove);
+ window.removeEventListener('mouseup', onUp);
+ };
+ window.addEventListener('mousemove', onMove);
+ window.addEventListener('mouseup', onUp);
+ }
+ """,
+ )
+ # content of each tab
+ with (
+ ui.column()
+ .classes("w-full terminal-panel")
+ .style("height: 300px; min-height: 100px;") as panel
):
- for node in nodes:
- with ui.tab_panel(node["name"]).classes("w-full p-0"):
- terminal = ui.xterm().classes("w-full h-full")
- ui.element("q-resize-observer").on("resize", terminal.fit)
- attach_container_to_xterm(terminal, node)
- panel.set_visibility(terminal_panel_expanded)
-
- # toggle logic
- def toggle_panel():
- nonlocal terminal_panel_expanded
- terminal_panel_expanded = not terminal_panel_expanded
- panel.set_visibility(terminal_panel_expanded)
- chevron.props(
- "icon=expand_more"
- if not terminal_panel_expanded
- else "icon=expand_less"
+ with ui.tab_panels(tabs, value=nodes[0]["name"]).classes(
+ "w-full h-full p-0"
+ ):
+ for node in nodes:
+ with ui.tab_panel(node["name"]).classes("w-full p-0"):
+ terminal = ui.xterm().classes("w-full h-full")
+ ui.element("q-resize-observer").on("resize", terminal.fit)
+ attach_container_to_xterm(terminal, node)
+ panel.set_visibility(terminal_panel_expanded)
+
+ # toggle logic
+ def toggle_panel():
+ nonlocal terminal_panel_expanded
+ terminal_panel_expanded = not terminal_panel_expanded
+ panel.set_visibility(terminal_panel_expanded)
+ chevron.props(
+ "icon=expand_more"
+ if not terminal_panel_expanded
+ else "icon=expand_less"
+ )
+
+ def open_panel():
+ nonlocal terminal_panel_expanded
+ terminal_panel_expanded = True
+ panel.set_visibility(True)
+ chevron.props("icon=expand_less")
+
+ chevron.on_click(toggle_panel)
+ # clicking a tab also opens the panel if collapsed
+ tabs.on("update:model-value", lambda _: open_panel())
+
+ def build_page() -> None:
+ links = []
+
+ def mouse_handler(e: events.MouseEventArguments):
+ color = "SkyBlue" if e.type == "mousedown" else "SteelBlue"
+ ii.content += f''
+ ui.notify(f"{e.type} at ({e.image_x:.1f}, {e.image_y:.1f})")
+ print(f"{e.type} at ({e.image_x:.1f}, {e.image_y:.1f})")
+
+ def load_netmap():
+ links.clear()
+ if not netmap_filename:
+ return
+ with open(netmap_filename) as f:
+ for line in f:
+ node1, link_type, node2 = line.split()
+ links.append((node1, node2, link_type))
+
+ def draw_netmap():
+ content = ""
+
+ for node in config["nodes"]:
+ content += add_marker(node["x"], node["y"], node["name"], node["color"])
+
+ for node1_name, node2_name, link_type in links:
+ node1 = next(
+ (node for node in config["nodes"] if node["name"] == node1_name),
+ None,
+ )
+ node2 = next(
+ (node for node in config["nodes"] if node["name"] == node2_name),
+ None,
+ )
+
+ if node1 is not None and node2 is not None:
+ content += add_link(node1, node2, dashed=(link_type == "."))
+
+ ii.content = content
+
+ with ui.card().classes("no-shadow self-center w-[1200px]"):
+ ui.markdown(
+ f"""
+ ## {config["title"]}
+
+ *{config["description"]}*
+
+ """
)
-
- def open_panel():
- nonlocal terminal_panel_expanded
- terminal_panel_expanded = True
- panel.set_visibility(True)
- chevron.props("icon=expand_less")
-
- chevron.on_click(toggle_panel)
- # clicking a tab also opens the panel if collapsed
- tabs.on("update:model-value", lambda _: open_panel())
-
-
-with ui.card().classes("no-shadow self-center w-[1200px]") as card:
- ui.markdown(
- f"""
- ## {config["title"]}
-
- *{config["description"]}*
-
- """
- )
- with ui.row():
- ii = ui.interactive_image(
- background,
- content="",
- on_mouse=mouse_handler,
- events=["mousedown", "mouseup"],
- cross=True,
+ with ui.row():
+ ii = ui.interactive_image(
+ background,
+ content="",
+ on_mouse=mouse_handler,
+ events=["mousedown", "mouseup"],
+ cross=True,
+ )
+ log = ui.log().classes("w-full")
+
+ build_terminal_footer(config["nodes"])
+
+ log_file = "tmp/main.log"
+ f = subprocess.Popen(
+ ["stdbuf", "-oL", "tail", "-F", log_file, "-n", "+0"],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
)
- log = ui.log().classes("w-full")
-
-build_terminal_footer(config["nodes"])
-
-
-log_file = "tmp/main.log"
-f = subprocess.Popen(
- ["stdbuf", "-oL", "tail", "-F", log_file, "-n", "+0"],
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
-)
-p = select.poll()
-p.register(f.stdout)
-
-log.push("Network Visualization started")
-log.push(f"Waiting for log messages in {log_file}...")
-
-
-def check_logfile():
- if p.poll(0.1):
- line = f.stdout.readline().decode("utf-8").strip()
- log.push(line)
-
-
-ui.timer(interval=0.1, callback=check_logfile, once=False)
-# spawn background worker to follow tmp/main.log and append new lines to log widget
+ p = select.poll()
+ p.register(f.stdout)
+
+ log.push("Network Visualization started")
+ log.push(f"Waiting for log messages in {log_file}...")
+
+ def check_logfile():
+ if p.poll(0.1):
+ line = f.stdout.readline().decode("utf-8").strip()
+ log.push(line)
+
+ ui.timer(interval=0.1, callback=check_logfile, once=False)
+ # spawn background worker to follow tmp/main.log and append new lines to log widget
+
+ ui.timer(interval=1, callback=load_netmap, once=False)
+ ui.timer(interval=0.1, callback=draw_netmap, once=False)
+ dark = ui.dark_mode()
+ dark.enable()
+
+ ui.run(
+ root=build_page,
+ title="Network Visualization",
+ reload=False,
+ host="127.0.0.1",
+ show=False,
+ )
-ui.timer(interval=1, callback=load_netmap, once=False)
-ui.timer(interval=0.1, callback=draw_netmap, once=False)
-dark = ui.dark_mode()
-dark.enable()
-ui.run(title="Network Visualization", reload=True, host="127.0.0.1", show=False)
+if __name__ == "__main__":
+ main()