Ver código fonte

Add " Interface IP" display capability + More modern CSS style (#25)

* add display of interface ip capability - in main.py

* add display of interface ip capability - in main.html

* add improved css style - in pure-min.css

* Enhance TCGUI Interface Design

- Improved the overall layout and styling of the interface table.
- Added a cleaner and modern design using CSS.
- Ensured all interfaces are displayed and properly aligned.
- Added hover effects for buttons to enhance interactivity.
- Updated the color scheme for better readability and aesthetics.
- Maintained functionality while improving visual appeal.

* Update main.py

Co-authored-by: Markus Hofbauer <markus.hofbauer@tum.de>

* add if match else "No IP found" - main.py

Co-authored-by: Markus Hofbauer <markus.hofbauer@tum.de>

* rename css file to gui_styles.css

* remove old file

* add suggestion for stdout decode

* rolled back proc and output separation main.py

Display of IP addresses started causing issues after last edit, had to rollback to my initial contribution so UI will display IP correctly

* Replace Popen with subprocess.run

use subprocess.run instead of subprocess.Popen for
retrieving IP

* Add error handling to get_interfaces functions

* Add new GUI screenshot to the readme

* Delete tcgui.png

* run - `pre-commit autoupdate`

* add setuptools dependency for `pre-commit run -a` failure

* lines added by `pre-commit run -a`

* network troubleshooting tools added to Dockerfile for net testing

* Update .pre-commit-config.yaml

* add docker-compose documentation

* fix - Correctly retrieve IP addresses for network interfaces

* default: network_mode: host

* Remove redundancy in IP command execution

* run pre-commit autoupdate

* fixes by `pre-commit run -a`

* pre-commit autoupdate

* pyupgrade downgrade to 3.16 due to python 3.8 dependency

---------

Co-authored-by: Markus Hofbauer <markus.hofbauer@tum.de>
URDead 1 ano atrás
pai
commit
e996853b97
10 arquivos alterados com 444 adições e 61 exclusões
  1. 14 13
      .pre-commit-config.yaml
  2. 1 1
      Dockerfile
  3. 12 1
      README.md
  4. 2 0
      docker-compose.yml
  5. 41 1
      main.py
  6. 253 0
      static/gui_styles.css
  7. 0 10
      static/pure-min.css
  8. BIN
      tcgui-new.png
  9. BIN
      tcgui.png
  10. 121 35
      templates/main.html

+ 14 - 13
.pre-commit-config.yaml

@@ -2,7 +2,7 @@ default_stages:
   - commit
 repos:
   - repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v4.4.0
+    rev: v4.6.0
     hooks:
       - id: check-added-large-files
       - id: check-case-conflict
@@ -18,14 +18,14 @@ repos:
       - id: requirements-txt-fixer
       - id: trailing-whitespace
   - repo: https://github.com/executablebooks/mdformat
-    rev: 0.7.16
+    rev: 0.7.17
     hooks:
       - id: mdformat
         additional_dependencies:
           - mdformat-gfm
           - mdformat-black
   - repo: https://github.com/jumanjihouse/pre-commit-hook-yamlfmt
-    rev: 0.2.2
+    rev: 0.2.3
     hooks:
       - id: yamlfmt
         args:
@@ -37,23 +37,23 @@ repos:
           - '2'
           - --implicit_start
   - repo: https://github.com/psf/black
-    rev: 22.10.0
+    rev: 24.8.0
     hooks:
       - id: black
         language_version: python3
       - id: black-jupyter
         language_version: python3
   - repo: https://github.com/asottile/blacken-docs
-    rev: v1.12.1
+    rev: 1.18.0
     hooks:
       - id: blacken-docs
   - repo: https://github.com/PyCQA/isort
-    rev: 5.10.1
+    rev: 5.13.2
     hooks:
       - id: isort
         args: [--profile, black]
   - repo: https://github.com/PyCQA/docformatter
-    rev: v1.5.0
+    rev: v1.7.5
     hooks:
       - id: docformatter
         args:
@@ -63,7 +63,7 @@ repos:
           - --wrap-descriptions
           - '81'
   - repo: https://github.com/PyCQA/flake8
-    rev: 6.0.0
+    rev: 7.1.1
     hooks:
       - id: flake8
         additional_dependencies:
@@ -71,25 +71,26 @@ repos:
           - flake8-bugbear
           - flake8-pytest-style
           - flake8-comprehensions
+          - setuptools
   - repo: https://github.com/PyCQA/pylint
-    rev: v2.15.8
+    rev: v3.2.6
     hooks:
       - id: pylint
   - repo: https://github.com/asottile/pyupgrade
-    rev: v3.3.1
+    rev: v3.16.0
     hooks:
       - id: pyupgrade
         args:
           - --py38-plus
   - repo: https://github.com/asottile/yesqa
-    rev: v1.4.0
+    rev: v1.5.0
     hooks:
       - id: yesqa
   - repo: https://github.com/PyCQA/autoflake
-    rev: v2.0.0
+    rev: v2.3.1
     hooks:
       - id: autoflake
   - repo: https://github.com/crate-ci/typos
-    rev: typos-dict-v0.9.13
+    rev: v1.23.6
     hooks:
       - id: typos

+ 1 - 1
Dockerfile

@@ -1,6 +1,6 @@
 FROM python:3.8-alpine
 
-RUN apk add --no-cache iproute2 && \
+RUN apk add --no-cache iproute2 mtr curl tcpdump net-tools iperf3 && \
     rm -rf /var/cache/apk/*
 
 RUN pip3 install Flask

+ 12 - 1
README.md

@@ -6,7 +6,7 @@
 
 A lightweight Python-based Web-GUI for Linux traffic control (`tc`) to set, view and delete traffic shaping rules. The Web-GUI is intended for short-term isolated testbeds or classroom scenarios and does not contain any security mechanisms.
 
-![tcgui screenshot](tcgui.png)
+![tcgui screenshot](tcgui-new.png)
 
 ## Requirements
 
@@ -49,6 +49,17 @@ You can change the configuration using these Environment Variables:
 - **TCGUI_DEV** - The interfaces to restrict to
 - **TCGUI_REGEX** - A regex to match interfaces
 
+### Docker-Compose
+
+```shell
+docker compose up --build
+
+## To run in the background:
+docker compose up -d --build
+```
+
+> Make sure you read the line of `network_mode` inside the `docker-compose.yml`
+
 If using an interface bridge, docker might cause issue with the bridge. ([askubunut](https://askubuntu.com/questions/1073501/docker-breaks-network-bridging-to-virtual-machines))
 To fix this, create a file `/etc/docker/daemon.json` with the following contents:
 

+ 2 - 0
docker-compose.yml

@@ -9,3 +9,5 @@ services:
       - 5000:5000
     cap_add:
       - NET_ADMIN
+    ## Uncomment if you want to control the traffic of the host machine:
+    network_mode: host

+ 41 - 1
main.py

@@ -68,8 +68,13 @@ def parse_arguments():
 @app.route("/")
 def main():
     rules = get_active_rules()
+    interfaces = get_interfaces()
     return render_template(
-        "main.html", rules=rules, units=BANDWIDTH_UNITS, standard_unit=STANDARD_UNIT
+        "main.html",
+        rules=rules,
+        units=BANDWIDTH_UNITS,
+        standard_unit=STANDARD_UNIT,
+        interfaces=interfaces,
     )
 
 
@@ -140,6 +145,21 @@ def filter_interface_name(interface):
     return re.sub(r"[^A-Za-z0-9_-]+", "", interface)
 
 
+def run_ip_command(command_args):
+    """Runs the 'ip' command with the specified arguments and returns the output."""
+    try:
+        result = subprocess.run(
+            command_args,
+            capture_output=True,
+            text=True,
+            check=True,
+        )
+        return result.stdout
+    except subprocess.CalledProcessError as e:
+        print(f"Error executing command '{command_args}': {e}")
+        return ""
+
+
 def get_active_rules():
     proc = subprocess.Popen(["tc", "qdisc"], stdout=subprocess.PIPE)
     output = proc.communicate()[0].decode()
@@ -150,16 +170,36 @@ def get_active_rules():
         arguments = line.split()
         rule = parse_rule(arguments)
         if rule["name"] and rule["name"] not in dev:
+            rule["ip"] = get_interface_ip(rule["name"])
             rules.append(rule)
             dev.add(rule["name"])
             rules.sort(key=lambda x: x["name"])
     return rules
 
 
+def get_interfaces():
+    output = run_ip_command(["ip", "-o", "-4", "addr", "show"])
+    interfaces = {}
+    for line in output.split("\n"):
+        if line:
+            parts = line.split()
+            iface = parts[1]
+            ip = parts[3].split("/")[0]
+            interfaces[iface] = ip
+    return interfaces
+
+
+def get_interface_ip(interface):
+    output = run_ip_command(["ip", "-o", "-4", "addr", "show", interface])
+    match = re.search(r"inet (\d+\.\d+\.\d+\.\d+)", output)
+    return match.group(1) if match else "No IP found"
+
+
 def parse_rule(split_rule):
     # pylint: disable=too-many-branches
     rule = {
         "name": None,
+        "ip": None,
         "rate": None,
         "delay": None,
         "delayVariance": None,

+ 253 - 0
static/gui_styles.css

@@ -0,0 +1,253 @@
+/*!
+Pure v1.0.1
+Copyright 2013 Yahoo!
+Licensed under the BSD License.
+https://github.com/pure-css/pure/blob/master/LICENSE.md
+*/
+/*!
+normalize.css v^3.0 | MIT License | git.io/normalize
+Copyright (c) Nicolas Gallagher and Jonathan Neal
+*/
+/*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */
+html {
+    font-family: 'Roboto', sans-serif;
+    -ms-text-size-adjust: 100%;
+    -webkit-text-size-adjust: 100%;
+}
+
+body {
+    margin: 0;
+    background-color: #f4f4f9;
+    color: #333;
+}
+
+a {
+    background-color: transparent;
+    transition: color 0.3s ease;
+}
+
+a:active, a:hover {
+    outline: 0;
+    color: #0078e7;
+}
+
+button, input, optgroup, select, textarea {
+    color: inherit;
+    font: inherit;
+    margin: 0;
+}
+
+button {
+    overflow: visible;
+    padding: 10px 20px;
+    background-color: #0078e7;
+    color: white;
+    border: none;
+    border-radius: 5px;
+    box-shadow: 0 5px #005bb5;
+    transition: background-color 0.3s ease, transform 0.2s ease;
+}
+
+button:hover {
+    background-color: #005bb5;
+    transform: translateY(-2px);
+}
+
+button:active {
+    background-color: #004494;
+    box-shadow: 0 2px #005bb5;
+    transform: translateY(0);
+}
+
+button:focus {
+    outline: none;
+}
+
+.pure-form input[type=text], .pure-form input[type=email], .pure-form select, .pure-form textarea {
+    padding: 10px;
+    display: inline-block;
+    border: 1px solid #ccc;
+    border-radius: 5px;
+    box-shadow: inset 0 1px 3px #ddd;
+    transition: border-color 0.3s ease, box-shadow 0.3s ease;
+}
+
+.pure-form input[type=text]:focus, .pure-form input[type=email]:focus, .pure-form select:focus, .pure-form textarea:focus {
+    outline: none;
+    border-color: #0078e7;
+    box-shadow: inset 0 1px 3px rgba(0, 120, 231, 0.5);
+}
+
+.pure-form .pure-group fieldset {
+    margin-bottom: 10px;
+}
+
+.pure-form .pure-group input, .pure-form .pure-group textarea {
+    display: block;
+    padding: 10px;
+    margin: 0 0 -1px;
+    border-radius: 0;
+    position: relative;
+    top: -1px;
+}
+
+.pure-form .pure-group input:focus, .pure-form .pure-group textarea:focus {
+    z-index: 3;
+}
+
+.pure-form .pure-group input:first-child, .pure-form .pure-group textarea:first-child {
+    top: 1px;
+    border-radius: 5px 5px 0 0;
+    margin: 0;
+}
+
+.pure-form .pure-group input:first-child:last-child, .pure-form .pure-group textarea:first-child:last-child {
+    top: 1px;
+    border-radius: 5px;
+    margin: 0;
+}
+
+.pure-form .pure-group input:last-child, .pure-form .pure-group textarea:last-child {
+    top: -2px;
+    border-radius: 0 0 5px 5px;
+    margin: 0;
+}
+
+.pure-form .pure-group button {
+    margin: .35em 0;
+}
+
+.pure-form .pure-input-1 {
+    width: 100%;
+}
+
+.pure-form .pure-input-3-4 {
+    width: 75%;
+}
+
+.pure-form .pure-input-2-3 {
+    width: 66%;
+}
+
+.pure-form .pure-input-1-2 {
+    width: 50%;
+}
+
+.pure-form .pure-input-1-3 {
+    width: 33%;
+}
+
+.pure-form .pure-input-1-4 {
+    width: 25%;
+}
+
+.pure-menu {
+    -webkit-box-sizing: border-box;
+    box-sizing: border-box;
+}
+
+.pure-menu-fixed {
+    position: fixed;
+    left: 0;
+    top: 0;
+    width: 100%;
+    z-index: 3;
+    background: #0078e7;
+    color: white;
+    padding: 10px;
+    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+}
+
+.pure-menu-heading {
+    text-transform: uppercase;
+    color: #fff;
+}
+
+.pure-menu-link {
+    color: #fff;
+    transition: color 0.3s ease;
+}
+
+.pure-menu-link:hover {
+    color: #d1ecff;
+}
+
+.pure-button {
+    display: inline-block;
+    zoom: 1;
+    line-height: normal;
+    white-space: nowrap;
+    vertical-align: middle;
+    text-align: center;
+    cursor: pointer;
+    -webkit-user-drag: none;
+    -webkit-user-select: none;
+    -moz-user-select: none;
+    -ms-user-select: none;
+    user-select: none;
+    -webkit-box-sizing: border-box;
+    box-sizing: border-box;
+    font-family: inherit;
+    font-size: 100%;
+    padding: .5em 1em;
+    color: #444;
+    color: rgba(0, 0, 0, .8);
+    border: 1px solid #999;
+    border: none transparent;
+    background-color: #e6e6e6;
+    text-decoration: none;
+    border-radius: 5px;
+    transition: background-color 0.3s ease, transform 0.2s ease;
+}
+
+.pure-button-hover, .pure-button:focus, .pure-button:hover {
+    background-image: -webkit-gradient(linear, left top, left bottom, from(transparent), color-stop(40%, rgba(0, 0, 0, .05)), to(rgba(0, 0, 0, .1)));
+    background-image: -webkit-linear-gradient(transparent, rgba(0, 0, 0, .05) 40%, rgba(0, 0, 0, .1));
+    background-image: linear-gradient(transparent, rgba(0, 0, 0, .05) 40%, rgba(0, 0, 0, .1));
+    transform: translateY(-2px);
+}
+
+.pure-button:focus {
+    outline: 0;
+}
+
+.pure-button-active, .pure-button:active {
+    -webkit-box-shadow: 0 0 0 1px rgba(0, 0, 0, .15) inset, 0 0 6px rgba(0, 0, 0, .2) inset;
+    box-shadow: 0 0 0 1px rgba(0, 0, 0, .15) inset, 0 0 6px rgba(0, 0, 0, .2) inset;
+    border-color: #000;
+    transform: translateY(0);
+}
+
+.pure-button-primary, .pure-button-selected, a.pure-button-primary, a.pure-button-selected {
+    background-color: #0078e7;
+    color: #fff;
+    box-shadow: 0 4px #005bb5;
+}
+
+.pure-form input[type=color]:focus, .pure-form input[type=date]:focus, .pure-form input[type=datetime-local]:focus, .pure-form input[type=datetime]:focus, .pure-form input[type=email]:focus, .pure-form input[type=month]:focus, .pure-form input[type=number]:focus, .pure-form input[type=password]:focus, .pure-form input[type=search]:focus, .pure-form input[type=tel]:focus, .pure-form input[type=text]:focus, .pure-form input[type=time]:focus, .pure-form input[type=url]:focus, .pure-form input[type=week]:focus, .pure-form select:focus, .pure-form textarea:focus {
+    outline: 0;
+    border-color: #03dac6;
+    box-shadow: inset 0 1px 3px rgba(3, 218, 198, 0.5);
+}
+
+
+.pure-form input[readonly], .pure-form select[readonly], .pure-form textarea[readonly] {
+    background-color: #eee;
+    color: #777;
+    border-color: #ccc;
+}
+
+.pure-form input:focus:invalid, .pure-form select:focus:invalid, .pure-form textarea:focus:invalid {
+    color: #b94a48;
+    border-color: #e9322d;
+}
+
+.pure-form input[type=checkbox]:focus:invalid:focus, .pure-form input[type=file]:focus:invalid:focus, .pure-form input[type=radio]:focus:invalid:focus {
+    outline-color: #e9322d;
+}
+
+.pure-form input[disabled], .pure-form select[disabled], .pure-form textarea[disabled] {
+    cursor: not-allowed;
+    background-color: #eaeded;
+    color: #cad2d3;
+}

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 10
static/pure-min.css


BIN
tcgui-new.png


BIN
tcgui.png


+ 121 - 35
templates/main.html

@@ -4,20 +4,121 @@
     <meta name="viewport" content="width=device-width, initial-scale=1">
     <meta charset="UTF-8">
     <title>tcgui</title>
-    <link rel="stylesheet" href="{{ url_for('static', filename='pure-min.css') }}">
+    <link rel="stylesheet" href="{{ url_for('static', filename='gui_styles.css') }}">
+    <style>
+        body {
+            font-family: Arial, sans-serif;
+            background-color: #e3f2fd;
+            color: #333;
+            padding: 20px;
+        }
+
+        .container {
+            max-width: 1200px;
+            margin: auto;
+            background: white;
+            padding: 20px;
+            border-radius: 10px;
+            box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
+        }
+
+        h1, h2 {
+            text-align: center;
+            color: #1e88e5;
+        }
+
+        ul {
+            list-style-type: none;
+            padding: 0;
+            margin: 0 auto;
+            width: fit-content; /* Adjust to fit the content */
+        }
+
+        ul li {
+            text-align: left;
+            margin-right: 10px;
+        }
+
+        ul li a {
+            text-decoration: none;
+            color: #1e88e5;
+        }
+
+        table {
+            width: 100%;
+            border-collapse: collapse;
+            margin: 20px 0;
+        }
+
+        th, td {
+            padding: 12px;
+            border: 1px solid #ddd;
+            text-align: center;
+        }
+
+        th {
+            background-color: #1e88e5;
+            color: white;
+        }
+
+        tr:nth-child(even) {
+            background-color: #f1f8e9;
+        }
+
+        .btn-container {
+            text-align: center;
+            margin-top: 20px;
+        }
+
+        .pure-button {
+            padding: 10px 20px;
+            font-size: 16px;
+            margin: 5px;
+            color: white;
+            border: none;
+            border-radius: 5px;
+            cursor: pointer;
+        }
+
+        .pure-button-primary {
+            background-color: #1e88e5;
+        }
+
+        .button-remove {
+            background-color: #d32f2f;
+        }
+
+        .input-group {
+            display: flex;
+            align-items: center;
+        }
+
+        .input-group input, .input-group select {
+            margin-right: 10px;
+        }
+
+        .tcgui-value-cell.modified {
+            background: rgb(121, 168, 255);
+        }
+
+        .tcgui-input-cell {
+            text-align: left;
+        }
+    </style>
 </head>
 <body>
 
-<div style="padding:2em;">
+<div class="container">
     <h1>TCGUI - {{ rules|length }} Available Interfaces</h1>
     <ul>
         {% for rule in rules %}
-        <li><a href="#{{ rule['name'] }}">{{ rule['name'] }}</a></li>
+        <li><a href="#{{ rule['name'] }}">{{ rule['name'] }} ({{ rule['ip'] }})</a></li>
         {% endfor %}
     </ul>
+
     {% for rule in rules %}
     <div>
-        <h2 id="{{ rule['name'] }}">{{ rule['name'] }}</h2>
+        <h2 id="{{ rule['name'] }}">{{ rule['name'] }} ({{ rule['ip'] }})</h2>
         <form method="POST" action="{{ url_for('new_rule', interface=rule['name']) }}">
             <table class="pure-table">
                 <thead>
@@ -34,12 +135,14 @@
                     <td>Rate</td>
                     <td class="tcgui-value-cell">{{ rule['rate'] }}</td>
                     <td class="tcgui-input-cell">
-                        <input type="number" name="Rate" size="5">
-                        <select name="rate_unit">
-                            {% for unit in units %}
-                            <option value="{{ unit }}" {% if unit== standard_unit %} selected{% endif %}>{{ unit }}</option>
-                            {% endfor %}
-                        </select>
+                        <div class="input-group">
+                            <input type="number" name="Rate" size="5">
+                            <select name="rate_unit">
+                                {% for unit in units %}
+                                <option value="{{ unit }}" {% if unit == standard_unit %} selected{% endif %}>{{ unit }}</option>
+                                {% endfor %}
+                            </select>
+                        </div>
                     </td>
                 </tr>
                 <tr>
@@ -80,14 +183,19 @@
                 </tr>
                 </tbody>
             </table>
-            <input class="pure-button pure-button-primary" type="submit" value="Apply {{ rule['name'] }} Rules">
+            <div class="btn-container">
+                <input class="pure-button pure-button-primary" type="submit" value="Apply {{ rule['name'] }} ({{ rule['ip'] }}) Rules">
+            </div>
         </form>
         <form method="POST" action="{{ url_for('remove_rule', interface= rule['name']) }}">
-            <input class="button-remove pure-button" type="submit" value="Remove {{ rule['name'] }} Rules">
+            <div class="btn-container">
+                <input class="button-remove pure-button" type="submit" value="Remove {{ rule['name'] }} ({{ rule['ip'] }}) Rules">
+            </div>
         </form>
     </div>
     {% endfor %}
 </div>
+
 <script type="text/javascript">
     (function() {
         /** Get next sibling of specified node or element.
@@ -127,7 +235,7 @@
                 input = firstChildElement(inputCell);
 
                 if (input.tagName === "INPUT") {
-                    // Match value cell innerText to extract integer value and optionals units.
+                    // Match value cell innerText to extract integer value and optional units.
                     match = x[i].innerText.match(/[^\d]*([\d.]+)([%bitpskmg]*)/i);
                     // Set input field to integer value.
                     input.value = parseInt(match[1]);
@@ -143,27 +251,5 @@
         }
     })();
 </script>
-<style>
-    th, td {
-        text-align: center;
-    }
-
-    .tcgui-input-cell {
-        text-align: left;
-    }
-
-    .tcgui-value-cell.modified {
-        background: rgb(121, 168, 255);
-    }
-
-    .button-remove {
-        color: white;
-        background: rgb(202, 60, 60);
-    }
-
-    .pure-button {
-        margin-top: .5em;
-    }
-</style>
 </body>
 </html>

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff