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()