├── .dockerignore ├── .gitattributes ├── .github └── workflows │ └── docker-publish.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── app.py ├── changelog.md ├── demo ├── gif │ ├── add.gif │ ├── drag.gif │ ├── generate.gif │ └── import.gif └── img │ ├── generate.png │ ├── import.png │ ├── port_settings.png │ ├── ports.png │ └── theme_settings.png ├── docker-compose.build.yml ├── docker-compose.yml ├── manage.py ├── migration.py ├── planned_features.md ├── requirements.txt ├── static ├── css │ ├── global │ │ ├── dark.css │ │ ├── light.css │ │ └── styles.css │ └── themes │ │ ├── dark.css │ │ └── light.css ├── favicon.ico ├── favicon.png └── js │ ├── api │ └── ajax.js │ ├── core │ ├── dragAndDrop.js │ ├── import.js │ ├── ipManagement.js │ ├── new.js │ ├── portManagement.js │ └── settings.js │ ├── main.js │ ├── ui │ ├── animations.js │ ├── helpers.js │ ├── loadingAnimation.js │ └── modals.js │ └── utils │ └── dragDropUtils.js ├── templates ├── base.html ├── import.html ├── new.html ├── ports.html └── settings.html └── utils ├── __init__.py ├── database ├── __init__.py ├── db.py ├── port.py └── setting.py └── routes ├── __init__.py ├── imports.py ├── index.py ├── ports.py └── settings.py /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .DS 3 | __pycache__ 4 | demo -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker Build and Push 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | tags: 7 | - 'v*.*.*' 8 | 9 | jobs: 10 | build-and-push: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: Set up QEMU 16 | uses: docker/setup-qemu-action@v2 17 | 18 | - name: Set up Docker Buildx 19 | uses: docker/setup-buildx-action@v2 20 | 21 | - name: Docker meta 22 | id: meta 23 | uses: docker/metadata-action@v3 24 | with: 25 | images: need4swede/portall 26 | tags: | 27 | type=semver,pattern={{version}} 28 | type=semver,pattern={{major}}.{{minor}} 29 | type=raw,value=latest,enable={{is_default_branch}} 30 | 31 | - name: Login to DockerHub 32 | uses: docker/login-action@v1 33 | with: 34 | username: ${{ secrets.DOCKERHUB_USERNAME }} 35 | password: ${{ secrets.DOCKERHUB_TOKEN }} 36 | 37 | - name: Build and push Docker image 38 | uses: docker/build-push-action@v2 39 | with: 40 | context: . 41 | platforms: linux/amd64,linux/arm64 42 | push: true 43 | tags: ${{ steps.meta.outputs.tags }} 44 | labels: ${{ steps.meta.outputs.labels }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | demo/.DS_Store 162 | demo/gif/.DS_Store 163 | .DS_Store 164 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-slim 2 | WORKDIR /app 3 | COPY requirements.txt . 4 | RUN pip install --no-cache-dir -r requirements.txt 5 | COPY . . 6 | ENV FLASK_APP=app.py 7 | ENV FLASK_RUN_HOST=0.0.0.0 8 | EXPOSE 8080 9 | CMD ["python", "app.py"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Need4Swede 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🚢 Portall - Port Management System 2 | 3 | ![Version](https://img.shields.io/badge/version-1.0.8-blue.svg) 4 | ![License](https://img.shields.io/badge/license-MIT-green.svg) 5 | ![Docker](https://img.shields.io/badge/docker-ready-brightgreen.svg) 6 | 7 | Portall provides an intuitive web-interface for generating, tracking, and organizing ports and services across multiple hosts. 8 | 9 | 10 | 11 | ## 🐳 Setup 12 | 13 | ### Docker Run 14 | 15 | ```bash 16 | docker run -p 8080:8080 \ 17 | -e SECRET_KEY=your_secret_key \ 18 | -e PORT=8080 \ 19 | -v ./instance:/app/instance \ 20 | Portall 21 | ``` 22 | 23 | ### Docker Compose 24 | ```yml 25 | version: '3' 26 | services: 27 | portall: 28 | image: need4swede/portall:latest 29 | container_name: portall 30 | ports: 31 | - "8080:8080" 32 | environment: 33 | - SECRET_KEY=your_secret_key 34 | volumes: 35 | - ./instance:/app/instance 36 | ``` 37 | 38 | ## ✨ Core Functionality 39 | 40 | **Easy Port Management** 41 | - Easily add, remove and assign ports to different services and hosts. 42 | 43 | **Port Number Generation** 44 | - Quickly generate unique port numbers to host your applications. 45 | 46 | **Import Tools** 47 | - Import existing configurations by pasting your Caddyfile, Docker-Compose or JSON data. 48 | 49 | **Custom Rules** 50 | - Define your own port ranges and set exclusions for the port generator. 51 | 52 | ## 🎨 UI Goodies 53 | 54 | **Block Level Design** 55 | - Drag and drop elements to easily organize your ports and move applications between hosts. 56 | 57 | **Themes** 58 | - Ships with both Light and Dark modes, with more themes to come. 59 | 60 | **CSS Playground** 61 | - Want to style the UI yourself? You can modify the look and feel via Custom CSS support. 62 | 63 | **Mobile Responsive** 64 | - Manage your ports from anywhere with fully-responsive pages. 65 | 66 | ## 🛠️ Technical Stack 67 | 68 | - 🐍 **Backend**: Flask (Python) 69 | - 💾 **Database**: SQLAlchemy with SQLite 70 | - 🌐 **Frontend**: HTML, CSS, JavaScript 71 | 72 | ## 📸 Screenshots 73 | 74 | ### Port Management 75 | 76 | 77 | 78 | ### Port Generator 79 | 80 | 81 | 82 | ### Import Tool 83 | 84 | 85 | 86 | ### Settings 87 | 88 | 89 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | # app.py 2 | 3 | # Standard Imports 4 | import logging 5 | import os 6 | 7 | # External Imports 8 | from flask import Flask 9 | from flask_migrate import Migrate, upgrade, stamp, current 10 | from sqlalchemy.exc import OperationalError 11 | from alembic.util import CommandError 12 | 13 | # Local Imports 14 | from utils.database import init_db 15 | from utils.routes import routes_bp 16 | 17 | # Setup logging 18 | logging.basicConfig(level=logging.DEBUG) 19 | 20 | def create_app(): 21 | """ 22 | Create and configure the Flask application. 23 | 24 | Returns: 25 | tuple: The configured Flask application instance and SQLAlchemy database instance. 26 | """ 27 | # Create the Flask app 28 | app = Flask(__name__) 29 | 30 | # Environment variables 31 | app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', 'sqlite:///portall.db') 32 | app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False 33 | app.secret_key = os.environ.get('SECRET_KEY', 'M1Hd4l58YKm2Tqci6ZU65sEgWDexjuSfRybf2i4G') 34 | 35 | # Initialize database 36 | db = init_db(app) 37 | 38 | # Initialize Flask-Migrate 39 | migrate = Migrate(app, db) 40 | 41 | # Register the routes blueprint 42 | app.register_blueprint(routes_bp) 43 | 44 | return app, db 45 | 46 | def init_or_migrate_db(app, db): 47 | """ 48 | Initialize a new database or migrate an existing one. 49 | 50 | This function handles four scenarios: 51 | 1. Database does not exist: Creates it and all tables. 52 | 2. Database exists, but no migration folder: Ensures all tables exist. 53 | 3. Database exists, migration folder exists, but no changes: No action needed. 54 | 4. Database exists, migration folder exists, and changes detected: Updates database. 55 | 56 | Args: 57 | app (Flask): The Flask application instance. 58 | db (SQLAlchemy): The SQLAlchemy database instance. 59 | """ 60 | with app.app_context(): 61 | # Check if migrations folder exists 62 | migrations_folder = os.path.join(os.path.dirname(__file__), 'migrations') 63 | migrations_exist = os.path.exists(migrations_folder) 64 | 65 | try: 66 | # Try to access the database 67 | db.engine.connect() 68 | logging.info("Existing database found.") 69 | 70 | if migrations_exist: 71 | logging.info("Migrations folder found. Checking for pending migrations...") 72 | try: 73 | # Get current migration version 74 | current_version = current(directory=migrations_folder) 75 | 76 | # Try to upgrade 77 | upgrade(directory=migrations_folder) 78 | new_version = current(directory=migrations_folder) 79 | 80 | if new_version != current_version: 81 | logging.info("Database updated successfully.") 82 | else: 83 | logging.info("Database is up-to-date. No migration needed.") 84 | except CommandError as e: 85 | if "Target database is not up to date" in str(e): 86 | logging.warning("Database schema has changed. Applying migrations...") 87 | upgrade(directory=migrations_folder) 88 | logging.info("Migrations applied successfully.") 89 | else: 90 | raise 91 | else: 92 | logging.info("No migrations folder found. Ensuring all tables exist...") 93 | db.create_all() 94 | logging.info("Database tables verified/created.") 95 | except OperationalError: 96 | logging.info("No existing database found. Creating new database and tables...") 97 | # If the database doesn't exist, create it and all tables 98 | db.create_all() 99 | if migrations_exist: 100 | # If migrations exist, stamp the database 101 | stamp(directory=migrations_folder) 102 | logging.info("New database created, all tables created, and stamped with latest migration version.") 103 | else: 104 | logging.info("New database and all tables created. No migrations to apply.") 105 | 106 | # Create the app and get the db instance 107 | app, db = create_app() 108 | 109 | # Run application 110 | if __name__ == '__main__': 111 | # Initialize or migrate the database before starting the app 112 | init_or_migrate_db(app, db) 113 | 114 | port = int(os.environ.get('PORT', 8080)) 115 | debug_mode = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true' 116 | 117 | logging.info(f"Starting Portall on port {port} with debug mode: {debug_mode}") 118 | 119 | app.run(debug=debug_mode, host='0.0.0.0', port=port) -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # v1.0.8 2 | ## Changed: 3 | ### Overhauled Docker-Compose Imports 4 | Complete rewrite of how docker-compose data is imported to make the import logic more versititle. 5 | 6 | # v1.0.7 7 | ## Added: 8 | ### Sorting 9 | You can now quickly sort your ports by port number or by protocol type. Manually sorting your ports via drag-and-drop is still supported. 10 | ### Database Migration 11 | Portall will now automatically migrate your database on version changes. 12 | ### Docker-Compose Anchors 13 | Added support for anchors in docker-compose imports. Port descriptions are still pulled from the image name. 14 | ## Changed: 15 | ### Restructured AJAX Calls 16 | Renamed and restructured all AJAX calls. 17 | ### Skip Existing Ports on Import 18 | Imports now skip adding ports that already exists in your database. 19 | ## Fixed: 20 | ### Missing Nicknames 21 | Fixed an issue where nicknames wouldn't get properly parsed when adding or importing ports. 22 | ### Moving Ports Creates Host 23 | Fixed a bug where moving a port from one host to another would register a new IP address. 24 | ### New Ports Missing Order 25 | Fixed a bug where newly added ports wouldn't have their order updated unless explicitly moved. 26 | ### Protocol Detection When Adding Ports 27 | Fixed a bug where port protocols wouldn't get caught for conflicts due to case-sensitivity. 28 | 29 | # v1.0.6 30 | ## Added: 31 | ### Port Protocols 32 | Portall now supports setting different protocols for ports (TCP/UDP). 33 | 34 | You can choose protocols when generating new ports (default is TCP), when creating new ones, and when editing existing ones. Two identical port numbers can both be registered to a single IP if they have different protocols. If you try to add an entry that already has a matching port and protocol, it will trigger the Port Conflict Resolver. 35 | 36 | If you add ports from an import, such as a Caddyfile or Docker-Compose, that doesn't explicitly state what protocols are being used, it will default to TCP. 37 | ### Loading Animation 38 | Certain actions, like port conflict resolutions and moving IP panels, now trigger a loading animation that prevents further action until the changes have registered. 39 | ## Changed: 40 | ### Database 41 | **Breaking Changes!** Database changes required for new port protocol feature. 42 | ### Docker-Compose Imports 43 | Now supports `/tcp` and `/udp` properties to differentiate between the two protocols. 44 | ## Fixed: 45 | ### Settings Reset 46 | Fixed an issue where certain settings would reset if you made changes in the settings. 47 | 48 | # v1.0.5 49 | ## Added: 50 | ### Data Export 51 | You can now export your entries to a JSON file. 52 | ## Changed: 53 | ### JSON Import 54 | Updated the format of JSON imports to match the new export, 55 | ## Fixed: 56 | ### Newly Added Port Order 57 | Fixed an issue where newly added ports would get placed near the beggining of the stack. Now they get appended to the end, 58 | 59 | # v1.0.4 60 | ## Added: 61 | ### Port Conflict Resolution 62 | In the event of moving a port to a different IP panel where the port is already registered, a new conflict resolution modal will present users with three options to choose from: 63 | 64 | - Change the number of the migrating port 65 | - Change the number of the existing port 66 | - Cancel the action 67 | 68 | This will prevent port conflicts when moving between hosts and give users an intuative way to migrate their ports and services over between IP's. 69 | ## Changed: 70 | ### Codebase Cleanup 71 | Refactored files and much of the code to make the applicaiton more modular. 72 | ## Fixed: 73 | ### Port Positioning 74 | Fixed a bug that would reset a port's position within an IP panel. 75 | ### Can't edit last port 76 | Fixed a bug that prevented users from editing the only remaining port in an IP panel. 77 | 78 | # v1.0.3 79 | ## Changed: 80 | ### Unique Port Numbers 81 | Port numbers now have to be unique within an IP panel. You cannot add a service using an already registered port number, nor can you change a port to a number that is already registered. 82 | ## Fixed: 83 | ### Port ID Bug 84 | Fixed an issue where the ID of a port wasn't being read correctly. 85 | 86 |
87 | # v1.0.2 88 | ## Changed: 89 | **Breaking Change!** - Altered database structure to handle new ordering logic. 90 | ## Fixed: 91 | ### Port Order Bug 92 | Fixed an issue where re-arranged ports would not have their order saved. 93 | 94 |
95 | # v1.0.1 96 | ## Added: 97 | ### Changelog section 98 | Track changes between app versions. Found under `Settings > About` 99 | ### Planned Features section 100 | See what is planned for future releases. Found under `Settings > About` 101 | ### linux/arm64 support 102 | Added support for linux/arm64, which was absent in the initial release. 103 | ## Fixed: 104 | ### Docker-Compose import bug 105 | Fixed bug that wouldn't detect ports for certain Docker-Compose imports 106 | 107 |
108 | # v1.0.0 109 | ### Initial Release 110 | Initial public release of Portal -------------------------------------------------------------------------------- /demo/gif/add.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/Portall/90bb4f56580bc6bec26c5bbbe01772be7559716e/demo/gif/add.gif -------------------------------------------------------------------------------- /demo/gif/drag.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/Portall/90bb4f56580bc6bec26c5bbbe01772be7559716e/demo/gif/drag.gif -------------------------------------------------------------------------------- /demo/gif/generate.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/Portall/90bb4f56580bc6bec26c5bbbe01772be7559716e/demo/gif/generate.gif -------------------------------------------------------------------------------- /demo/gif/import.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/Portall/90bb4f56580bc6bec26c5bbbe01772be7559716e/demo/gif/import.gif -------------------------------------------------------------------------------- /demo/img/generate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/Portall/90bb4f56580bc6bec26c5bbbe01772be7559716e/demo/img/generate.png -------------------------------------------------------------------------------- /demo/img/import.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/Portall/90bb4f56580bc6bec26c5bbbe01772be7559716e/demo/img/import.png -------------------------------------------------------------------------------- /demo/img/port_settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/Portall/90bb4f56580bc6bec26c5bbbe01772be7559716e/demo/img/port_settings.png -------------------------------------------------------------------------------- /demo/img/ports.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/Portall/90bb4f56580bc6bec26c5bbbe01772be7559716e/demo/img/ports.png -------------------------------------------------------------------------------- /demo/img/theme_settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/Portall/90bb4f56580bc6bec26c5bbbe01772be7559716e/demo/img/theme_settings.png -------------------------------------------------------------------------------- /docker-compose.build.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | portall: 4 | build: . 5 | container_name: portall 6 | ports: 7 | - "8080:8080" 8 | environment: 9 | - SECRET_KEY=your_secret_key 10 | volumes: 11 | - ./instance:/app/instance -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | portall: 4 | image: need4swede/portall:latest 5 | container_name: portall 6 | ports: 7 | - "8080:8080" 8 | environment: 9 | - SECRET_KEY=your_secret_key 10 | volumes: 11 | - ./instance:/app/instance -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | # manage.py 2 | 3 | # Standard Imports 4 | import os 5 | 6 | # External Imports 7 | from flask.cli import FlaskGroup 8 | 9 | # Local Imports 10 | from app import app, db 11 | from flask_migrate import Migrate 12 | 13 | # Initialize Flask-Migrate 14 | migrate = Migrate(app, db) 15 | 16 | # Create CLI group 17 | cli = FlaskGroup(app) 18 | 19 | @cli.command("run") 20 | def run(): 21 | """Run the Flask development server.""" 22 | port = int(os.environ.get('PORT', 8080)) 23 | debug = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true' 24 | app.run(debug=debug, host='0.0.0.0', port=port) 25 | 26 | if __name__ == '__main__': 27 | cli() -------------------------------------------------------------------------------- /migration.py: -------------------------------------------------------------------------------- 1 | # migration.py 2 | 3 | # Standard Imports 4 | import os 5 | import sys 6 | 7 | # External Imports 8 | from flask_migrate import upgrade, migrate, init, stamp 9 | 10 | # Local Imports 11 | from app import app, db 12 | 13 | def run_migration(): 14 | with app.app_context(): 15 | # Check if the migrations folder exists 16 | if not os.path.exists('migrations'): 17 | # Initialize migrations 18 | init() 19 | 20 | # Create a stamp for the current state of the database 21 | stamp() 22 | 23 | # Get the migration message from user input 24 | message = input("Enter a brief description for this migration: ") 25 | 26 | # Generate the migration script 27 | migrate(message=message) 28 | 29 | # Apply the migration 30 | upgrade() 31 | 32 | if __name__ == '__main__': 33 | run_migration() -------------------------------------------------------------------------------- /planned_features.md: -------------------------------------------------------------------------------- 1 | ## v1.1 2 | ### Docker Support 3 | Connect Portall to Docker and automatically read/update ports 4 | ### Portainer Support 5 | Connect Portall to Portainer to automatically read/update ports -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alembic==1.13.2 2 | blinker==1.8.2 3 | click==8.1.7 4 | colorama==0.4.6 5 | Flask==3.0.3 6 | Flask-Migrate==4.0.7 7 | Flask-Script==2.0.6 8 | Flask-SQLAlchemy==3.1.1 9 | greenlet==3.0.3 10 | importlib_metadata==8.0.0 11 | itsdangerous==2.2.0 12 | Jinja2==3.1.4 13 | Mako==1.3.5 14 | Markdown==3.6 15 | MarkupSafe==2.1.5 16 | PyYAML==6.0.1 17 | SQLAlchemy==2.0.31 18 | typing_extensions==4.12.2 19 | Werkzeug==3.0.3 20 | zipp==3.19.2 21 | -------------------------------------------------------------------------------- /static/css/global/dark.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bg-color: #333333; 3 | --bg-color-secondary: #2c2c2c; 4 | --nav-bg-color: #1c1c1c; 5 | --text-color-primary: #ffffff; 6 | --text-color-secondary: #b0b0b0; 7 | --text-color-placeholder: #b0b0b057; 8 | --accent-color: #4a90e2; 9 | --shadow-color: rgba(0, 0, 0, 0.1); 10 | } -------------------------------------------------------------------------------- /static/css/global/light.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bg-color: #ffffff; 3 | --bg-color-secondary: #f8f9fa; 4 | --text-color-primary: #333333; 5 | --text-color-secondary: #6c757d; 6 | --text-color-placeholder: #00000066; 7 | --accent-color: #4a90e2; 8 | --shadow-color: rgba(0, 0, 0, 0.1); 9 | } -------------------------------------------------------------------------------- /static/css/global/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* Font */ 3 | --font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; 4 | 5 | /* Colors */ 6 | --color-white: #ffffff; 7 | --color-black: #000000; 8 | 9 | /* Gray Scale */ 10 | --color-gray: #969696; 11 | --color-gray-light: #b9b9b9; 12 | --color-gray-lighter: #f8f8f8; 13 | --color-gray-dark: #333333; 14 | --color-gray-darker: #2c2c2c; 15 | --color-gray-very-dark: #2c3e50; 16 | --color-gray-very-dark-2: #34495e; 17 | --color-gray-very-light: #ecf0f1; 18 | --color-gray-medium: #95a5a6; 19 | --color-gray-medium-dark: #555; 20 | 21 | /* Blue */ 22 | --color-blue: #3a80d2; 23 | --color-blue-dark: #2980b9; 24 | --color-blue-light: #3498db; 25 | --color-blue-shadow: #4a90e240; 26 | 27 | /* Other Colors */ 28 | --color-green: #2ecc71; 29 | --color-red: #c82333; 30 | --color-red-light: #dc3545; 31 | --color-orange: #f39c12; 32 | --color-yellow: #f1c40f; 33 | --color-purple: #9b59b6; 34 | --color-purple-light: #c76bec; 35 | --color-purple-dark: #8e44ad; 36 | --color-teal: #1abc9c; 37 | --color-teal-dark: #16a085; 38 | 39 | /* Layout */ 40 | --border-radius: 0.5rem; 41 | --transition-duration: 0.3s; 42 | --box-shadow: 0 0 20px rgba(0, 0, 0, 0.1); 43 | } 44 | 45 | /* General Styles */ 46 | body { 47 | font-family: var(--font-family); 48 | } 49 | 50 | h1 { 51 | font-size: 2.5em; 52 | font-weight: bold; 53 | margin-bottom: 1em; 54 | } 55 | 56 | .container { 57 | max-width: 800px; 58 | } 59 | 60 | /* Navbar Styles */ 61 | .navbar .navbar-brand { 62 | font-weight: bold; 63 | } 64 | 65 | .navbar .nav-link { 66 | transition: color var(--transition-duration) ease; 67 | } 68 | 69 | /* Form Elements */ 70 | .form-control, 71 | .form-select { 72 | border-radius: var(--border-radius); 73 | transition: border-color var(--transition-duration) ease, box-shadow var(--transition-duration) ease; 74 | } 75 | 76 | /* Buttons */ 77 | .btn-primary { 78 | transition: all var(--transition-duration) ease; 79 | } 80 | 81 | #result { 82 | margin-top: 1em; 83 | } 84 | 85 | #data div h3~p { 86 | margin-top: 1em; 87 | font-size: small; 88 | } 89 | 90 | #data div h3 { 91 | margin-top: 1.5em; 92 | } 93 | 94 | /* Modal Styles */ 95 | .modal-content { 96 | border-radius: var(--border-radius); 97 | overflow: hidden; 98 | } 99 | 100 | .modal-footer { 101 | background-color: var(--bg-light-secondary); 102 | justify-content: flex-end; 103 | } 104 | 105 | .modal-dialog { 106 | display: flex; 107 | align-items: center; 108 | min-height: calc(100% - 1rem); 109 | } 110 | 111 | /* Modern select styling */ 112 | .modern-select { 113 | appearance: none; 114 | -webkit-appearance: none; 115 | -moz-appearance: none; 116 | background-color: var(--color-gray-lighter); 117 | border: 1px solid var(--color-gray-light); 118 | border-radius: var(--border-radius); 119 | padding: 0.5rem 2rem 0.5rem 1rem; 120 | font-size: 1rem; 121 | line-height: 1.5; 122 | color: var(--color-gray-dark); 123 | cursor: pointer; 124 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23333' d='M10.293 3.293L6 7.586 1.707 3.293A1 1 0 00.293 4.707l5 5a1 1 0 001.414 0l5-5a1 1 0 10-1.414-1.414z'/%3E%3C/svg%3E"); 125 | background-repeat: no-repeat; 126 | background-position: right 1rem center; 127 | background-size: 0.75rem; 128 | transition: border-color var(--transition-duration) ease, box-shadow var(--transition-duration) ease; 129 | } 130 | 131 | .modern-select:hover { 132 | border-color: var(--color-blue-light); 133 | } 134 | 135 | .modern-select:focus { 136 | outline: none; 137 | border-color: var(--color-blue); 138 | box-shadow: 0 0 0 2px var(--color-blue-shadow); 139 | } 140 | 141 | @media (min-width: 576px) { 142 | .modal-dialog { 143 | min-height: calc(100% - 3.5rem); 144 | } 145 | } 146 | 147 | /* Alert Styles */ 148 | .alert { 149 | border-radius: var(--border-radius); 150 | border: none; 151 | } 152 | 153 | /* Network Switch Styles */ 154 | .network-switch { 155 | background-color: var(--color-gray-very-dark); 156 | border-radius: 10px; 157 | padding: 20px; 158 | margin-bottom: 30px; 159 | box-shadow: var(--box-shadow); 160 | } 161 | 162 | .switch-label { 163 | color: var(--color-gray-very-light); 164 | font-size: 1.2em; 165 | margin-bottom: 15px; 166 | display: flex; 167 | justify-content: flex-start; 168 | align-items: center; 169 | } 170 | 171 | .switch-label a { 172 | color: var(--color-blue-light); 173 | } 174 | 175 | .network-switch .fa-pencil-alt, 176 | .network-switch .fa-pencil { 177 | color: var(--color-gray-light); 178 | } 179 | 180 | .network-switch .fa-pencil-alt:hover, 181 | .network-switch .fa-pencil:hover { 182 | color: var(--color-gray-lighter); 183 | } 184 | 185 | .switch-panel { 186 | display: grid; 187 | grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); 188 | gap: 10px; 189 | background-color: var(--color-gray-very-dark-2); 190 | padding: 15px; 191 | border-radius: 5px; 192 | min-height: 100px; 193 | } 194 | 195 | .switch-panel.drag-over { 196 | background-color: var(--color-blue-shadow); 197 | } 198 | 199 | /* Port Styles */ 200 | .port-slot { 201 | background-color: var(--color-gray-very-dark); 202 | border-radius: 5px; 203 | padding: 5px; 204 | transition: transform 0.2s ease; 205 | } 206 | 207 | .port-slot.dragging { 208 | opacity: 0.8; 209 | transform: scale(1.05); 210 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); 211 | } 212 | 213 | .port { 214 | background-color: var(--color-gray-medium); 215 | border-radius: 3px; 216 | padding: 5px; 217 | text-align: center; 218 | transition: all var(--transition-duration) ease; 219 | position: relative; 220 | cursor: pointer; 221 | } 222 | 223 | .port.active { 224 | background-color: var(--color-green); 225 | } 226 | 227 | .port-number { 228 | display: block; 229 | font-weight: bold; 230 | color: var(--color-gray-very-dark); 231 | } 232 | 233 | .port-description { 234 | display: block; 235 | font-size: 0.8em; 236 | color: var(--color-gray-very-dark-2); 237 | white-space: nowrap; 238 | overflow: hidden; 239 | text-overflow: ellipsis; 240 | } 241 | 242 | .port-protocol { 243 | color: var(--color-teal); 244 | text-align: center; 245 | font-weight: bold; 246 | margin: 0; 247 | } 248 | 249 | .port-tooltip { 250 | visibility: hidden; 251 | width: 200px; 252 | background-color: var(--color-gray-medium-dark); 253 | color: var(--color-white); 254 | text-align: center; 255 | border-radius: 6px; 256 | padding: 5px; 257 | position: absolute; 258 | z-index: 1; 259 | bottom: 125%; 260 | left: 50%; 261 | margin-left: -100px; 262 | opacity: 0; 263 | transition: opacity var(--transition-duration); 264 | } 265 | 266 | .port:hover .port-tooltip { 267 | visibility: visible; 268 | opacity: 1; 269 | transform: scale(1.05); 270 | box-shadow: 0 0 10px var(--color-gray-darker); 271 | } 272 | 273 | .port-tooltip::after { 274 | content: ""; 275 | position: absolute; 276 | top: 100%; 277 | left: 50%; 278 | margin-left: -5px; 279 | border-width: 5px; 280 | border-style: solid; 281 | border-color: var(--color-gray-medium-dark) transparent transparent transparent; 282 | } 283 | 284 | .add-port-slot { 285 | display: flex; 286 | justify-content: center; 287 | align-items: center; 288 | } 289 | 290 | .add-port { 291 | background-color: var(--color-blue-light); 292 | border-radius: 3px; 293 | padding: 5px; 294 | text-align: center; 295 | transition: all var(--transition-duration) ease; 296 | cursor: pointer; 297 | width: 100%; 298 | height: 100%; 299 | display: flex; 300 | justify-content: center; 301 | align-items: center; 302 | } 303 | 304 | .add-port:hover { 305 | background-color: var(--color-blue); 306 | } 307 | 308 | .add-port-icon { 309 | font-size: 24px; 310 | color: var(--color-white); 311 | font-weight: bold; 312 | } 313 | 314 | /* Miscellaneous */ 315 | .edit-ip { 316 | color: var(--color-white); 317 | border: none; 318 | border-radius: 50%; 319 | width: 30px; 320 | height: 30px; 321 | display: flex; 322 | align-items: center; 323 | justify-content: center; 324 | text-decoration: none; 325 | transition: background-color var(--transition-duration) ease; 326 | } 327 | 328 | .switch-label .edit-ip { 329 | margin-left: 10px; 330 | } 331 | 332 | #settingsTabs { 333 | margin-bottom: 2rem; 334 | } 335 | 336 | .tab-content>.tab-pane { 337 | padding: 1rem; 338 | } 339 | 340 | .tab-content h2 { 341 | margin-bottom: 1rem; 342 | } 343 | 344 | #delete-port { 345 | margin-right: auto; 346 | } 347 | 348 | #delete-port:disabled { 349 | opacity: 0.5; 350 | cursor: not-allowed; 351 | } 352 | 353 | .sort-buttons { 354 | display: inline-flex; 355 | margin-left: auto; 356 | margin-right: 10px; 357 | } 358 | 359 | .sort-btn { 360 | background: none; 361 | border: none; 362 | cursor: pointer; 363 | padding: 2px 5px; 364 | font-size: 0.8em; 365 | color: var(--color-gray-light); 366 | } 367 | 368 | .sort-btn:hover { 369 | color: var(--color-gray-very-light); 370 | } 371 | 372 | .sort-btn i { 373 | margin-right: 2px; 374 | } 375 | 376 | /* CodeMirror Styles */ 377 | .CodeMirror { 378 | height: 300px; 379 | border: 1px solid var(--color-gray-lighter); 380 | font-size: 14px; 381 | } 382 | 383 | .CodeMirror-gutters { 384 | width: 40px; 385 | } 386 | 387 | .CodeMirror-linenumbers { 388 | width: 30px; 389 | } 390 | 391 | .CodeMirror-sizer { 392 | margin-left: 40px !important; 393 | } 394 | 395 | .CodeMirror-scroll { 396 | overflow-x: auto; 397 | overflow-y: hidden; 398 | } 399 | 400 | /* About section styles */ 401 | #about { 402 | padding: 20px; 403 | border-radius: 10px; 404 | } 405 | 406 | .section-title { 407 | font-size: 2.5em; 408 | margin-bottom: 30px; 409 | text-align: center; 410 | font-weight: 700; 411 | letter-spacing: -1px; 412 | } 413 | 414 | .info-card { 415 | border-radius: 15px; 416 | box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); 417 | overflow: hidden; 418 | transition: all 0.3s ease; 419 | } 420 | 421 | .info-card:hover { 422 | transform: translateY(-5px); 423 | box-shadow: 0 15px 40px rgba(0, 0, 0, 0.15); 424 | } 425 | 426 | .card-content { 427 | padding: 25px; 428 | } 429 | 430 | .card-title { 431 | font-size: 1.8em; 432 | margin-bottom: 20px; 433 | font-weight: 600; 434 | position: relative; 435 | padding-bottom: 10px; 436 | } 437 | 438 | .card-title::after { 439 | content: ''; 440 | position: absolute; 441 | left: 0; 442 | bottom: 0; 443 | width: 50px; 444 | height: 3px; 445 | transition: width 0.3s ease; 446 | } 447 | 448 | .info-card:hover .card-title::after { 449 | width: 100px; 450 | } 451 | 452 | .info-label { 453 | font-weight: 600; 454 | margin-right: 10px; 455 | } 456 | 457 | .markdown-content { 458 | font-size: 1em; 459 | line-height: 1.8; 460 | } 461 | 462 | .markdown-content h1, 463 | .markdown-content h2, 464 | .markdown-content h3 { 465 | margin-top: 25px; 466 | margin-bottom: 15px; 467 | font-weight: 600; 468 | } 469 | 470 | .markdown-content h1 { 471 | font-size: 1.8em; 472 | } 473 | 474 | .markdown-content h1 a { 475 | text-decoration: none; 476 | border-bottom: none; 477 | } 478 | 479 | .markdown-content h2 { 480 | font-size: 1.5em; 481 | } 482 | 483 | .markdown-content h3 { 484 | font-size: 1.3em; 485 | } 486 | 487 | .markdown-content ul { 488 | padding-left: 20px; 489 | list-style-type: none; 490 | } 491 | 492 | .markdown-content li { 493 | margin-bottom: 10px; 494 | position: relative; 495 | padding-left: 25px; 496 | } 497 | 498 | .markdown-content li::before { 499 | content: '•'; 500 | font-size: 1.5em; 501 | position: absolute; 502 | left: 0; 503 | top: -5px; 504 | } 505 | 506 | .markdown-content code { 507 | padding: 2px 6px; 508 | border-radius: 4px; 509 | font-family: 'Courier New', Courier, monospace; 510 | font-size: 0.9em; 511 | font-weight: bold; 512 | } 513 | 514 | .markdown-content a { 515 | text-decoration: none; 516 | transition: all 0.3s ease; 517 | } 518 | 519 | .show-more { 520 | display: inline-block; 521 | margin-top: 10px; 522 | text-decoration: none; 523 | font-weight: 600; 524 | transition: all 0.3s ease; 525 | } 526 | 527 | .show-more:hover { 528 | text-decoration: underline; 529 | } 530 | 531 | @keyframes wave { 532 | 0% { 533 | transform: translateY(0); 534 | } 535 | 536 | 50% { 537 | transform: translateY(-10px); 538 | } 539 | 540 | 100% { 541 | transform: translateY(0); 542 | } 543 | } 544 | 545 | #loading-overlay .fa-anchor { 546 | animation: wave 2s ease-in-out infinite; 547 | } 548 | 549 | /* Responsive adjustments */ 550 | @media (max-width: 768px) { 551 | #about { 552 | padding: 15px; 553 | } 554 | 555 | .section-title { 556 | font-size: 2em; 557 | } 558 | 559 | .card-title { 560 | font-size: 1.5em; 561 | } 562 | 563 | .markdown-content { 564 | font-size: 0.9em; 565 | } 566 | } -------------------------------------------------------------------------------- /static/css/themes/dark.css: -------------------------------------------------------------------------------- 1 | @import '../global/styles.css'; 2 | @import '../global/dark.css'; 3 | 4 | body { 5 | background-color: var(--bg-color); 6 | color: var(--text-color-primary); 7 | } 8 | 9 | /* Navigation */ 10 | .navbar { 11 | background-color: var(--nav-bg-color); 12 | box-shadow: 0 2px 4px var(--shadow-color); 13 | } 14 | 15 | .navbar .navbar-brand { 16 | color: var(--accent-color); 17 | } 18 | 19 | .navbar .nav-link { 20 | color: var(--text-color-secondary); 21 | } 22 | 23 | .navbar .nav-link:hover { 24 | color: var(--accent-color); 25 | } 26 | 27 | .navbar-toggler-icon { 28 | filter: brightness(1000%); 29 | } 30 | 31 | .navbar-toggler { 32 | border-color: var(--text-color-secondary); 33 | } 34 | 35 | .navbar-toggler:focus { 36 | box-shadow: none; 37 | } 38 | 39 | /* Buttons */ 40 | .btn-primary { 41 | background-color: var(--accent-color); 42 | border-color: var(--accent-color); 43 | } 44 | 45 | .btn-primary:hover { 46 | background-color: var(--color-blue); 47 | border-color: var(--color-blue); 48 | } 49 | 50 | .btn-danger { 51 | background-color: var(--color-red-light); 52 | border-color: var(--color-red-light); 53 | } 54 | 55 | .btn-danger:hover { 56 | background-color: var(--color-red); 57 | border-color: var(--color-red); 58 | } 59 | 60 | .btn-secondary { 61 | background-color: var(--color-gray); 62 | border-color: var(--color-gray); 63 | } 64 | 65 | .btn-secondary:hover { 66 | background-color: var(--color-gray-dark); 67 | border-color: var(--color-gray-darker); 68 | } 69 | 70 | /* Forms */ 71 | .form-control, 72 | .form-select { 73 | background-color: var(--bg-color-secondary); 74 | border: 1px solid var(--text-color-secondary); 75 | color: var(--text-color-primary); 76 | } 77 | 78 | .form-control::placeholder { 79 | color: var(--text-color-placeholder); 80 | } 81 | 82 | .form-control:focus, 83 | .form-select:focus { 84 | border-color: var(--accent-color); 85 | box-shadow: 0 0 0 0.2rem var(--color-blue-shadow); 86 | background-color: var(--bg-color-secondary); 87 | color: var(--text-color-primary); 88 | } 89 | 90 | .form-label { 91 | color: var(--text-color-secondary); 92 | } 93 | 94 | /* Modal */ 95 | .modal-content { 96 | background-color: var(--bg-color); 97 | color: var(--text-color-primary); 98 | } 99 | 100 | .modal-header { 101 | background-color: var(--nav-bg-color); 102 | color: var(--color-white); 103 | border-bottom: 1px solid var(--text-color-secondary); 104 | } 105 | 106 | .modal-footer { 107 | background-color: var(--bg-color-secondary); 108 | border-top: 1px solid var(--text-color-secondary); 109 | } 110 | 111 | /* Alerts */ 112 | .alert { 113 | box-shadow: 0 2px 4px var(--shadow-color); 114 | } 115 | 116 | /* Typography */ 117 | h1, 118 | h2, 119 | h3, 120 | h4, 121 | h5, 122 | h6 { 123 | color: var(--text-color-primary); 124 | } 125 | 126 | hr { 127 | border-top: 1px solid var(--text-color-secondary); 128 | } 129 | 130 | /* Tables */ 131 | .table { 132 | color: var(--text-color-primary); 133 | } 134 | 135 | .table-striped tbody tr:nth-of-type(odd) { 136 | background-color: var(--bg-color-secondary); 137 | } 138 | 139 | .table thead th { 140 | border-bottom: 2px solid var(--text-color-secondary); 141 | } 142 | 143 | .table td, 144 | .table th { 145 | border-top: 1px solid var(--text-color-secondary); 146 | } 147 | 148 | /* Cards */ 149 | .card { 150 | background-color: var(--bg-color-secondary); 151 | border: 1px solid var(--text-color-secondary); 152 | } 153 | 154 | .card-header { 155 | background-color: var(--nav-bg-color); 156 | border-bottom: 1px solid var(--text-color-secondary); 157 | } 158 | 159 | .card-footer { 160 | background-color: var(--nav-bg-color); 161 | border-top: 1px solid var(--text-color-secondary); 162 | } 163 | 164 | /* List groups */ 165 | .list-group-item { 166 | background-color: var(--bg-color-secondary); 167 | border-color: var(--text-color-secondary); 168 | color: var(--text-color-primary); 169 | } 170 | 171 | /* Pagination */ 172 | .page-item.disabled .page-link { 173 | background-color: var(--bg-color-secondary); 174 | border-color: var(--text-color-secondary); 175 | } 176 | 177 | .page-link { 178 | background-color: var(--bg-color); 179 | border-color: var(--text-color-secondary); 180 | color: var(--accent-color); 181 | } 182 | 183 | .page-item.active .page-link { 184 | background-color: var(--accent-color); 185 | border-color: var(--accent-color); 186 | } 187 | 188 | /* Custom scrollbar */ 189 | ::-webkit-scrollbar { 190 | width: 12px; 191 | } 192 | 193 | ::-webkit-scrollbar-track { 194 | background: var(--bg-color-secondary); 195 | } 196 | 197 | ::-webkit-scrollbar-thumb { 198 | background-color: var(--text-color-secondary); 199 | border-radius: 6px; 200 | border: 3px solid var(--bg-color-secondary); 201 | } 202 | 203 | /* Links */ 204 | a { 205 | color: var(--accent-color); 206 | } 207 | 208 | a:hover { 209 | color: var(--color-blue-dark); 210 | } 211 | 212 | /* Code blocks */ 213 | pre, 214 | code { 215 | background-color: var(--bg-color-secondary); 216 | color: var(--text-color-primary); 217 | border: 1px solid var(--text-color-secondary); 218 | border-radius: 4px; 219 | } 220 | 221 | /* Tooltips */ 222 | .tooltip .tooltip-inner { 223 | background-color: var(--bg-color-secondary); 224 | color: var(--text-color-primary); 225 | } 226 | 227 | .tooltip .arrow::before { 228 | border-top-color: var(--bg-color-secondary); 229 | } 230 | 231 | /* Badges */ 232 | .badge { 233 | background-color: var(--accent-color); 234 | color: var(--text-color-primary); 235 | } 236 | 237 | /* Progress bars */ 238 | .progress { 239 | background-color: var(--bg-color-secondary); 240 | } 241 | 242 | .progress-bar { 243 | background-color: var(--accent-color); 244 | } 245 | 246 | #settingsTabs .nav-item .active { 247 | background-color: var(--color-gray-darker); 248 | border: none; 249 | } 250 | 251 | #settingsTabs .nav-link { 252 | color: var(--text-color-primary); 253 | transition: color var(--transition-duration) ease; 254 | } 255 | 256 | #settingsTabs .nav-link:hover { 257 | color: var(--color-blue-dark); 258 | border: none; 259 | } 260 | 261 | #settingsTabs .nav-link.active { 262 | color: var(--text-color-primary); 263 | } 264 | 265 | .tab-content>.tab-pane { 266 | background-color: var(--color-gray-darker); 267 | border-radius: 0 0 var(--border-radius) var(--border-radius); 268 | } 269 | 270 | .tab-content h2 { 271 | color: var(--text-color-primary); 272 | } 273 | 274 | .cm-s-monokai.CodeMirror { 275 | background-color: var(--bg-color-secondary); 276 | color: var(--text-color-primary); 277 | border: 1px solid var(--text-color-secondary); 278 | } 279 | 280 | .cm-s-monokai .CodeMirror-gutters { 281 | background-color: var(--bg-color-secondary); 282 | } 283 | 284 | /* About section styles */ 285 | #about { 286 | background-color: var(--bg-color); 287 | } 288 | 289 | .section-title { 290 | color: var(--color-blue-dark); 291 | } 292 | 293 | .info-card { 294 | background-color: var(--bg-color-secondary); 295 | } 296 | 297 | .card-title { 298 | color: var(--color-gray-lighter); 299 | } 300 | 301 | .card-title::after { 302 | background-color: var(--color-purple-light); 303 | } 304 | 305 | .info-label { 306 | color: var(--color-gray-lighter); 307 | } 308 | 309 | .markdown-content { 310 | color: var(--text-color-primary); 311 | } 312 | 313 | .markdown-content h1 { 314 | color: var(--color-blue-dark); 315 | } 316 | 317 | .markdown-content h2 { 318 | color: var(--color-teal-dark); 319 | } 320 | 321 | .markdown-content h3 { 322 | color: var(--color-purple-light); 323 | } 324 | 325 | .markdown-content li::before { 326 | color: var(--color-blue-light); 327 | } 328 | 329 | .markdown-content code { 330 | background-color: var(--color-black); 331 | color: var(--color-purple-light); 332 | } 333 | 334 | .markdown-content a { 335 | color: var(--color-blue); 336 | border-bottom: 1px solid var(--color-blue-light); 337 | } 338 | 339 | .markdown-content a:hover { 340 | color: var(--color-blue-dark); 341 | border-bottom-color: var(--color-blue-dark); 342 | } 343 | 344 | .markdown-content h3 a { 345 | color: var(--color-purple-light); 346 | border-bottom: 1px solid var(--color-blue-dark); 347 | } 348 | 349 | .show-more { 350 | color: var(--color-blue); 351 | } 352 | 353 | .show-more:hover { 354 | color: var(--color-blue-dark); 355 | } -------------------------------------------------------------------------------- /static/css/themes/light.css: -------------------------------------------------------------------------------- 1 | @import '../global/styles.css'; 2 | @import '../global/light.css'; 3 | 4 | body { 5 | background-color: var(--bg-color); 6 | color: var(--text-color-primary); 7 | } 8 | 9 | /* Navigation */ 10 | .navbar { 11 | background-color: var(--bg-color); 12 | box-shadow: 0 2px 4px var(--shadow-color); 13 | } 14 | 15 | .navbar .navbar-brand { 16 | color: var(--accent-color); 17 | } 18 | 19 | .navbar .nav-link { 20 | color: var(--text-color-secondary); 21 | } 22 | 23 | .navbar .nav-link:hover { 24 | color: var(--accent-color); 25 | } 26 | 27 | /* Buttons */ 28 | .btn-primary { 29 | background-color: var(--accent-color); 30 | border-color: var(--accent-color); 31 | } 32 | 33 | .btn-primary:hover { 34 | background-color: var(--color-blue); 35 | border-color: var(--color-blue); 36 | } 37 | 38 | .btn-danger { 39 | background-color: var(--color-red-light); 40 | border-color: var(--color-red-light); 41 | } 42 | 43 | .btn-danger:hover { 44 | background-color: var(--color-red); 45 | border-color: var(--color-red); 46 | } 47 | 48 | .btn-secondary { 49 | background-color: var(--color-gray); 50 | border-color: var(--color-gray); 51 | } 52 | 53 | .btn-secondary:hover { 54 | background-color: var(--color-gray-dark); 55 | border-color: var(--color-gray-darker); 56 | } 57 | 58 | /* Forms */ 59 | .form-control, 60 | .form-select { 61 | border: 1px solid var(--text-color-secondary); 62 | background-color: var(--bg-color); 63 | color: var(--text-color-primary); 64 | } 65 | 66 | .form-control::placeholder { 67 | color: var(--text-color-placeholder); 68 | } 69 | 70 | .form-control:focus, 71 | .form-select:focus { 72 | border-color: var(--accent-color); 73 | box-shadow: 0 0 0 0.2rem var(--color-blue-shadow); 74 | } 75 | 76 | .form-label { 77 | color: var(--text-color-primary); 78 | } 79 | 80 | /* Modal */ 81 | .modal-content { 82 | background-color: var(--bg-color); 83 | color: var(--text-color-primary); 84 | } 85 | 86 | .modal-header { 87 | background-color: var(--accent-color); 88 | color: var(--color-white); 89 | border-bottom: 1px solid var(--text-color-secondary); 90 | } 91 | 92 | .modal-title { 93 | color: var(--color-white); 94 | } 95 | 96 | .modal-footer { 97 | background-color: var(--bg-color-secondary); 98 | border-top: 1px solid var(--text-color-secondary); 99 | } 100 | 101 | /* Alerts */ 102 | .alert { 103 | box-shadow: 0 2px 4px var(--shadow-color); 104 | } 105 | 106 | /* Typography */ 107 | h1, 108 | h2, 109 | h3, 110 | h4, 111 | h5, 112 | h6 { 113 | color: var(--text-color-primary); 114 | } 115 | 116 | hr { 117 | border-top: 1px solid var(--text-color-secondary); 118 | } 119 | 120 | /* Tables */ 121 | .table { 122 | color: var(--text-color-primary); 123 | } 124 | 125 | .table-striped tbody tr:nth-of-type(odd) { 126 | background-color: var(--bg-color-secondary); 127 | } 128 | 129 | .table thead th { 130 | border-bottom: 2px solid var(--text-color-secondary); 131 | } 132 | 133 | .table td, 134 | .table th { 135 | border-top: 1px solid var(--text-color-secondary); 136 | } 137 | 138 | /* Cards */ 139 | .card { 140 | background-color: var(--bg-color); 141 | border: 1px solid var(--text-color-secondary); 142 | } 143 | 144 | .card-header { 145 | background-color: var(--bg-color-secondary); 146 | border-bottom: 1px solid var(--text-color-secondary); 147 | } 148 | 149 | .card-footer { 150 | background-color: var(--bg-color-secondary); 151 | border-top: 1px solid var(--text-color-secondary); 152 | } 153 | 154 | /* List groups */ 155 | .list-group-item { 156 | background-color: var(--bg-color); 157 | border-color: var(--text-color-secondary); 158 | color: var(--text-color-primary); 159 | } 160 | 161 | /* Pagination */ 162 | .page-item.disabled .page-link { 163 | background-color: var(--bg-color-secondary); 164 | border-color: var(--text-color-secondary); 165 | } 166 | 167 | .page-link { 168 | background-color: var(--bg-color); 169 | border-color: var(--text-color-secondary); 170 | color: var(--accent-color); 171 | } 172 | 173 | .page-item.active .page-link { 174 | background-color: var(--accent-color); 175 | border-color: var(--accent-color); 176 | } 177 | 178 | /* Custom scrollbar */ 179 | ::-webkit-scrollbar { 180 | width: 12px; 181 | } 182 | 183 | ::-webkit-scrollbar-track { 184 | background: var(--bg-color-secondary); 185 | } 186 | 187 | ::-webkit-scrollbar-thumb { 188 | background-color: var(--text-color-secondary); 189 | border-radius: 6px; 190 | border: 3px solid var(--bg-color-secondary); 191 | } 192 | 193 | /* Links */ 194 | a { 195 | color: var(--accent-color); 196 | } 197 | 198 | a:hover { 199 | color: var(--color-blue-dark); 200 | } 201 | 202 | /* Code blocks */ 203 | pre, 204 | code { 205 | background-color: var(--bg-color-secondary); 206 | color: var(--text-color-primary); 207 | border: 1px solid var(--text-color-secondary); 208 | border-radius: 4px; 209 | } 210 | 211 | /* Tooltips */ 212 | .tooltip .tooltip-inner { 213 | background-color: var(--bg-color-secondary); 214 | color: var(--text-color-primary); 215 | } 216 | 217 | .tooltip .arrow::before { 218 | border-top-color: var(--bg-color-secondary); 219 | } 220 | 221 | /* Badges */ 222 | .badge { 223 | background-color: var(--accent-color); 224 | color: var(--color-white); 225 | } 226 | 227 | /* Progress bars */ 228 | .progress { 229 | background-color: var(--bg-color-secondary); 230 | } 231 | 232 | .progress-bar { 233 | background-color: var(--accent-color); 234 | } 235 | 236 | #settingsTabs .nav-link { 237 | color: var(--color-gray-dark); 238 | transition: color var(--transition-duration) ease; 239 | } 240 | 241 | #settingsTabs .nav-link:hover, 242 | #settingsTabs .nav-link.active { 243 | color: var(--color-blue); 244 | } 245 | 246 | .tab-content>.tab-pane { 247 | background-color: var(--color-gray-lighter); 248 | border-radius: 0 0 var(--border-radius) var(--border-radius); 249 | } 250 | 251 | .tab-content h2 { 252 | color: var(--color-gray-dark); 253 | } 254 | 255 | .cm-s-monokai.CodeMirror { 256 | background-color: var(--bg-color-secondary); 257 | border: 1px solid var(--text-color-secondary); 258 | } 259 | 260 | .cm-s-monokai .CodeMirror-gutters { 261 | background-color: var(--bg-color-secondary); 262 | } 263 | 264 | .cm-s-monokai.CodeMirror, 265 | .CodeMirror.cm-s-monokai.CodeMirror-wrap.CodeMirror-focused { 266 | color: var(--color-black); 267 | } 268 | 269 | /* About section styles */ 270 | #about { 271 | background-color: var(--color-gray-lighter); 272 | } 273 | 274 | .section-title { 275 | color: var(--color-blue-dark); 276 | } 277 | 278 | .info-card { 279 | background-color: var(--color-white); 280 | } 281 | 282 | .card-title { 283 | color: var(--color-blue); 284 | } 285 | 286 | .card-title::after { 287 | background-color: var(--color-blue-light); 288 | } 289 | 290 | .info-label { 291 | color: var(--color-gray-dark); 292 | } 293 | 294 | .markdown-content { 295 | color: var(--color-gray-dark); 296 | } 297 | 298 | .markdown-content h1 { 299 | color: var(--color-blue-dark); 300 | } 301 | 302 | .markdown-content h2 { 303 | color: var(--color-teal-dark); 304 | } 305 | 306 | .markdown-content h3 { 307 | color: var(--color-purple-dark); 308 | } 309 | 310 | .markdown-content li::before { 311 | color: var(--color-blue-light); 312 | } 313 | 314 | .markdown-content code { 315 | background-color: var(--color-gray-lighter); 316 | color: var(--color-purple-dark); 317 | } 318 | 319 | .markdown-content a { 320 | color: var(--color-blue); 321 | border-bottom: 1px solid var(--color-blue-light); 322 | } 323 | 324 | .markdown-content a:hover { 325 | color: var(--color-blue-dark); 326 | border-bottom-color: var(--color-blue-dark); 327 | } 328 | 329 | .markdown-content h3 a { 330 | color: var(--color-purple-dark); 331 | border-bottom: 1px solid var(--color-purple-light); 332 | } 333 | 334 | .show-more { 335 | color: var(--color-blue); 336 | } 337 | 338 | .show-more:hover { 339 | color: var(--color-blue-dark); 340 | } -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/Portall/90bb4f56580bc6bec26c5bbbe01772be7559716e/static/favicon.ico -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/Portall/90bb4f56580bc6bec26c5bbbe01772be7559716e/static/favicon.png -------------------------------------------------------------------------------- /static/js/api/ajax.js: -------------------------------------------------------------------------------- 1 | // js/api/ajax.js 2 | 3 | import { showNotification } from '../ui/helpers.js'; 4 | import { cancelDrop } from '../utils/dragDropUtils.js'; 5 | 6 | /** 7 | * Move a port from one IP address to another. 8 | * Sends an AJAX request to move the port and updates the port order. 9 | * 10 | * @param {number} portNumber - The port number being moved 11 | * @param {string} sourceIp - The source IP address 12 | * @param {string} targetIp - The target IP address 13 | * @param {string} protocol - The protocol of the port (TCP or UDP) 14 | * @param {function} successCallback - Function to call on successful move 15 | * @param {function} errorCallback - Function to call on move error 16 | */ 17 | export function movePort(portNumber, sourceIp, targetIp, protocol, successCallback, errorCallback) { 18 | console.log(`Attempting to move port: ${portNumber} (${protocol}) from ${sourceIp} to ${targetIp}`); 19 | $.ajax({ 20 | url: '/move_port', 21 | method: 'POST', 22 | data: { 23 | port_number: portNumber, 24 | source_ip: sourceIp, 25 | target_ip: targetIp, 26 | protocol: protocol.toUpperCase() // Ensure protocol is uppercase 27 | }, 28 | success: function (response) { 29 | console.log('Server response:', response); 30 | if (response.success) { 31 | if (typeof successCallback === 'function') { 32 | successCallback(response.port); // Pass the updated port data 33 | } 34 | } else { 35 | showNotification('Error moving port: ' + response.message, 'error'); 36 | if (typeof errorCallback === 'function') { 37 | errorCallback(); 38 | } 39 | } 40 | }, 41 | error: function (xhr, status, error) { 42 | console.log('Error response:', xhr.responseText); 43 | showNotification('Error moving port: ' + error, 'error'); 44 | if (typeof errorCallback === 'function') { 45 | errorCallback(); 46 | } 47 | } 48 | }); 49 | } 50 | 51 | /** 52 | * Edit an IP address. 53 | * Sends an AJAX request to update the IP address and updates the DOM. 54 | * 55 | * @param {Object} formData - The form data containing the IP address information 56 | */ 57 | export function editIp(formData) { 58 | $.ajax({ 59 | url: '/edit_ip', 60 | method: 'POST', 61 | data: formData, 62 | success: function (response) { 63 | if (response.success) { 64 | showNotification('IP updated successfully', 'success'); 65 | // Update the DOM dynamically 66 | const oldIp = $('#old-ip').val(); 67 | const newIp = $('#new-ip').val(); 68 | const nickname = $('#new-nickname').val(); 69 | const editIpElement = $(`.edit-ip[data-ip="${oldIp}"]`); 70 | 71 | // Update the data attributes of the edit button 72 | editIpElement.data('ip', newIp).data('nickname', nickname); 73 | editIpElement.attr('data-ip', newIp).attr('data-nickname', nickname); 74 | 75 | // Update the IP label 76 | const switchLabel = editIpElement.closest('.switch-label'); 77 | switchLabel.contents().first().replaceWith(newIp + (nickname ? ' (' + nickname + ')' : '')); 78 | } else { 79 | showNotification('Error updating IP: ' + response.message, 'error'); 80 | } 81 | $('#editIpModal').modal('hide'); 82 | }, 83 | error: function (xhr, status, error) { 84 | showNotification('Error updating IP: ' + error, 'error'); 85 | $('#editIpModal').modal('hide'); 86 | } 87 | }); 88 | } 89 | 90 | /** 91 | * Delete an IP address. 92 | * Sends an AJAX request to delete the IP address and removes it from the DOM. 93 | * 94 | * @param {string} ip - The IP address to delete 95 | */ 96 | export function deleteIp(ip) { 97 | $.ajax({ 98 | url: '/delete_ip', 99 | method: 'POST', 100 | data: { ip: ip }, 101 | success: function (response) { 102 | if (response.success) { 103 | showNotification('IP and all assigned ports deleted successfully', 'success'); 104 | // Remove the IP panel from the DOM 105 | $(`.network-switch:has(.switch-label:contains("${ip}"))`).remove(); 106 | } else { 107 | showNotification('Error deleting IP: ' + response.message, 'error'); 108 | } 109 | $('#deleteIpModal').modal('hide'); 110 | }, 111 | error: function (xhr, status, error) { 112 | showNotification('Error deleting IP: ' + error, 'error'); 113 | $('#deleteIpModal').modal('hide'); 114 | } 115 | }); 116 | } 117 | 118 | /** 119 | * Edit a port's details. 120 | * Sends an AJAX request to update the port information and updates the DOM. 121 | * 122 | * @param {Object} formData - The form data containing the port information 123 | */ 124 | export function editPort(formData) { 125 | $.ajax({ 126 | url: '/edit_port', 127 | method: 'POST', 128 | data: formData, 129 | success: function (response) { 130 | if (response.success) { 131 | showNotification('Port updated successfully', 'success'); 132 | // Update the DOM dynamically 133 | const ip = $('#edit-port-ip').val(); 134 | const oldPortNumber = $('#old-port-number').val(); 135 | const newPortNumber = $('#new-port-number').val(); 136 | const description = $('#port-description').val(); 137 | const portElement = $(`.port[data-ip="${ip}"][data-port="${oldPortNumber}"]`); 138 | portElement.data('port', newPortNumber).data('description', description); 139 | portElement.attr('data-port', newPortNumber).attr('data-description', description); 140 | portElement.find('.port-number').text(newPortNumber); 141 | portElement.find('.port-description').text(description); 142 | 143 | // Refresh the page to ensure all data is correctly displayed 144 | location.reload(); 145 | } else { 146 | showNotification('Error updating port: ' + response.message, 'error'); 147 | } 148 | $('#editPortModal').modal('hide'); 149 | }, 150 | error: function (xhr, status, error) { 151 | showNotification('Error updating port: ' + error, 'error'); 152 | $('#editPortModal').modal('hide'); 153 | } 154 | }); 155 | } 156 | 157 | /** 158 | * Add a new port. 159 | * Sends an AJAX request to add a port and reloads the page on success. 160 | * 161 | * @param {Object} formData - The form data containing the new port information 162 | */ 163 | export function addPort(formData) { 164 | $.ajax({ 165 | url: '/add_port', 166 | method: 'POST', 167 | data: formData, 168 | success: function (response) { 169 | if (response.success) { 170 | showNotification('Port added successfully', 'success'); 171 | location.reload(); 172 | } else { 173 | showNotification('Error adding port: ' + response.message, 'error'); 174 | } 175 | $('#addPortModal').modal('hide'); 176 | }, 177 | error: function (xhr, status, error) { 178 | showNotification('Error adding port: ' + error, 'error'); 179 | $('#addPortModal').modal('hide'); 180 | } 181 | }); 182 | } 183 | 184 | /** 185 | * Generates a new random port for an IP. 186 | * Sends an AJAX request to generate a port. 187 | * 188 | * @param {Object} formData - The form data containing the new port information 189 | */ 190 | export function generatePort(formData) { 191 | // Send AJAX request to generate port 192 | $.ajax({ 193 | url: '/generate_port', 194 | method: 'POST', 195 | data: formData, 196 | success: function (response) { 197 | console.log('Port generated successfully:', response); 198 | // Display the generated URL with a copy button 199 | $('#result').html(` 200 | 204 | `); 205 | // Add click event for the copy button 206 | $('.copy-btn').click(function () { 207 | copyToClipboard($(this).data('url')); 208 | }); 209 | }, 210 | error: function (xhr, status, error) { 211 | console.error('Error generating port:', status, error); 212 | // Display error message 213 | $('#result').html(` 214 | 217 | `); 218 | } 219 | }); 220 | } 221 | 222 | /** 223 | * Delete a port. 224 | * Sends an AJAX request to delete a port and removes it from the DOM. 225 | * 226 | * @param {string} ip - The IP address of the port 227 | * @param {number} portNumber - The port number to delete 228 | */ 229 | export function deletePort(ip, portNumber) { 230 | $.ajax({ 231 | url: '/delete_port', 232 | method: 'POST', 233 | data: { ip: ip, port_number: portNumber }, 234 | success: function (response) { 235 | if (response.success) { 236 | showNotification('Port deleted successfully', 'success'); 237 | // Remove the port from the DOM 238 | $(`.port[data-ip="${ip}"][data-port="${portNumber}"]`).parent().remove(); 239 | } else { 240 | showNotification('Error deleting port: ' + response.message, 'error'); 241 | } 242 | $('#deletePortModal').modal('hide'); 243 | }, 244 | error: function (xhr, status, error) { 245 | showNotification('Error deleting port: ' + error, 'error'); 246 | $('#deletePortModal').modal('hide'); 247 | } 248 | }); 249 | } 250 | 251 | /** 252 | * Change the port number. 253 | * Sends an AJAX request to change the port number and executes a callback on success. 254 | * 255 | * @param {string} ip - The IP address of the port 256 | * @param {number} oldPortNumber - The current port number 257 | * @param {number} newPortNumber - The new port number 258 | * @param {function} callback - The callback function to execute on success 259 | */ 260 | export function changePortNumber(ip, oldPortNumber, newPortNumber, callback) { 261 | $.ajax({ 262 | url: '/change_port_number', 263 | method: 'POST', 264 | data: { 265 | ip: ip, 266 | old_port_number: oldPortNumber, 267 | new_port_number: newPortNumber 268 | }, 269 | success: function (response) { 270 | if (response.success) { 271 | showNotification('Port number changed successfully', 'success'); 272 | if (callback) callback(); 273 | } else { 274 | showNotification('Error changing port number: ' + response.message, 'error'); 275 | } 276 | }, 277 | error: function (xhr, status, error) { 278 | showNotification('Error changing port number: ' + error, 'error'); 279 | } 280 | }); 281 | } 282 | 283 | /** 284 | * Export all entries as a JSON file. 285 | * Sends a GET request to fetch the export data and triggers a download. 286 | */ 287 | export function exportEntries() { 288 | fetch('/export_entries', { 289 | method: 'GET', 290 | }) 291 | .then(response => { 292 | if (!response.ok) { 293 | throw new Error('Network response was not ok'); 294 | } 295 | 296 | // Get the filename from the Content-Disposition header 297 | const contentDisposition = response.headers.get('Content-Disposition'); 298 | let filename = 'export.json'; 299 | if (contentDisposition) { 300 | const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i); 301 | if (filenameMatch.length === 2) 302 | filename = filenameMatch[1]; 303 | } 304 | 305 | return response.blob().then(blob => ({ blob, filename })); 306 | }) 307 | .then(({ blob, filename }) => { 308 | const url = window.URL.createObjectURL(blob); 309 | const a = document.createElement('a'); 310 | a.style.display = 'none'; 311 | a.href = url; 312 | a.download = filename; 313 | document.body.appendChild(a); 314 | a.click(); 315 | window.URL.revokeObjectURL(url); 316 | showNotification('Data exported successfully', 'success'); 317 | }) 318 | .catch(error => { 319 | console.error('Error:', error); 320 | showNotification('Error exporting data: ' + error.message, 'error'); 321 | }); 322 | } 323 | 324 | /** 325 | * Update the order of ports for a specific IP address. 326 | * Sends an AJAX request to update the port order on the server. 327 | * 328 | * @param {string} ip - The IP address for which to update the port order 329 | * @param {Array} portOrder - An array of port numbers in the new order 330 | */ 331 | export function updatePortOrder(ip, portOrder) { 332 | $.ajax({ 333 | url: '/update_port_order', 334 | method: 'POST', 335 | data: JSON.stringify({ 336 | ip: ip, 337 | port_order: portOrder 338 | }), 339 | contentType: 'application/json', 340 | success: function (response) { 341 | if (response.success) { 342 | console.log('Port order updated successfully'); 343 | } else { 344 | console.error('Error updating port order:', response.message); 345 | showNotification('Error updating port order: ' + response.message, 'error'); 346 | } 347 | }, 348 | error: function (xhr, status, error) { 349 | console.error('Error updating port order:', error); 350 | showNotification('Error updating port order: ' + error, 'error'); 351 | } 352 | }); 353 | } -------------------------------------------------------------------------------- /static/js/core/dragAndDrop.js: -------------------------------------------------------------------------------- 1 | // js/core/dragAndDrop.js 2 | 3 | import { updatePortOrder, handlePortClick, checkPortExists } from './portManagement.js'; 4 | import { updateIPPanelOrder } from './ipManagement.js'; 5 | import { showNotification } from '../ui/helpers.js'; 6 | import { movePort, changePortNumber } from '../api/ajax.js'; 7 | import { cancelDrop as cancelDropUtil } from '../utils/dragDropUtils.js'; 8 | import { showLoadingAnimation, hideLoadingAnimation } from '../ui/loadingAnimation.js'; 9 | 10 | // For dragging ports 11 | let draggingElement = null; 12 | let placeholder = null; 13 | let dragStartX, dragStartY, dragStartTime; 14 | let isDragging = false; 15 | let sourcePanel = null; 16 | let sourceIp = null; 17 | const dragThreshold = 5; 18 | const clickThreshold = 200; 19 | 20 | // For dragging IP panels 21 | let draggingIPPanel = null; 22 | let ipPanelPlaceholder = null; 23 | 24 | // For port conflict handling 25 | let conflictingPortData = null; 26 | 27 | /** 28 | * Initialize drag-and-drop event handlers. 29 | * Sets up event listeners for port slots and network switches. 30 | */ 31 | export function init() { 32 | $('.port-slot:not(.add-port-slot)').on('mousedown', handleMouseDown); 33 | $('.network-switch').on('dragstart', handleNetworkSwitchDragStart); 34 | $('.network-switch').on('dragover', handleNetworkSwitchDragOver); 35 | $('.network-switch').on('dragend', handleNetworkSwitchDragEnd); 36 | $('body').on('drop', handleBodyDrop); 37 | } 38 | 39 | /** 40 | * Handle mousedown event on a port slot. 41 | * Initiates drag detection and handles click vs. drag distinction. 42 | * 43 | * @param {Event} e - The mousedown event object 44 | */ 45 | function handleMouseDown(e) { 46 | if (e.which !== 1) return; // Only respond to left mouse button 47 | 48 | const panel = $(this).closest('.switch-panel'); 49 | const isLastPort = panel.find('.port-slot:not(.add-port-slot)').length === 1; 50 | 51 | dragStartX = e.clientX; 52 | dragStartY = e.clientY; 53 | dragStartTime = new Date().getTime(); 54 | const element = this; 55 | 56 | $(document).on('mousemove.dragdetect', function (e) { 57 | if (!isDragging && 58 | (Math.abs(e.clientX - dragStartX) > dragThreshold || 59 | Math.abs(e.clientY - dragStartY) > dragThreshold)) { 60 | if (isLastPort) { 61 | showNotification("Can't move the last port in a panel", 'error'); 62 | $(document).off('mousemove.dragdetect mouseup.dragdetect'); 63 | return; 64 | } 65 | isDragging = true; 66 | initiateDrag(e, element); 67 | } 68 | }); 69 | 70 | $(document).on('mouseup.dragdetect', function (e) { 71 | $(document).off('mousemove.dragdetect mouseup.dragdetect'); 72 | if (!isDragging && new Date().getTime() - dragStartTime < clickThreshold) { 73 | handlePortClick(element); 74 | } 75 | isDragging = false; 76 | }); 77 | 78 | e.preventDefault(); 79 | } 80 | 81 | /** 82 | * Handle drag start event for IP panels. 83 | * Prepares the drag operation and creates a placeholder. 84 | * 85 | * @param {Event} e - The dragstart event object 86 | */ 87 | function handleNetworkSwitchDragStart(e) { 88 | draggingIPPanel = this; 89 | e.originalEvent.dataTransfer.effectAllowed = 'move'; 90 | e.originalEvent.dataTransfer.setData('text/html', this.outerHTML); 91 | 92 | ipPanelPlaceholder = document.createElement('div'); 93 | ipPanelPlaceholder.className = 'network-switch-placeholder'; 94 | ipPanelPlaceholder.style.height = `${this.offsetHeight}px`; 95 | this.parentNode.insertBefore(ipPanelPlaceholder, this.nextSibling); 96 | 97 | setTimeout(() => { 98 | this.style.display = 'none'; 99 | }, 0); 100 | } 101 | 102 | /** 103 | * Handle drag over event for IP panels. 104 | * Adjusts the placeholder position based on the drag location. 105 | * 106 | * @param {Event} e - The dragover event object 107 | */ 108 | function handleNetworkSwitchDragOver(e) { 109 | e.preventDefault(); 110 | e.originalEvent.dataTransfer.dropEffect = 'move'; 111 | 112 | const rect = this.getBoundingClientRect(); 113 | const midpoint = rect.top + rect.height / 2; 114 | 115 | if (e.originalEvent.clientY < midpoint) { 116 | this.parentNode.insertBefore(ipPanelPlaceholder, this); 117 | } else { 118 | this.parentNode.insertBefore(ipPanelPlaceholder, this.nextSibling); 119 | } 120 | } 121 | 122 | /** 123 | * Handle drag end event for IP panels. 124 | * Finalizes the drag operation, updates the order of panels, 125 | * shows a loading animation, and refreshes the page. 126 | * 127 | * @param {Event} e - The dragend event object 128 | */ 129 | function handleNetworkSwitchDragEnd(e) { 130 | this.style.display = 'block'; 131 | 132 | if (ipPanelPlaceholder && ipPanelPlaceholder.parentNode) { 133 | ipPanelPlaceholder.parentNode.insertBefore(this, ipPanelPlaceholder); 134 | ipPanelPlaceholder.parentNode.removeChild(ipPanelPlaceholder); 135 | } 136 | 137 | // Update IP panel order 138 | updateIPPanelOrder(() => { 139 | // Show loading animation 140 | showLoadingAnimation(); 141 | 142 | // Refresh the page after a short delay 143 | setTimeout(() => { 144 | location.reload(); 145 | }, 1500); // 1.5 seconds delay 146 | }); 147 | } 148 | 149 | /** 150 | * Handle drop event on the body element. 151 | * Completes the drag-and-drop operation for IP panels. 152 | * 153 | * @param {Event} e - The drop event object 154 | */ 155 | function handleBodyDrop(e) { 156 | e.preventDefault(); 157 | if (draggingIPPanel !== null) { 158 | if (ipPanelPlaceholder && ipPanelPlaceholder.parentNode) { 159 | ipPanelPlaceholder.parentNode.insertBefore(draggingIPPanel, ipPanelPlaceholder); 160 | ipPanelPlaceholder.parentNode.removeChild(ipPanelPlaceholder); 161 | } 162 | draggingIPPanel = null; 163 | } 164 | } 165 | 166 | /** 167 | * Initiates the drag operation for a port 168 | * @param {Event} e - The event object 169 | * @param {HTMLElement} element - The element being dragged 170 | */ 171 | function initiateDrag(e, element) { 172 | draggingElement = element; 173 | sourcePanel = $(element).closest('.switch-panel'); 174 | sourceIp = sourcePanel.data('ip'); 175 | console.log('Dragging element:', draggingElement); 176 | console.log('Source panel:', sourcePanel); 177 | console.log('Source IP:', sourceIp); 178 | 179 | const rect = draggingElement.getBoundingClientRect(); 180 | const offsetX = e.clientX - rect.left; 181 | const offsetY = e.clientY - rect.top; 182 | 183 | // Create a placeholder for the dragged element 184 | placeholder = $(draggingElement).clone().empty().css({ 185 | 'height': $(draggingElement).height(), 186 | 'background-color': 'rgba(0, 0, 0, 0.1)', 187 | 'border': '2px dashed #ccc' 188 | }).insertAfter(draggingElement); 189 | 190 | // Style the dragging element 191 | $(draggingElement).css({ 192 | 'position': 'fixed', 193 | 'zIndex': 1000, 194 | 'pointer-events': 'none', 195 | 'width': $(draggingElement).width() + 'px', 196 | 'height': $(draggingElement).height() + 10 + 'px' 197 | }).appendTo('body'); 198 | 199 | // Handle mouse movement during drag 200 | function mouseMoveHandler(e) { 201 | $(draggingElement).css({ 202 | 'left': e.clientX - offsetX + 'px', 203 | 'top': e.clientY - offsetY + 'px' 204 | }); 205 | 206 | const targetElement = getTargetElement(e.clientX, e.clientY); 207 | if (targetElement && !$(targetElement).is(placeholder)) { 208 | if ($(targetElement).index() < $(placeholder).index()) { 209 | $(placeholder).insertBefore(targetElement); 210 | } else { 211 | $(placeholder).insertAfter(targetElement); 212 | } 213 | } 214 | } 215 | 216 | // Handle mouse up to end drag 217 | function mouseUpHandler(e) { 218 | $(document).off('mousemove', mouseMoveHandler); 219 | $(document).off('mouseup', mouseUpHandler); 220 | 221 | const targetElement = getTargetElement(e.clientX, e.clientY); 222 | if (targetElement) { 223 | finalizeDrop(targetElement); 224 | } else { 225 | cancelDrop(); 226 | } 227 | 228 | // Reset the dragging element's style 229 | $(draggingElement).css({ 230 | 'position': '', 231 | 'zIndex': '', 232 | 'left': '', 233 | 'top': '', 234 | 'pointer-events': '', 235 | 'width': '', 236 | 'height': '' 237 | }).insertBefore(placeholder); 238 | placeholder.remove(); 239 | draggingElement = null; 240 | placeholder = null; 241 | } 242 | 243 | $(document).on('mousemove', mouseMoveHandler); 244 | $(document).on('mouseup', mouseUpHandler); 245 | } 246 | 247 | /** 248 | * Determines the target element for dropping a port 249 | * @param {number} x - The x-coordinate of the mouse 250 | * @param {number} y - The y-coordinate of the mouse 251 | * @returns {HTMLElement|null} The target element or null if invalid 252 | */ 253 | function getTargetElement(x, y) { 254 | const elements = document.elementsFromPoint(x, y); 255 | const potentialTarget = elements.find(el => el.classList.contains('port-slot') && el !== draggingElement && !el.classList.contains('add-port-slot')); 256 | 257 | if (potentialTarget) { 258 | const sourcePanel = $(draggingElement).closest('.switch-panel'); 259 | const targetPanel = $(potentialTarget).closest('.switch-panel'); 260 | 261 | // Prevent moving the last port out of a panel 262 | if (sourcePanel[0] !== targetPanel[0] && sourcePanel.find('.port-slot:not(.add-port-slot)').length === 1) { 263 | return null; 264 | } 265 | } 266 | 267 | return potentialTarget; 268 | } 269 | 270 | /** 271 | * Finalizes the drop operation for a port 272 | * @param {HTMLElement} targetElement - The element where the port is being dropped 273 | */ 274 | function finalizeDrop(targetElement) { 275 | if (!targetElement) { 276 | showNotification("Can't move the last port in a panel", 'error'); 277 | cancelDrop(); 278 | return; 279 | } 280 | 281 | const targetPanel = $(targetElement).closest('.switch-panel'); 282 | const targetIp = targetPanel.data('ip'); 283 | 284 | console.log('Source panel:', sourcePanel); 285 | console.log('Target panel:', targetPanel); 286 | console.log('Source IP:', sourceIp); 287 | console.log('Target IP:', targetIp); 288 | 289 | if (sourceIp !== targetIp) { 290 | console.log('Moving port to a different IP group'); 291 | const portNumber = $(draggingElement).find('.port').data('port'); 292 | const protocol = $(draggingElement).find('.port').data('protocol'); 293 | 294 | console.log('Port number:', portNumber); 295 | console.log('Protocol:', protocol); 296 | 297 | // Check if the port number and protocol combination already exists in the target IP group 298 | if (checkPortExists(targetIp, portNumber, protocol)) { 299 | conflictingPortData = { 300 | sourceIp: sourceIp, 301 | targetIp: targetIp, 302 | portNumber: portNumber, 303 | protocol: protocol, 304 | targetElement: targetElement, 305 | draggingElement: draggingElement 306 | }; 307 | $('#conflictingPortNumber').text(`${portNumber} (${protocol})`); 308 | $('#portConflictModal').modal('show'); 309 | return; 310 | } 311 | 312 | // If no conflict, proceed with the move 313 | proceedWithMove(portNumber, protocol, sourceIp, targetIp, targetElement, draggingElement); 314 | } else { 315 | console.log('Reordering within the same IP group'); 316 | targetElement.parentNode.insertBefore(draggingElement, targetElement); 317 | updatePortOrder(sourceIp); 318 | } 319 | 320 | // Reset source variables 321 | sourcePanel = null; 322 | sourceIp = null; 323 | } 324 | 325 | /** 326 | * Proceed with moving a port from one IP address to another. 327 | * Inserts the dragged element before the target element, updates the server, 328 | * and adjusts the port order. 329 | * 330 | * @param {number} portNumber - The port number being moved 331 | * @param {string} protocol - The protocol of the port (e.g., 'TCP', 'UDP') 332 | * @param {string} sourceIp - The source IP address 333 | * @param {string} targetIp - The target IP address 334 | * @param {HTMLElement} targetElement - The target element for insertion 335 | * @param {HTMLElement} draggingElement - The element being dragged 336 | * @param {boolean} [isConflictResolution=false] - Flag indicating if this is part of conflict resolution 337 | */ 338 | function proceedWithMove(portNumber, protocol, sourceIp, targetIp, targetElement, draggingElement, isConflictResolution = false) { 339 | console.log(`Proceeding with move: ${portNumber} (${protocol}) from ${sourceIp} to ${targetIp}`); 340 | console.log('Dragging element:', draggingElement); 341 | console.log('Target element:', targetElement); 342 | 343 | // Insert the dragged element before the target element 344 | $(targetElement).before(draggingElement); 345 | 346 | // Move port on the server and update orders 347 | movePort(portNumber, sourceIp, targetIp, protocol, function (updatedPort) { 348 | console.log(`Move successful: ${portNumber} (${protocol}) from ${sourceIp} to ${targetIp}`); 349 | 350 | // Update the dragged element with new data 351 | const $port = $(draggingElement).find('.port'); 352 | $port.attr('data-ip', updatedPort.ip_address); 353 | $port.attr('data-port', updatedPort.port_number); 354 | $port.attr('data-protocol', updatedPort.protocol); 355 | $port.attr('data-description', updatedPort.description); 356 | $port.attr('data-order', updatedPort.order); 357 | $port.attr('data-id', updatedPort.id); 358 | $port.attr('data-nickname', updatedPort.nickname); // Update the nickname 359 | 360 | // Update the port's IP and other attributes 361 | const targetNickname = updatedPort.nickname; 362 | $port.attr('data-nickname', targetNickname); 363 | 364 | // Update the visual representation of the port 365 | $port.find('.port-number').text(updatedPort.port_number); 366 | $port.find('.port-description').text(updatedPort.description); 367 | $port.closest('.port-slot').find('.port-protocol').text(updatedPort.protocol); 368 | 369 | // Update the order of ports for both source and target IPs 370 | updatePortOrder(sourceIp); 371 | updatePortOrder(targetIp); 372 | 373 | if (isConflictResolution) { 374 | refreshPageAfterDelay(); 375 | } 376 | }, function () { 377 | console.log(`Move failed: ${portNumber} (${protocol}) from ${sourceIp} to ${targetIp}`); 378 | cancelDrop(); 379 | }); 380 | } 381 | 382 | /** 383 | * Cancels the drop operation and reverts the dragged element to its original position 384 | */ 385 | function cancelDrop() { 386 | cancelDropUtil(draggingElement, placeholder); 387 | } 388 | 389 | /** 390 | * Event handler for cancelling port conflict. 391 | * Hides the port conflict modal and reloads the page. 392 | */ 393 | $('#cancelPortConflict').click(function () { 394 | $('#portConflictModal').modal('hide'); 395 | location.reload(); 396 | }); 397 | 398 | /** 399 | * Event handler for changing port during conflict resolution. 400 | * Determines if the migrating or existing port is being changed, 401 | * hides the port conflict modal, and shows the port change modal. 402 | */ 403 | $('#changeMigratingPort, #changeExistingPort').click(function () { 404 | const isChangingMigrating = $(this).attr('id') === 'changeMigratingPort'; 405 | $('#portChangeType').text(isChangingMigrating ? 'migrating' : 'existing'); 406 | $('#portConflictModal').modal('hide'); 407 | $('#portChangeModal').modal('show'); 408 | }); 409 | 410 | /** 411 | * Event handler for confirming port change during conflict resolution. 412 | * Retrieves the new port number and updates the port based on whether 413 | * the migrating or existing port is being changed. 414 | * Proceeds with the port move and hides the port change modal. 415 | */ 416 | $('#confirmPortChange').click(function () { 417 | const newPortNumber = $('#newPortNumber').val(); 418 | const isChangingMigrating = $('#portChangeType').text() === 'migrating'; 419 | 420 | if (isChangingMigrating) { 421 | changePortNumber(conflictingPortData.sourceIp, conflictingPortData.portNumber, newPortNumber, function () { 422 | proceedWithMove(newPortNumber, conflictingPortData.protocol, conflictingPortData.sourceIp, conflictingPortData.targetIp, conflictingPortData.targetElement, conflictingPortData.draggingElement, true); 423 | }); 424 | } else { 425 | changePortNumber(conflictingPortData.targetIp, conflictingPortData.portNumber, newPortNumber, function () { 426 | proceedWithMove(conflictingPortData.portNumber, conflictingPortData.protocol, conflictingPortData.sourceIp, conflictingPortData.targetIp, conflictingPortData.targetElement, conflictingPortData.draggingElement, true); 427 | }); 428 | } 429 | 430 | $('#portChangeModal').modal('hide'); 431 | }); 432 | 433 | /** 434 | * Refresh the page after a delay. 435 | * Shows a loading animation and reloads the page after 1.5 seconds. 436 | */ 437 | function refreshPageAfterDelay() { 438 | showLoadingAnimation(); 439 | setTimeout(function () { 440 | location.reload(); 441 | }, 1500); 442 | } 443 | 444 | $(document).ready(init); 445 | 446 | export { initiateDrag, getTargetElement, finalizeDrop, proceedWithMove, cancelDrop }; -------------------------------------------------------------------------------- /static/js/core/import.js: -------------------------------------------------------------------------------- 1 | // static/js/import.js 2 | 3 | /** 4 | * Application: Configuration Import Tool 5 | * Description: Provides a user interface for importing various types of configuration files. 6 | * It supports Caddyfile, JSON, and Docker-Compose file formats. 7 | * 8 | * Uses jQuery for DOM manipulation and AJAX requests. 9 | */ 10 | 11 | $(document).ready(function () { 12 | 13 | /** 14 | * Placeholder text for different file types. 15 | * Helps users understand the expected format for each file type. 16 | */ 17 | 18 | const placeholders = { 19 | 'Caddyfile': `service.domain.tld { 20 | encode gzip 21 | import /config/security.conf 22 | reverse_proxy 192.168.0.123:8080 23 | } 24 | jellyfin.domain.tld { 25 | reverse_proxy 192.168.0.110:8096 26 | }`, 27 | 'JSON': `[ 28 | { 29 | "ip_address": "192.168.1.100", 30 | "nickname": "Server1", 31 | "port_number": 8080, 32 | "description": "example.domain.com", 33 | "order": 0 34 | }, 35 | { 36 | "ip_address": "192.168.1.101", 37 | "nickname": "Server2", 38 | "port_number": 9090, 39 | "description": "app.domain.com", 40 | "order": 0 41 | } 42 | ]`, 43 | 'Docker-Compose': `version: '3' 44 | services: 45 | webapp: 46 | image: webapp:latest 47 | ports: 48 | - "8080:80" 49 | database: 50 | image: postgres:12 51 | ports: 52 | - "5432:5432" 53 | ` 54 | }; 55 | 56 | /** 57 | * Displays a notification message to the user. 58 | * 59 | * @param {string} message - The message to display in the notification. 60 | * @param {string} [type='success'] - The type of notification ('success' or 'error'). 61 | */ 62 | function showNotification(message, type = 'success') { 63 | // Determine the appropriate Bootstrap alert class based on the notification type 64 | const alertClass = type === 'success' ? 'alert-success' : 'alert-danger'; 65 | 66 | // Create the notification HTML 67 | const notification = ` 68 | 72 | `; 73 | 74 | // Insert the notification into the DOM 75 | $('#notification-area').html(notification); 76 | 77 | // Automatically dismiss the notification after 5 seconds 78 | setTimeout(() => { 79 | $('.alert').alert('close'); 80 | }, 5000); 81 | } 82 | 83 | // Event handler for changing the import type 84 | $('#import-type').change(function () { 85 | const selectedType = $(this).val(); 86 | // Update the placeholder text based on the selected import type 87 | $('#file-content').attr('placeholder', placeholders[selectedType]); 88 | }); 89 | 90 | // Set initial placeholder text when the page loads 91 | $('#file-content').attr('placeholder', placeholders[$('#import-type').val()]); 92 | 93 | // Event handler for form submission 94 | $('#import-form').submit(function (e) { 95 | e.preventDefault(); // Prevent the default form submission 96 | 97 | // Send an AJAX POST request to the server 98 | $.ajax({ 99 | url: '/import', 100 | method: 'POST', 101 | data: $(this).serialize(), 102 | success: function (response) { 103 | console.log('Import successful:', response); 104 | showNotification(response.message); 105 | $('#file-content').val(''); // Clear the textarea after successful import 106 | }, 107 | error: function (xhr, status, error) { 108 | console.error('Error importing data:', status, error); 109 | showNotification('Error importing data.', 'error'); 110 | } 111 | }); 112 | }); 113 | }); -------------------------------------------------------------------------------- /static/js/core/ipManagement.js: -------------------------------------------------------------------------------- 1 | // js/core/ipManagement.js 2 | 3 | import { showNotification } from '../ui/helpers.js'; 4 | import { editIp, deleteIp } from '../api/ajax.js'; 5 | import { editIpModal } from '../ui/modals.js'; 6 | 7 | let deleteIpAddress; 8 | 9 | /** 10 | * Initialize event handlers for IP management. 11 | * Sets up click event listeners for editing, saving, and deleting IPs, 12 | * and modal event listeners for delete IP modal. 13 | */ 14 | export function init() { 15 | $('.edit-ip').click(handleEditIpClick); 16 | $('#save-ip').click(handleSaveIpClick); 17 | $('#delete-ip').click(handleDeleteIpClick); 18 | $('#confirm-delete-ip').click(handleConfirmDeleteIpClick); 19 | $('#deleteIpModal').on('hidden.bs.modal', handleDeleteIpModalHidden); 20 | } 21 | 22 | /** 23 | * Handle click event on the edit IP button. 24 | * Populates and shows the edit IP modal with the current IP and nickname. 25 | * 26 | * @param {Event} e - The click event object 27 | */ 28 | function handleEditIpClick(e) { 29 | e.preventDefault(); 30 | const ip = $(this).data('ip'); 31 | const nickname = $(this).data('nickname'); 32 | $('#old-ip').val(ip); 33 | $('#new-ip').val(ip); 34 | $('#new-nickname').val(nickname); 35 | editIpModal.show(); 36 | } 37 | 38 | /** 39 | * Handle click event on the save IP button. 40 | * Sends an AJAX request to update the IP and updates the UI accordingly. 41 | */ 42 | function handleSaveIpClick() { 43 | const formData = $('#edit-ip-form').serialize(); 44 | $.ajax({ 45 | url: '/edit_ip', 46 | method: 'POST', 47 | data: formData, 48 | success: function (response) { 49 | if (response.success) { 50 | showNotification('IP updated successfully', 'success'); 51 | updateIPLabel(formData); 52 | } else { 53 | showNotification('Error updating IP: ' + response.message, 'error'); 54 | } 55 | editIpModal.hide(); 56 | }, 57 | error: function (xhr, status, error) { 58 | showNotification('Error updating IP: ' + error, 'error'); 59 | editIpModal.hide(); 60 | } 61 | }); 62 | } 63 | 64 | /** 65 | * Update the IP label in the UI. 66 | * Modifies the IP and nickname data attributes and updates the displayed label. 67 | * 68 | * @param {string} formData - The serialized form data containing the IP information 69 | */ 70 | function updateIPLabel(formData) { 71 | const data = new URLSearchParams(formData); 72 | const oldIp = data.get('old_ip'); 73 | const newIp = data.get('new_ip'); 74 | const nickname = data.get('new_nickname'); 75 | const editIpElement = $(`.edit-ip[data-ip="${oldIp}"]`); 76 | 77 | editIpElement.data('ip', newIp).data('nickname', nickname); 78 | editIpElement.attr('data-ip', newIp).attr('data-nickname', nickname); 79 | 80 | const switchLabel = editIpElement.closest('.switch-label'); 81 | switchLabel.contents().first().replaceWith(newIp + (nickname ? ' (' + nickname + ')' : '')); 82 | } 83 | 84 | /** 85 | * Handle click event on the delete IP button. 86 | * Stores the IP address to be deleted and shows the delete IP modal. 87 | */ 88 | function handleDeleteIpClick() { 89 | deleteIpAddress = $('#old-ip').val(); 90 | $('#delete-ip-address').text(deleteIpAddress); 91 | editIpModal.hide(); 92 | $('#deleteIpModal').modal('show'); 93 | } 94 | 95 | /** 96 | * Handle click event on the confirm delete IP button. 97 | * Sends an AJAX request to delete the IP and updates the UI accordingly. 98 | */ 99 | function handleConfirmDeleteIpClick() { 100 | $.ajax({ 101 | url: '/delete_ip', 102 | method: 'POST', 103 | data: { ip: deleteIpAddress }, 104 | success: function (response) { 105 | if (response.success) { 106 | showNotification('IP and all assigned ports deleted successfully', 'success'); 107 | $(`.network-switch:has(.switch-label:contains("${deleteIpAddress}"))`).remove(); 108 | } else { 109 | showNotification('Error deleting IP: ' + response.message, 'error'); 110 | } 111 | $('#deleteIpModal').modal('hide'); 112 | }, 113 | error: function (xhr, status, error) { 114 | showNotification('Error deleting IP: ' + error, 'error'); 115 | $('#deleteIpModal').modal('hide'); 116 | } 117 | }); 118 | } 119 | 120 | /** 121 | * Handle hidden event for the delete IP modal. 122 | * Resets the stored IP address to be deleted. 123 | */ 124 | function handleDeleteIpModalHidden() { 125 | deleteIpAddress = null; 126 | } 127 | 128 | /** 129 | * Update the order of IP panels and execute a callback function. 130 | * Sends an AJAX request to update the IP order on the server. 131 | * 132 | * @param {Function} callback - Function to be called after updating the order 133 | */ 134 | export function updateIPPanelOrder(callback) { 135 | const ipOrder = []; 136 | $('.network-switch').each(function () { 137 | ipOrder.push($(this).data('ip')); 138 | }); 139 | 140 | console.log("Sending IP order:", ipOrder); 141 | 142 | $.ajax({ 143 | url: '/update_ip_order', 144 | method: 'POST', 145 | data: JSON.stringify({ ip_order: ipOrder }), 146 | contentType: 'application/json', 147 | success: function (response) { 148 | if (response.success) { 149 | console.log('IP panel order updated successfully'); 150 | if (typeof callback === 'function') { 151 | callback(); 152 | } 153 | } else { 154 | showNotification('Error updating IP panel order: ' + response.message, 'error'); 155 | location.reload(); 156 | } 157 | }, 158 | error: function (xhr, status, error) { 159 | showNotification('Error updating IP panel order: ' + error, 'error'); 160 | location.reload(); 161 | } 162 | }); 163 | } -------------------------------------------------------------------------------- /static/js/core/new.js: -------------------------------------------------------------------------------- 1 | // static/js/new.js 2 | 3 | /** 4 | * Application: New Port / IP Address Generator 5 | * Description: AJAX requests for managing IP addresses and 6 | * generating ports based on user input. 7 | */ 8 | 9 | import { showNotification } from '../ui/helpers.js'; 10 | import { generatePort } from '../api/ajax.js'; 11 | 12 | $(document).ready(function () { 13 | console.log('Document ready'); 14 | const ipSelect = $('#ip-address'); 15 | const newIpModal = new bootstrap.Modal(document.getElementById('newIpModal')); 16 | 17 | /** 18 | * Event handler for the "Add IP" button. 19 | * Opens a modal for adding a new IP address and nickname. 20 | */ 21 | $('#add-ip-btn').click(function () { 22 | console.log('Add IP button clicked'); 23 | $('#new-ip').val(''); 24 | $('#new-nickname').val(''); 25 | newIpModal.show(); 26 | }); 27 | 28 | /** 29 | * Event handler for saving a new IP address. 30 | * Validates the IP, adds it to the select dropdown, and closes the modal. 31 | */ 32 | $('#save-new-ip').click(function () { 33 | console.log('Save new IP clicked'); 34 | const newIp = $('#new-ip').val().trim(); 35 | const newNickname = $('#new-nickname').val().trim(); 36 | if (isValidIpAddress(newIp)) { 37 | console.log('New IP:', newIp, 'Nickname:', newNickname); 38 | const optionText = newIp + (newNickname ? ` (${newNickname})` : ''); 39 | // Add the new IP to the dropdown if it doesn't already exist 40 | if ($(`#ip-address option[value="${newIp}"]`).length === 0) { 41 | ipSelect.append(new Option(optionText, newIp)); 42 | } 43 | ipSelect.val(newIp); 44 | newIpModal.hide(); 45 | } else { 46 | console.log('Invalid IP'); 47 | alert('Please enter a valid IP address'); 48 | } 49 | }); 50 | 51 | /** 52 | * Event handler for form submission. 53 | * Prevents default form submission, validates input, and sends an AJAX request 54 | * to generate a port based on the selected IP address and other inputs. 55 | */ 56 | $('#port-form').submit(function (e) { 57 | e.preventDefault(); 58 | const ipAddress = ipSelect.val(); 59 | const selectedOption = ipSelect.find('option:selected'); 60 | const nickname = selectedOption.text().match(/\((.*?)\)/)?.[1] || ''; 61 | const portProtocol = $('#protocol').val(); 62 | if (!ipAddress) { 63 | alert('Please select or enter an IP address'); 64 | return; 65 | } 66 | 67 | // Send AJAX request to generate port 68 | const formData = { 69 | ip_address: ipAddress, 70 | nickname: nickname, 71 | description: $('#description').val(), 72 | protocol: portProtocol 73 | } 74 | generatePort(formData) 75 | }); 76 | }); 77 | 78 | /** 79 | * Validates an IP address. 80 | * @param {string} ip - The IP address to validate. 81 | * @returns {boolean} True if the IP address is valid, false otherwise. 82 | */ 83 | function isValidIpAddress(ip) { 84 | const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; 85 | if (ipv4Regex.test(ip)) { 86 | const parts = ip.split('.'); 87 | return parts.every(part => parseInt(part) >= 0 && parseInt(part) <= 255); 88 | } 89 | return false; 90 | } 91 | 92 | // Port Only or Full URL Copy Format 93 | function getCopyFormat() { 94 | return new Promise((resolve, reject) => { 95 | $.ajax({ 96 | url: '/port_settings', 97 | method: 'GET', 98 | success: function (data) { 99 | resolve(data.copy_format || 'port_only'); // Default to 'port_only' if not set 100 | }, 101 | error: function (xhr, status, error) { 102 | console.error('Error getting copy format:', status, error); 103 | reject(error); 104 | } 105 | }); 106 | }); 107 | } 108 | 109 | /** 110 | * Copies the given URL to the clipboard. 111 | * Uses the Clipboard API if available, otherwise falls back to a manual method. 112 | * @param {string} url - The URL to copy to the clipboard. 113 | */ 114 | function copyToClipboard(url) { 115 | getCopyFormat().then(format => { 116 | let textToCopy; 117 | if (format === 'port_only') { 118 | const port = url.split(':').pop(); 119 | textToCopy = port; 120 | } else { 121 | textToCopy = url; 122 | } 123 | 124 | if (navigator.clipboard) { 125 | navigator.clipboard.writeText(textToCopy).then(function () { 126 | showNotification('Copied to clipboard!'); 127 | }, function (err) { 128 | console.error('Could not copy text: ', err); 129 | fallbackCopyTextToClipboard(textToCopy); 130 | }); 131 | } else { 132 | fallbackCopyTextToClipboard(textToCopy); 133 | } 134 | }).catch(error => { 135 | console.error('Error getting copy format:', error); 136 | showNotification('Error getting copy format', 'error'); 137 | }); 138 | } 139 | 140 | /** 141 | * Fallback method to copy text to clipboard for browsers that don't support the Clipboard API. 142 | * Creates a temporary textarea element, selects its content, and uses document.execCommand('copy'). 143 | * @param {string} text - The text to copy to the clipboard. 144 | */ 145 | function fallbackCopyTextToClipboard(text) { 146 | var textArea = document.createElement("textarea"); 147 | textArea.value = text; 148 | 149 | // Avoid scrolling to bottom 150 | textArea.style.top = "0"; 151 | textArea.style.left = "0"; 152 | textArea.style.position = "fixed"; 153 | 154 | document.body.appendChild(textArea); 155 | textArea.focus(); 156 | textArea.select(); 157 | 158 | try { 159 | var successful = document.execCommand('copy'); 160 | var msg = successful ? 'successful' : 'unsuccessful'; 161 | console.log('Fallback: Copying text command was ' + msg); 162 | alert('Copied to clipboard!'); 163 | } catch (err) { 164 | console.error('Fallback: Oops, unable to copy', err); 165 | alert('Failed to copy to clipboard. Please copy manually.'); 166 | } 167 | 168 | document.body.removeChild(textArea); 169 | } -------------------------------------------------------------------------------- /static/js/core/settings.js: -------------------------------------------------------------------------------- 1 | // static/js/core/settings.js 2 | 3 | /** 4 | * Manages the settings page functionality for a web application. 5 | * This script handles custom CSS editing, form submissions, port settings, 6 | * and various UI interactions. 7 | */ 8 | 9 | import { exportEntries } from '../api/ajax.js'; 10 | 11 | let cssEditor; 12 | 13 | $(document).ready(function () { 14 | // Initialize Bootstrap modal for confirmation dialogs 15 | const confirmModal = new bootstrap.Modal(document.getElementById('confirmModal')); 16 | 17 | /** 18 | * Initializes the CodeMirror editor for custom CSS editing. 19 | * Sets up the editor with specific options and event listeners. 20 | */ 21 | function initializeCodeMirror() { 22 | cssEditor = CodeMirror(document.getElementById("custom-css-editor"), { 23 | value: $('#custom-css').val(), 24 | mode: "text/css", 25 | theme: "monokai", 26 | lineNumbers: true, 27 | autoCloseBrackets: true, 28 | matchBrackets: true, 29 | indentUnit: 4, 30 | tabSize: 4, 31 | indentWithTabs: false, 32 | lineWrapping: true, 33 | extraKeys: { "Ctrl-Space": "autocomplete" }, 34 | smartIndent: true 35 | }); 36 | 37 | // Force a refresh after a short delay to ensure proper rendering 38 | setTimeout(function () { 39 | cssEditor.refresh(); 40 | }, 100); 41 | 42 | // Update hidden input when CodeMirror content changes 43 | cssEditor.on("change", function () { 44 | $('#custom-css').val(cssEditor.getValue()); 45 | }); 46 | } 47 | 48 | // Initialize CodeMirror on page load 49 | initializeCodeMirror(); 50 | 51 | // Load port settings on page load 52 | loadPortSettings(); 53 | 54 | // Apply custom CSS on page load 55 | applyCustomCSS($('#custom-css').val()); 56 | 57 | /** 58 | * Displays a notification message to the user. 59 | * @param {string} message - The message to display. 60 | * @param {string} [type='success'] - The type of notification ('success' or 'error'). 61 | */ 62 | function showNotification(message, type = 'success') { 63 | const alertClass = type === 'success' ? 'alert-success' : 'alert-danger'; 64 | const notification = ` 65 | 69 | `; 70 | $('#notification-area').html(notification); 71 | // Auto-dismiss after 5 seconds 72 | setTimeout(() => { 73 | $('.alert').alert('close'); 74 | }, 5000); 75 | } 76 | 77 | // Handle settings and theme form submissions 78 | $('#settings-form, #theme-form').submit(function (e) { 79 | e.preventDefault(); 80 | // Update hidden input with latest CodeMirror content before submitting 81 | $('#custom-css').val(cssEditor.getValue()); 82 | $.ajax({ 83 | url: '/settings', 84 | method: 'POST', 85 | data: $(this).serialize(), 86 | success: function (response) { 87 | console.log('Settings saved successfully:', response); 88 | showNotification('Settings saved successfully!'); 89 | // Apply custom CSS immediately 90 | applyCustomCSS($('#custom-css').val()); 91 | // Reload the page to apply the new theme 92 | location.reload(); 93 | }, 94 | error: function (xhr, status, error) { 95 | console.error('Error saving settings:', status, error); 96 | showNotification('Error saving settings.', 'error'); 97 | } 98 | }); 99 | }); 100 | 101 | // Handle purge button click 102 | $('#purge-button').click(function () { 103 | confirmModal.show(); 104 | }); 105 | 106 | // Handle export button click 107 | $('#export-entries-button').on('click', function () { 108 | exportEntries(); 109 | }); 110 | 111 | // Handle confirmation of purge action 112 | $('#confirm-purge').click(function () { 113 | $.ajax({ 114 | url: '/purge_entries', 115 | method: 'POST', 116 | success: function (response) { 117 | console.log('Entries purged successfully:', response); 118 | showNotification(response.message); 119 | confirmModal.hide(); 120 | }, 121 | error: function (xhr, status, error) { 122 | console.error('Error purging entries:', status, error); 123 | showNotification('Error purging entries: ' + (xhr.responseJSON ? xhr.responseJSON.error : 'Unknown error occurred'), 'error'); 124 | } 125 | }); 126 | }); 127 | 128 | // Handle tab navigation 129 | $('#settingsTabs button').on('click', function (e) { 130 | e.preventDefault(); 131 | $(this).tab('show'); 132 | }); 133 | 134 | // Change hash for page-reload 135 | $('.nav-tabs a').on('shown.bs.tab', function (e) { 136 | window.location.hash = e.target.hash; 137 | }); 138 | 139 | /** 140 | * Activates the correct tab based on the URL hash. 141 | */ 142 | function activateTabFromHash() { 143 | let hash = window.location.hash; 144 | if (hash) { 145 | $('.nav-tabs button[data-bs-target="' + hash + '"]').tab('show'); 146 | } 147 | } 148 | 149 | // Call on page load 150 | activateTabFromHash(); 151 | 152 | // Call when hash changes 153 | $(window).on('hashchange', activateTabFromHash); 154 | 155 | // Refresh CodeMirror when its tab becomes visible 156 | $('button[data-bs-toggle="tab"]').on('shown.bs.tab', function (e) { 157 | if (e.target.getAttribute('data-bs-target') === '#appearance') { 158 | if (cssEditor) { 159 | cssEditor.refresh(); 160 | } else { 161 | initializeCodeMirror(); 162 | } 163 | } 164 | }); 165 | 166 | // Load port settings on page load 167 | $.ajax({ 168 | url: '/port_settings', 169 | method: 'GET', 170 | success: function (data) { 171 | $('#port-start').val(data.port_start || ''); 172 | $('#port-end').val(data.port_end || ''); 173 | $('#port-exclude').val(data.port_exclude || ''); 174 | if (data.port_length) { 175 | $(`input[name="port_length"][value="${data.port_length}"]`).prop('checked', true); 176 | } 177 | updatePortLengthStatus(); 178 | }, 179 | error: function (xhr, status, error) { 180 | console.error('Error loading port settings:', status, error); 181 | showNotification('Error loading port settings.', 'error'); 182 | } 183 | }); 184 | 185 | // Handle port settings form submission 186 | $('#port-settings-form').submit(function (e) { 187 | e.preventDefault(); 188 | var formData = $(this).serializeArray(); 189 | 190 | // Filter out empty values 191 | formData = formData.filter(function (item) { 192 | return item.value !== ""; 193 | }); 194 | 195 | $.ajax({ 196 | url: '/port_settings', 197 | method: 'POST', 198 | data: $.param(formData), 199 | success: function (response) { 200 | console.log('Port settings saved successfully:', response); 201 | showNotification('Port settings saved successfully!'); 202 | loadPortSettings(); 203 | updatePortLengthStatus(); 204 | }, 205 | error: function (xhr, status, error) { 206 | console.error('Error saving port settings:', status, error); 207 | showNotification('Error saving port settings: ' + (xhr.responseJSON ? xhr.responseJSON.error : 'Unknown error occurred'), 'error'); 208 | } 209 | }); 210 | }); 211 | 212 | /** 213 | * Loads and updates the port settings UI. 214 | */ 215 | function loadPortSettings() { 216 | $.ajax({ 217 | url: '/port_settings', 218 | method: 'GET', 219 | success: function (data) { 220 | console.log("Received port settings:", data); // Add this line for debugging 221 | 222 | // Clear all fields first 223 | $('#port-start, #port-end, #port-exclude').val(''); 224 | $('input[name="port_length"]').prop('checked', false); 225 | $('input[name="copy_format"]').prop('checked', false); 226 | 227 | // Then set values only if they exist in the data 228 | if (data.port_start) $('#port-start').val(data.port_start); 229 | if (data.port_end) $('#port-end').val(data.port_end); 230 | if (data.port_exclude) $('#port-exclude').val(data.port_exclude); 231 | if (data.port_length) { 232 | $(`input[name="port_length"][value="${data.port_length}"]`).prop('checked', true); 233 | } 234 | 235 | // Always set a value for copy_format 236 | const copyFormat = data.copy_format || 'port_only'; 237 | $(`input[name="copy_format"][value="${copyFormat}"]`).prop('checked', true); 238 | 239 | console.log("Copy format set to:", copyFormat); // Add this line for debugging 240 | 241 | updatePortLengthStatus(); 242 | }, 243 | error: function (xhr, status, error) { 244 | console.error('Error loading port settings:', status, error); 245 | showNotification('Error loading port settings.', 'error'); 246 | } 247 | }); 248 | } 249 | 250 | /** 251 | * Updates the UI state of port length controls based on start/end port values. 252 | */ 253 | function updatePortLengthStatus() { 254 | const portStart = $('#port-start').val(); 255 | const portEnd = $('#port-end').val(); 256 | const portLengthRadios = $('input[name="port_length"]'); 257 | const portLengthControls = $('#port-length-controls'); 258 | const portLengthHelp = $('#port-length-help'); 259 | 260 | if (portStart || portEnd) { 261 | portLengthRadios.prop('disabled', true); 262 | portLengthControls.addClass('text-muted'); 263 | portLengthRadios.closest('.form-check-label').css('text-decoration', 'line-through'); 264 | portLengthHelp.show(); 265 | 266 | // Add tooltip to the disabled radio buttons 267 | portLengthRadios.attr('title', 'Disabled: Port length is determined by Start/End values'); 268 | portLengthRadios.tooltip(); 269 | } else { 270 | portLengthRadios.prop('disabled', false); 271 | portLengthControls.removeClass('text-muted'); 272 | portLengthRadios.closest('.form-check-label').css('text-decoration', 'none'); 273 | portLengthHelp.hide(); 274 | 275 | // Remove tooltip from the enabled radio buttons 276 | portLengthRadios.removeAttr('title'); 277 | portLengthRadios.tooltip('dispose'); 278 | } 279 | } 280 | 281 | // Add event listeners for Port Start and Port End inputs 282 | $('#port-start, #port-end').on('input', updatePortLengthStatus); 283 | 284 | // Initial call to set the correct state 285 | updatePortLengthStatus(); 286 | 287 | // Handle clear port settings button 288 | $('#clear-port-settings').click(function () { 289 | 290 | // Clear all input fields 291 | $('#port-start, #port-end, #port-exclude').val(''); 292 | 293 | // Uncheck all radio buttons 294 | $('input[name="port_length"]').prop('checked', false); 295 | 296 | // Check the default radio button (assuming 4 digits is default) 297 | $('#port-length-4').prop('checked', true); 298 | 299 | // Update the port length status 300 | updatePortLengthStatus(); 301 | 302 | // Show a notification 303 | showNotification('Port settings cleared.'); 304 | }); 305 | 306 | /** 307 | * Applies custom CSS to the page. 308 | * @param {string} css - The CSS string to apply. 309 | */ 310 | function applyCustomCSS(css) { 311 | let styleElement = $('#custom-style'); 312 | if (styleElement.length === 0) { 313 | styleElement = $(''); 314 | $('head').append(styleElement); 315 | } 316 | styleElement.text(css); 317 | } 318 | 319 | // Call this function when the About tab is shown 320 | $('button[data-bs-target="#about"]').on('shown.bs.tab', function (e) { 321 | loadAboutContent(); 322 | }); 323 | 324 | // Load About content 325 | function loadAboutContent() { 326 | $.ajax({ 327 | url: '/get_about_content', 328 | method: 'GET', 329 | success: function (data) { 330 | $('#planned-features-content').html(data.planned_features); 331 | $('#changelog-content').html(data.changelog); 332 | 333 | // Apply custom formatting and animations 334 | $('.markdown-content h2').each(function (index) { 335 | $(this).css('opacity', '0').delay(100 * index).animate({ opacity: 1 }, 500); 336 | }); 337 | 338 | $('.markdown-content ul').addClass('list-unstyled'); 339 | $('.markdown-content li').each(function (index) { 340 | $(this).css('opacity', '0').delay(50 * index).animate({ opacity: 1 }, 300); 341 | }); 342 | 343 | // Add a collapsible feature to long lists 344 | $('.markdown-content ul').each(function () { 345 | if ($(this).children().length > 5) { 346 | var $list = $(this); 347 | var $items = $list.children(); 348 | $items.slice(5).hide(); 349 | $list.after('Show more...'); 350 | $list.next('.show-more').click(function (e) { 351 | e.preventDefault(); 352 | $items.slice(5).slideToggle(); 353 | $(this).text($(this).text() === 'Show more...' ? 'Show less' : 'Show more...'); 354 | }); 355 | } 356 | }); 357 | 358 | // Animate info cards 359 | $('.info-card').each(function (index) { 360 | $(this).css('opacity', '0').delay(200 * index).animate({ opacity: 1 }, 500); 361 | }); 362 | }, 363 | error: function (xhr, status, error) { 364 | console.error('Error loading About content:', status, error); 365 | showNotification('Error loading About content.', 'error'); 366 | } 367 | }); 368 | } 369 | 370 | // Force a refresh on window load 371 | $(window).on('load', function () { 372 | if (cssEditor) { 373 | cssEditor.refresh(); 374 | } 375 | }); 376 | }); -------------------------------------------------------------------------------- /static/js/main.js: -------------------------------------------------------------------------------- 1 | // static/js/main.js 2 | 3 | import * as DragAndDrop from "./core/dragAndDrop.js"; 4 | import * as IpManagement from "./core/ipManagement.js"; 5 | import * as PortManagement from "./core/portManagement.js"; 6 | import * as Modals from "./ui/modals.js"; 7 | 8 | function init() { 9 | Modals.init(); // Initialize modals first 10 | DragAndDrop.init(); 11 | IpManagement.init(); 12 | PortManagement.init(); 13 | } 14 | 15 | document.addEventListener('DOMContentLoaded', init); -------------------------------------------------------------------------------- /static/js/ui/animations.js: -------------------------------------------------------------------------------- 1 | // static/js/ui/animations.js 2 | 3 | /** 4 | * Application: Animation Logic 5 | * Description: Handles animations and transitions for the application. 6 | * 7 | * Uses jQuery for DOM manipulation and Bootstrap for animations. 8 | */ 9 | 10 | $(document).ready(function () { 11 | 12 | // Animate buttons on hover 13 | $('.btn').hover( 14 | function () { $(this).addClass('animate__animated animate__pulse'); }, 15 | function () { $(this).removeClass('animate__animated animate__pulse'); } 16 | ); 17 | 18 | // Show modals 19 | $('.modal').on('show.bs.modal', function (e) { 20 | $(this).find('.modal-dialog').attr('class', 'modal-dialog animate__animated'); 21 | }); 22 | 23 | // Hide modals 24 | $('.modal').on('hide.bs.modal', function (e) { 25 | $(this).find('.modal-dialog').attr('class', 'modal-dialog animate__animated'); 26 | }); 27 | 28 | // Show dropdown menus 29 | $('.dropdown-toggle').on('show.bs.dropdown', function () { 30 | $(this).find('.dropdown-menu').first().stop(true, true).slideDown(200); 31 | }); 32 | 33 | // Hide dropdown menus 34 | $('.dropdown-toggle').on('hide.bs.dropdown', function () { 35 | $(this).find('.dropdown-menu').first().stop(true, true).slideUp(200); 36 | }); 37 | 38 | }); -------------------------------------------------------------------------------- /static/js/ui/helpers.js: -------------------------------------------------------------------------------- 1 | // js/ui/helpers.js 2 | 3 | /** 4 | * Displays a notification message 5 | * @param {string} message - The message to display 6 | * @param {string} [type='success'] - The type of notification ('success' or 'error') 7 | */ 8 | export function showNotification(message, type = 'success') { 9 | const alertClass = type === 'success' ? 'alert-success' : 'alert-danger'; 10 | const notification = ` 11 | 15 | `; 16 | $('#notification-area').html(notification); 17 | // Auto-dismiss after 5 seconds 18 | setTimeout(() => { 19 | $('.alert').alert('close'); 20 | }, 5000); 21 | } -------------------------------------------------------------------------------- /static/js/ui/loadingAnimation.js: -------------------------------------------------------------------------------- 1 | // js/ui/loadingAnimation.js 2 | 3 | /** 4 | * Display a loading animation overlay. 5 | * Creates and appends a loading overlay to the body with a spinner and a custom message. 6 | */ 7 | export function showLoadingAnimation() { 8 | const loadingHTML = ` 9 |
10 |
11 |
12 | Loading... 13 |
14 |
15 | 16 |

Anchors aweigh! Refreshing Portall...

17 |
18 |
19 |
20 | `; 21 | $('body').append(loadingHTML); 22 | } 23 | 24 | /** 25 | * Hide the loading animation overlay. 26 | * Removes the loading overlay element from the body. 27 | */ 28 | export function hideLoadingAnimation() { 29 | $('#loading-overlay').remove(); 30 | } -------------------------------------------------------------------------------- /static/js/ui/modals.js: -------------------------------------------------------------------------------- 1 | // static/js/ui/modals.js 2 | 3 | export let editIpModal, editPortModal, addPortModal, deletePortModal; 4 | 5 | export function init() { 6 | editIpModal = new bootstrap.Modal(document.getElementById('editIpModal')); 7 | editPortModal = new bootstrap.Modal(document.getElementById('editPortModal')); 8 | addPortModal = new bootstrap.Modal(document.getElementById('addPortModal')); 9 | deletePortModal = new bootstrap.Modal(document.getElementById('deletePortModal')); 10 | } -------------------------------------------------------------------------------- /static/js/utils/dragDropUtils.js: -------------------------------------------------------------------------------- 1 | // js/utils/dragDropUtils.js 2 | 3 | export function cancelDrop(draggingElement, placeholder) { 4 | $(draggingElement).insertBefore(placeholder); 5 | } -------------------------------------------------------------------------------- /templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Portall 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 45 |
46 | {% block content %}{% endblock %} 47 |
48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | {% block scripts %}{% endblock %} 56 | 57 | 58 | -------------------------------------------------------------------------------- /templates/import.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 |

Import

4 |
5 |
6 |
7 | 8 | 13 |
14 |
15 | 16 | 17 |
18 | 19 | 20 |
21 | {% endblock %} 22 | 23 | {% block scripts %} 24 | 25 | {% endblock %} -------------------------------------------------------------------------------- /templates/new.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 |

New Port Assignment

4 |
5 |
6 |
7 | 8 |
9 | 16 | 17 |
18 |
19 |
20 | 21 | 22 |
23 |
24 | 25 | 29 |
30 | 31 |
32 |
33 | 34 | 35 | 59 | {% endblock %} 60 | 61 | {% block scripts %} 62 | 63 | {% endblock %} -------------------------------------------------------------------------------- /templates/ports.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 |

Registered Ports

4 |
5 | {% for ip, data in ports_by_ip.items() %} 6 |
7 |

8 | {{ ip }}{% if data.nickname %} ({{ data.nickname }}){% endif %} 9 |
10 | 13 | 16 |
17 | 18 | 19 | 20 |

21 |
22 | {% for port in data.ports %} 23 |
24 |
27 | {{ port.port_number }} 28 | {{ port.description }} 29 |
{{ port.description }}
30 |
31 |

{{ port.port_protocol }}

32 |
33 | {% endfor %} 34 |
35 |
36 | + 37 |
38 |
39 |
40 |
41 | {% endfor %} 42 | 43 | 44 | 72 | 73 | 74 | 112 | 113 | 114 | 150 | 151 | 152 | 169 | 170 | 171 | 188 | 189 | 190 | 212 | 213 | 214 | 232 | {% endblock %} 233 | 234 | {% block scripts %} 235 | 236 | 237 | {% endblock %} -------------------------------------------------------------------------------- /templates/settings.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 |

Settings

4 |
5 | 6 | 7 | 40 | 41 | 42 |
43 | 44 | 45 |
46 |

General Settings

47 |
48 |
49 | 50 | 55 |
56 | 57 |
58 |
59 | 60 | 61 |
62 |

Port Generation Settings

63 |
64 | 65 | 66 |
67 | 68 | 69 |
70 | 71 | 72 |
73 | 74 | 75 |
76 | 77 | 78 |
79 | 80 | 81 |
82 | 83 | 84 |
85 | 86 |
87 |
88 | 90 | 93 |
94 |
95 | 96 | 99 |
100 |
101 | 104 |
105 | 106 | 107 |
108 | 109 |
110 |
111 | 113 | 116 |
117 |
118 | 120 | 123 |
124 |
125 |
126 | 127 | 128 |
129 | 130 | 131 |
132 | 133 | 134 |
135 |
136 | 137 | 138 |
139 |

Appearance

140 |
141 |
142 | 143 | 150 |
151 |
152 | 153 |
154 | 155 |
156 | 157 |
158 |
159 | 160 | 161 |
162 |

Data Management

163 | 164 | 165 |
166 |

Export Data

167 |

Export all Port entries to a file.

168 | 169 |
170 | 171 | 172 |
173 |

Purge Data

174 |

Purge all entries from the database. This action cannot be undone.

175 | 176 |
177 |
178 | 179 | 180 |
181 |

About Portall

182 | 183 |
184 |
185 |

Version Info

186 |
    187 |
  • Version: {{ version }}
  • 188 |
  • Released: July 14, 2024
  • 189 |
  • Github: Portall Repository
  • 191 |
192 |
193 |
194 | 195 |
196 |
197 |

Planned Features

198 |
199 |
200 |
201 | 202 |
203 |
204 |

Changelog

205 |
206 |
207 |
208 |
209 | 210 |
211 | 212 | 213 | 230 | {% endblock %} 231 | 232 | {% block scripts %} 233 | 234 | {% endblock %} -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/Portall/90bb4f56580bc6bec26c5bbbe01772be7559716e/utils/__init__.py -------------------------------------------------------------------------------- /utils/database/__init__.py: -------------------------------------------------------------------------------- 1 | # utils/database/__init__.py 2 | 3 | from .db import db, init_db, create_tables 4 | from .port import Port 5 | from .setting import Setting 6 | 7 | __all__ = ['db', 'init_db', 'create_tables', 'Port', 'Setting'] -------------------------------------------------------------------------------- /utils/database/db.py: -------------------------------------------------------------------------------- 1 | # utils/database/db.py 2 | 3 | from flask_sqlalchemy import SQLAlchemy 4 | 5 | db = SQLAlchemy() 6 | 7 | def init_db(app): 8 | db.init_app(app) 9 | return db 10 | 11 | def create_tables(app): 12 | with app.app_context(): 13 | db.create_all() -------------------------------------------------------------------------------- /utils/database/port.py: -------------------------------------------------------------------------------- 1 | # utils/database/port.py 2 | 3 | from .db import db 4 | 5 | class Port(db.Model): 6 | id = db.Column(db.Integer, primary_key=True) 7 | ip_address = db.Column(db.String(15), nullable=False) 8 | nickname = db.Column(db.String(50), nullable=True) 9 | port_number = db.Column(db.Integer, nullable=False) 10 | port_protocol = db.Column(db.String(3), nullable=False) 11 | description = db.Column(db.String(100), nullable=False) 12 | order = db.Column(db.Integer, default=0) 13 | 14 | __table_args__ = (db.UniqueConstraint('ip_address', 'port_number', 'port_protocol', name='_ip_port_protocol_uc'),) -------------------------------------------------------------------------------- /utils/database/setting.py: -------------------------------------------------------------------------------- 1 | # utils/database/setting.py 2 | 3 | from .db import db 4 | 5 | class Setting(db.Model): 6 | id = db.Column(db.Integer, primary_key=True) 7 | key = db.Column(db.String(50), unique=True, nullable=False) 8 | value = db.Column(db.String(100), nullable=False, default='') -------------------------------------------------------------------------------- /utils/routes/__init__.py: -------------------------------------------------------------------------------- 1 | # utils/routes/__init__.py 2 | 3 | # Import Blueprints 4 | from flask import Blueprint 5 | from .imports import imports_bp 6 | from .index import index_bp 7 | from .ports import ports_bp 8 | from .settings import settings_bp 9 | 10 | # Register Blueprints 11 | routes_bp = Blueprint('routes', __name__) 12 | routes_bp.register_blueprint(imports_bp) 13 | routes_bp.register_blueprint(index_bp) 14 | routes_bp.register_blueprint(ports_bp) 15 | routes_bp.register_blueprint(settings_bp) -------------------------------------------------------------------------------- /utils/routes/imports.py: -------------------------------------------------------------------------------- 1 | # utils/routes/imports.py 2 | 3 | # Standard Imports 4 | import json # For parsing JSON data 5 | import re # For regular expressions 6 | 7 | # External Imports 8 | from flask import Blueprint # For creating a blueprint 9 | from flask import jsonify # For returning JSON responses 10 | from flask import render_template # For rendering HTML templates 11 | from flask import request # For handling HTTP requests 12 | from flask import session # For storing session data 13 | 14 | # Local Imports 15 | from utils.database import db, Port # For accessing the database models 16 | 17 | # Create the blueprint 18 | imports_bp = Blueprint('imports', __name__) 19 | 20 | # Import to Database 21 | 22 | @imports_bp.route('/import', methods=['GET', 'POST']) 23 | def import_data(): 24 | """ 25 | Handle data import requests for various file types. 26 | 27 | This function processes both GET and POST requests: 28 | - GET: Renders the import template. 29 | - POST: Processes the uploaded file based on the import type. 30 | 31 | The function supports importing from Caddyfile, JSON, and Docker-Compose formats. 32 | It checks for existing entries in the database to avoid duplicates and 33 | provides a summary of added and skipped entries. 34 | The order is reset for each unique IP address. 35 | 36 | Returns: 37 | For GET: Rendered HTML template 38 | For POST: JSON response indicating success or failure of the import, 39 | including counts of added and skipped entries. 40 | """ 41 | if request.method == 'POST': 42 | import_type = request.form.get('import_type') 43 | file_content = request.form.get('file_content') 44 | 45 | if import_type == 'Caddyfile': 46 | imported_data = import_caddyfile(file_content) 47 | elif import_type == 'JSON': 48 | imported_data = import_json(file_content) 49 | elif import_type == 'Docker-Compose': 50 | imported_data = import_docker_compose(file_content) 51 | else: 52 | return jsonify({'success': False, 'message': 'Unsupported import type'}), 400 53 | 54 | added_count = 0 55 | skipped_count = 0 56 | ip_order_map = {} # To keep track of the current order for each IP 57 | 58 | # Group imported data by IP address 59 | grouped_data = {} 60 | for item in imported_data: 61 | ip = item['ip'] 62 | if ip not in grouped_data: 63 | grouped_data[ip] = [] 64 | grouped_data[ip].append(item) 65 | 66 | for ip, items in grouped_data.items(): 67 | # Get the maximum order for this IP 68 | max_order = db.session.query(db.func.max(Port.order)).filter(Port.ip_address == ip).scalar() 69 | current_order = max_order if max_order is not None else -1 70 | 71 | for item in items: 72 | existing_port = Port.query.filter_by( 73 | ip_address=item['ip'], 74 | port_number=item['port'], 75 | port_protocol=item['port_protocol'] 76 | ).first() 77 | 78 | if existing_port is None: 79 | current_order += 1 80 | port = Port( 81 | ip_address=item['ip'], 82 | nickname=item['nickname'] if item['nickname'] is not None else None, 83 | port_number=item['port'], 84 | description=item['description'], 85 | port_protocol=item['port_protocol'], 86 | order=current_order 87 | ) 88 | db.session.add(port) 89 | added_count += 1 90 | else: 91 | skipped_count += 1 92 | 93 | db.session.commit() 94 | 95 | return jsonify({ 96 | 'success': True, 97 | 'message': f'Imported {added_count} entries, skipped {skipped_count} existing entries' 98 | }) 99 | 100 | return render_template('import.html', theme=session.get('theme', 'light')) 101 | 102 | # Import Types 103 | 104 | def import_caddyfile(content): 105 | """ 106 | Parse a Caddyfile and extract port information. 107 | 108 | This function processes a Caddyfile content, extracting domain names and 109 | their associated reverse proxy configurations. 110 | 111 | Args: 112 | content (str): The content of the Caddyfile 113 | 114 | Returns: 115 | list: A list of dictionaries containing extracted port information 116 | """ 117 | entries = [] 118 | lines = content.split('\n') 119 | current_domain = None 120 | 121 | for line in lines: 122 | line = line.strip() 123 | if line and not line.startswith('#'): 124 | if '{' in line: 125 | # Extract domain name 126 | current_domain = line.split('{')[0].strip() 127 | elif 'reverse_proxy' in line: 128 | # Extract IP and port from reverse proxy directive 129 | parts = line.split() 130 | if len(parts) > 1: 131 | ip_port = parts[-1] 132 | ip, port = ip_port.split(':') 133 | entries.append({ 134 | 'ip': ip, 135 | 'nickname': None, 136 | 'port': int(port), 137 | 'description': current_domain, 138 | 'port_protocol': 'TCP' # Assume TCP 139 | }) 140 | 141 | return entries 142 | 143 | def parse_docker_compose(content): 144 | """ 145 | Parse Docker Compose file content and extract service information. 146 | 147 | Args: 148 | content (str): The content of the Docker Compose file 149 | 150 | Returns: 151 | dict: A dictionary with service names as keys and lists of (port, protocol) tuples as values 152 | """ 153 | result = {} 154 | current_service = None 155 | current_image = None 156 | in_services = False 157 | in_ports = False 158 | indent_level = 0 159 | 160 | def add_port(image, port, protocol): 161 | image_parts = image.split('/') 162 | image_name = image_parts[-1].split(':')[0] 163 | if image_name not in result: 164 | result[image_name] = [] 165 | result[image_name].append((port, protocol)) 166 | 167 | lines = content.split('\n') 168 | for line in lines: 169 | original_line = line 170 | line = line.strip() 171 | current_indent = len(original_line) - len(original_line.lstrip()) 172 | 173 | if line.startswith('services:'): 174 | in_services = True 175 | indent_level = current_indent 176 | continue 177 | 178 | if in_services and current_indent == indent_level + 2: 179 | if ':' in line and not line.startswith('-'): 180 | current_service = line.split(':')[0].strip() 181 | current_image = None 182 | in_ports = False 183 | 184 | if in_services and current_indent == indent_level + 4: 185 | if line.startswith('image:'): 186 | current_image = line.split('image:')[1].strip() 187 | if line.startswith('ports:'): 188 | in_ports = True 189 | continue 190 | 191 | if in_ports and current_indent == indent_level + 6: 192 | if line.startswith('-'): 193 | port_mapping = line.split('-')[1].strip().strip('"').strip("'") 194 | if ':' in port_mapping: 195 | host_port = port_mapping.split(':')[0] 196 | protocol = 'UDP' if '/udp' in port_mapping else 'TCP' 197 | host_port = host_port.split('/')[0] # Remove any protocol specification from the port 198 | if current_image: 199 | add_port(current_image, host_port, protocol) 200 | elif in_ports and current_indent <= indent_level + 4: 201 | in_ports = False 202 | 203 | return result 204 | 205 | def import_docker_compose(content): 206 | """ 207 | Parse a Docker Compose file and extract port information. 208 | 209 | This function processes Docker Compose file content, extracting service names, 210 | ports, and protocols. 211 | 212 | Args: 213 | content (str): The content of the Docker Compose file 214 | 215 | Returns: 216 | list: A list of dictionaries containing extracted port information 217 | 218 | Raises: 219 | ValueError: If there's an error parsing the Docker Compose file 220 | """ 221 | try: 222 | parsed_data = parse_docker_compose(content) 223 | 224 | entries = [] 225 | for image, ports in parsed_data.items(): 226 | for port, protocol in ports: 227 | entry = { 228 | "ip": "127.0.0.1", 229 | "nickname": None, 230 | "port": int(port), 231 | "description": image, 232 | "port_protocol": protocol 233 | } 234 | entries.append(entry) 235 | 236 | print(f"Total entries found: {len(entries)}") 237 | return entries 238 | 239 | except Exception as e: 240 | raise ValueError(f"Error parsing Docker-Compose file: {str(e)}") 241 | 242 | def import_json(content): 243 | """ 244 | Parse JSON content and extract port information. 245 | 246 | This function processes JSON content, expecting a specific format 247 | for port entries. 248 | 249 | Args: 250 | content (str): JSON-formatted string containing port information 251 | 252 | Returns: 253 | list: A list of dictionaries containing extracted port information 254 | 255 | Raises: 256 | ValueError: If the JSON format is invalid 257 | """ 258 | try: 259 | data = json.loads(content) 260 | entries = [] 261 | for item in data: 262 | entries.append({ 263 | 'ip': item['ip_address'], 264 | 'nickname': item['nickname'], 265 | 'port': int(item['port_number']), 266 | 'description': item['description'], 267 | 'port_protocol': item['port_protocol'].upper() 268 | }) 269 | return entries 270 | except json.JSONDecodeError: 271 | raise ValueError("Invalid JSON format") 272 | 273 | # Import Helpers 274 | 275 | def parse_port_and_protocol(port_value): 276 | """ 277 | Parse a port value and protocol, handling direct integers, environment variable expressions, 278 | and complex port mappings. 279 | 280 | Args: 281 | port_value (str): The port value to parse 282 | 283 | Returns: 284 | tuple: (int, str) The parsed port number and protocol ('TCP' or 'UDP' if specified, else 'TCP') 285 | 286 | Raises: 287 | ValueError: If no valid port number can be found 288 | """ 289 | # Remove any leading/trailing whitespace 290 | port_value = port_value.strip() 291 | 292 | # Check for explicit protocol specification 293 | if '/tcp' in port_value: 294 | protocol = 'TCP' 295 | port_value = port_value.replace('/tcp', '') 296 | elif '/udp' in port_value: 297 | protocol = 'UDP' 298 | port_value = port_value.replace('/udp', '') 299 | else: 300 | protocol = 'TCP' # Default to TCP if not explicitly specified 301 | 302 | # Find the last colon in the string 303 | last_colon_index = port_value.rfind(':') 304 | if last_colon_index != -1: 305 | # Look for numbers after the last colon 306 | after_colon = port_value[last_colon_index + 1:] 307 | number_match = re.search(r'(\d+)', after_colon) 308 | if number_match: 309 | return int(number_match.group(1)), protocol 310 | 311 | # If no number found after the last colon, check for other patterns 312 | # Check for complete environment variable syntax with default value 313 | complete_env_var_match = re.search(r':-(\d+)', port_value) 314 | if complete_env_var_match: 315 | return int(complete_env_var_match.group(1)), protocol 316 | 317 | # Check if it's a direct integer 318 | if port_value.isdigit(): 319 | return int(port_value), protocol 320 | 321 | # If we can't parse it, raise an error 322 | raise ValueError(f"Unable to parse port value: {port_value}") 323 | 324 | def get_max_order(): 325 | """ 326 | Retrieve the maximum order value from the Port table. 327 | 328 | Returns: 329 | int: The maximum order value, or -1 if the table is empty. 330 | """ 331 | max_order = db.session.query(db.func.max(Port.order)).scalar() 332 | return max_order if max_order is not None else -1 333 | -------------------------------------------------------------------------------- /utils/routes/index.py: -------------------------------------------------------------------------------- 1 | # utils/routes/index.py 2 | 3 | # External Imports 4 | from flask import Blueprint # For creating a blueprint 5 | from flask import render_template # For rendering HTML templates 6 | from flask import session # For storing session data 7 | 8 | # Local Imports 9 | from utils.database import db, Port, Setting # For accessing the database models 10 | 11 | # Create the blueprint 12 | index_bp = Blueprint('index', __name__) 13 | 14 | @index_bp.route('/') 15 | def index(): 16 | """ 17 | Render the main index page of the application. 18 | 19 | This function handles requests to the root URL ('/') and prepares data 20 | for rendering the main page. It performs the following tasks: 21 | 1. Retrieves distinct IP addresses and their nicknames from the database. 22 | 2. Determines the default IP address to display. 23 | 3. Manages the theme setting for the user interface. 24 | 25 | Returns: 26 | rendered_template: The 'new.html' template with context data including 27 | IP addresses, default IP, and current theme. 28 | """ 29 | # Query distinct IP addresses and their nicknames from the Port table 30 | ip_addresses = db.session.query(Port.ip_address, Port.nickname).distinct().all() 31 | 32 | # Determine the default IP address 33 | default_ip = Setting.query.filter_by(key='default_ip').first() 34 | default_ip = default_ip.value if default_ip else (ip_addresses[0][0] if ip_addresses else '') 35 | 36 | # Check if theme is set in session, if not, retrieve from database 37 | if 'theme' not in session: 38 | 39 | # Retrieve theme setting from database 40 | theme_setting = Setting.query.filter_by(key='theme').first() 41 | theme = theme_setting.value if theme_setting else 'light' 42 | 43 | # Store theme in session for future requests 44 | session['theme'] = theme 45 | 46 | else: 47 | 48 | # Use theme from session if already set 49 | theme = session['theme'] 50 | 51 | # Render the template with the prepared data 52 | return render_template('new.html', ip_addresses=ip_addresses, default_ip=default_ip, theme=theme) -------------------------------------------------------------------------------- /utils/routes/ports.py: -------------------------------------------------------------------------------- 1 | # utils/routes/ports.py 2 | 3 | # Standard Imports 4 | import json # For parsing JSON data 5 | import random # For generating random ports 6 | 7 | # External Imports 8 | from flask import Blueprint # For creating a blueprint 9 | from flask import current_app as app # For accessing the Flask app 10 | from flask import jsonify # For returning JSON responses 11 | from flask import render_template # For rendering HTML templates 12 | from flask import request # For handling HTTP requests 13 | from flask import session # For storing session data 14 | from flask import url_for # For generating URLs 15 | from sqlalchemy import func # For using SQL functions 16 | 17 | # Local Imports 18 | from utils.database import db, Port, Setting # For accessing the database models 19 | 20 | # Create the blueprint 21 | ports_bp = Blueprint('ports', __name__) 22 | 23 | ## Ports ## 24 | 25 | @ports_bp.route('/ports') 26 | def ports(): 27 | """ 28 | Render the ports page. 29 | 30 | This function retrieves all ports from the database, organizes them by IP address, 31 | and renders the 'ports.html' template with the organized port data. 32 | 33 | Returns: 34 | str: Rendered HTML template for the ports page. 35 | """ 36 | # Retrieve all ports, ordered by order 37 | ports = Port.query.order_by(Port.order).all() 38 | 39 | # Organize ports by IP address 40 | ports_by_ip = {} 41 | 42 | # For each port... 43 | for port in ports: 44 | 45 | # If the port's IP address is not in the dictionary... 46 | if port.ip_address not in ports_by_ip: 47 | # ...add the IP address to the dictionary with an empty list of ports, and set its nickname (if available) 48 | ports_by_ip[port.ip_address] = {'nickname': port.nickname, 'ports': []} 49 | 50 | # Add the port's details to the list of ports for the given IP address 51 | ports_by_ip[port.ip_address]['ports'].append({ 52 | 'id': port.id, # Unique identifier for the port 53 | 'port_number': port.port_number, # Port number 54 | 'description': port.description, # Description, usually the service name 55 | 'port_protocol': (port.port_protocol).upper(), # Protocol, converted to uppercase 56 | 'order': port.order # Position of the port within its IP address group 57 | }) 58 | 59 | # Get the current theme from the session 60 | theme = session.get('theme', 'light') 61 | 62 | # Render the template with the organized port data and theme 63 | return render_template('ports.html', ports_by_ip=ports_by_ip, theme=theme) 64 | 65 | @ports_bp.route('/add_port', methods=['POST']) 66 | def add_port(): 67 | """ 68 | Add a new port for a given IP address. 69 | 70 | This function creates a new port entry in the database with the provided details. 71 | 72 | Returns: 73 | JSON: A JSON response indicating success or failure of the operation. 74 | """ 75 | ip_address = request.form['ip'] 76 | ip_nickname = request.form['ip_nickname'] or None 77 | port_number = request.form['port_number'] 78 | description = request.form['description'] 79 | protocol = request.form['protocol'] 80 | 81 | try: 82 | max_order = db.session.query(db.func.max(Port.order)).filter_by(ip_address=ip_address).scalar() or 0 83 | port = Port(ip_address=ip_address, nickname=ip_nickname, port_number=port_number, description=description, 84 | port_protocol=protocol, order=max_order + 1) # Updated 85 | db.session.add(port) 86 | db.session.commit() 87 | 88 | app.logger.info(f"Added new port {port_number} for IP: {ip_address} with order {port.order}") 89 | return jsonify({'success': True, 'message': 'Port added successfully', 'order': port.order}) 90 | except Exception as e: 91 | db.session.rollback() 92 | app.logger.error(f"Error adding new port: {str(e)}") 93 | return jsonify({'success': False, 'message': 'Error adding new port'}), 500 94 | 95 | @ports_bp.route('/edit_port', methods=['POST']) 96 | def edit_port(): 97 | """ 98 | Edit an existing port for a given IP address. 99 | 100 | This function updates an existing port entry in the database with the provided details. 101 | 102 | Returns: 103 | JSON: A JSON response indicating success or failure of the operation. 104 | """ 105 | port_id = request.form.get('port_id') 106 | new_port_number = request.form.get('new_port_number') 107 | ip_address = request.form.get('ip') 108 | description = request.form.get('description') 109 | protocol = request.form.get('protocol') # Add this line 110 | 111 | if not all([port_id, new_port_number, ip_address, description, protocol]): # Update this line 112 | return jsonify({'success': False, 'message': 'Missing required data'}), 400 113 | 114 | try: 115 | port_entry = Port.query.get(port_id) 116 | if not port_entry: 117 | return jsonify({'success': False, 'message': 'Port entry not found'}), 404 118 | 119 | # Check if the new port number and protocol combination already exists for this IP 120 | existing_port = Port.query.filter(Port.ip_address == ip_address, 121 | Port.port_number == new_port_number, 122 | Port.port_protocol == protocol, 123 | Port.id != port_id).first() 124 | if existing_port: 125 | return jsonify({'success': False, 'message': 'Port number and protocol combination already exists for this IP'}), 400 126 | 127 | port_entry.port_number = new_port_number 128 | port_entry.description = description 129 | port_entry.port_protocol = protocol # Add this line 130 | db.session.commit() 131 | return jsonify({'success': True, 'message': 'Port updated successfully'}) 132 | except Exception as e: 133 | db.session.rollback() 134 | return jsonify({'success': False, 'message': str(e)}), 400 135 | 136 | @ports_bp.route('/delete_port', methods=['POST']) 137 | def delete_port(): 138 | """ 139 | Delete a specific port for a given IP address. 140 | 141 | This function removes a port entry from the database based on the IP address and port number. 142 | 143 | Returns: 144 | JSON: A JSON response indicating success or failure of the operation. 145 | """ 146 | ip_address = request.form['ip'] 147 | port_number = request.form['port_number'] 148 | 149 | try: 150 | port = Port.query.filter_by(ip_address=ip_address, port_number=port_number).first() 151 | if port: 152 | db.session.delete(port) 153 | db.session.commit() 154 | app.logger.info(f"Deleted port {port_number} for IP: {ip_address}") 155 | return jsonify({'success': True, 'message': 'Port deleted successfully'}) 156 | else: 157 | return jsonify({'success': False, 'message': 'Port not found'}), 404 158 | except Exception as e: 159 | db.session.rollback() 160 | app.logger.error(f"Error deleting port: {str(e)}") 161 | return jsonify({'success': False, 'message': 'Error deleting port'}), 500 162 | 163 | @ports_bp.route('/generate_port', methods=['POST']) 164 | def generate_port(): 165 | """ 166 | Generate a new port for a given IP address. 167 | 168 | This function receives IP address, nickname, and description from a POST request, 169 | generates a new unique port number within the configured range, and saves it to the database. 170 | 171 | Returns: 172 | tuple: A tuple containing a JSON response and an HTTP status code. 173 | The JSON response includes the new port number and full URL on success, 174 | or an error message on failure. 175 | """ 176 | # Extract data from the POST request 177 | ip_address = request.form['ip_address'] 178 | nickname = request.form['nickname'] 179 | description = request.form['description'] 180 | protocol = request.form['protocol'] 181 | app.logger.debug(f"Received request to generate port for IP: {ip_address}, Nickname: {nickname}, Description: {description}, Protocol: {protocol}") 182 | 183 | def get_setting(key, default): 184 | """Helper function to retrieve settings from the database.""" 185 | setting = Setting.query.filter_by(key=key).first() 186 | value = setting.value if setting else str(default) 187 | return value if value != '' else str(default) 188 | 189 | # Retrieve port generation settings 190 | port_start = int(get_setting('port_start', 1024)) 191 | port_end = int(get_setting('port_end', 65535)) 192 | port_exclude = get_setting('port_exclude', '') 193 | port_length = int(get_setting('port_length', 4)) 194 | 195 | # Get existing ports for this IP 196 | existing_ports = set(p.port_number for p in Port.query.filter_by(ip_address=ip_address).all()) 197 | 198 | # Create set of excluded ports 199 | excluded_ports = set() 200 | if port_exclude: 201 | excluded_ports.update(int(p.strip()) for p in port_exclude.split(',') if p.strip().isdigit()) 202 | 203 | # Generate list of available ports based on settings 204 | available_ports = [p for p in range(port_start, port_end + 1) 205 | if p not in excluded_ports and 206 | (port_length == 0 or len(str(p)) == port_length)] 207 | 208 | # Count ports in use within the current range 209 | ports_in_use = sum(1 for p in existing_ports if p in available_ports) 210 | 211 | # Check if there are any available ports 212 | if not available_ports or all(p in existing_ports for p in available_ports): 213 | total_ports = len(available_ports) 214 | app.logger.error(f"No available ports for IP: {ip_address}. Used {ports_in_use} out of {total_ports} possible ports.") 215 | settings_url = url_for('routes.settings.settings', _external=True) + '#ports' 216 | error_message = ( 217 | f"No available ports.\n" 218 | f"Used {ports_in_use} out of {total_ports} possible ports.\n" 219 | f"Consider expanding your port range in the settings." 220 | ) 221 | return jsonify({'error': error_message, 'html': True}), 400 222 | 223 | # Choose a new port randomly from available ports 224 | new_port = random.choice([p for p in available_ports if p not in existing_ports]) 225 | 226 | # Create and save the new port 227 | try: 228 | last_port_position = db.session.query(func.max(Port.order)).filter_by(ip_address=ip_address).scalar() or -1 229 | port = Port(ip_address=ip_address, nickname=nickname, port_number=new_port, description=description, port_protocol=protocol, order=last_port_position + 1) 230 | db.session.add(port) 231 | db.session.commit() 232 | app.logger.info(f"Generated new port {new_port} for IP: {ip_address}") 233 | except Exception as e: 234 | db.session.rollback() 235 | app.logger.error(f"Error saving new port: {str(e)}") 236 | return jsonify({'error': 'Error saving new port'}), 500 237 | 238 | # Return the new port and full URL 239 | full_url = f"http://{ip_address}:{new_port}" 240 | return jsonify({'protocol': protocol, 'port': new_port, 'full_url': full_url}) 241 | 242 | @ports_bp.route('/move_port', methods=['POST']) 243 | def move_port(): 244 | """ 245 | Move a port from one IP address to another. 246 | 247 | This function updates the IP address and order of a port based on the target IP. 248 | It also updates the nickname of the port to match the target IP's nickname. 249 | 250 | Returns: 251 | JSON: A JSON response indicating success or failure of the operation. 252 | On success, it includes the updated port details. 253 | """ 254 | port_number = request.form.get('port_number') 255 | source_ip = request.form.get('source_ip') 256 | target_ip = request.form.get('target_ip') 257 | protocol = request.form.get('protocol', '').upper() # Convert to uppercase 258 | 259 | app.logger.info(f"Moving {port_number} ({protocol}) from Source IP: {source_ip} to Target IP: {target_ip}") 260 | 261 | if not all([port_number, source_ip, target_ip, protocol]): 262 | app.logger.error(f"Missing required data. port_number: {port_number}, source_ip: {source_ip}, target_ip: {target_ip}, protocol: {protocol}") 263 | return jsonify({'success': False, 'message': 'Missing required data'}), 400 264 | 265 | try: 266 | # Check if the port already exists in the target IP with the same protocol 267 | existing_port = Port.query.filter_by(port_number=port_number, ip_address=target_ip, port_protocol=protocol).first() 268 | if existing_port: 269 | app.logger.info(f"Port {port_number} ({protocol}) already exists in target IP {target_ip}") 270 | return jsonify({'success': False, 'message': 'Port number and protocol combination already exists in the target IP group'}), 400 271 | 272 | # Log all ports for the source IP to check if the port exists 273 | all_source_ports = Port.query.filter_by(ip_address=source_ip).all() 274 | app.logger.info(f"All ports for source IP {source_ip}: {[(p.port_number, p.port_protocol) for p in all_source_ports]}") 275 | 276 | port = Port.query.filter_by(port_number=port_number, ip_address=source_ip, port_protocol=protocol).first() 277 | if port: 278 | app.logger.info(f"Found port to move: {port.id}, {port.port_number}, {port.ip_address}, {port.port_protocol}") 279 | 280 | # Get the nickname of the target IP 281 | target_port = Port.query.filter_by(ip_address=target_ip).first() 282 | target_nickname = target_port.nickname if target_port else None 283 | 284 | # Update IP address and nickname 285 | port.ip_address = target_ip 286 | port.nickname = target_nickname 287 | 288 | # Update order 289 | max_order = db.session.query(db.func.max(Port.order)).filter_by(ip_address=target_ip).scalar() or 0 290 | port.order = max_order + 1 291 | 292 | db.session.commit() 293 | app.logger.info(f"Port moved successfully: {port.id}, {port.port_number}, {port.ip_address}, {port.port_protocol}, {port.nickname}") 294 | return jsonify({ 295 | 'success': True, 296 | 'message': 'Port moved successfully', 297 | 'port': { 298 | 'id': port.id, 299 | 'port_number': port.port_number, 300 | 'ip_address': port.ip_address, 301 | 'protocol': port.port_protocol, 302 | 'description': port.description, 303 | 'order': port.order, 304 | 'nickname': port.nickname 305 | } 306 | }) 307 | else: 308 | app.logger.error(f"Port not found: {port_number}, {source_ip}, {protocol}") 309 | return jsonify({'success': False, 'message': 'Port not found'}), 404 310 | except Exception as e: 311 | db.session.rollback() 312 | app.logger.error(f"Error moving port: {str(e)}") 313 | return jsonify({'success': False, 'message': f'Error moving port: {str(e)}'}), 500 314 | 315 | @ports_bp.route('/update_port_order', methods=['POST']) 316 | def update_port_order(): 317 | """ 318 | Update the order of ports for a specific IP address. 319 | 320 | This function receives a new order for ports of a given IP and updates the database accordingly. 321 | 322 | Returns: 323 | JSON: A JSON response indicating success or failure of the operation. 324 | """ 325 | data = request.json 326 | ip = data.get('ip') 327 | port_order = data.get('port_order') 328 | 329 | if not ip or not port_order: 330 | return jsonify({'success': False, 'message': 'Missing IP or port order data'}), 400 331 | 332 | try: 333 | # Get the base order for this IP 334 | base_order = Port.query.filter_by(ip_address=ip).order_by(Port.order).first().order 335 | 336 | # Update the order for each port 337 | for index, port_number in enumerate(port_order): 338 | port = Port.query.filter_by(ip_address=ip, port_number=port_number).first() 339 | if port: 340 | port.order = base_order + index 341 | 342 | db.session.commit() 343 | return jsonify({'success': True, 'message': 'Port order updated successfully'}) 344 | except Exception as e: 345 | db.session.rollback() 346 | app.logger.error(f"Error updating port order: {str(e)}") 347 | return jsonify({'success': False, 'message': f'Error updating port order: {str(e)}'}), 500 348 | 349 | @ports_bp.route('/change_port_number', methods=['POST']) 350 | def change_port_number(): 351 | """ 352 | Change the port number for a given IP address. 353 | 354 | This function updates the port number for an existing port entry. 355 | 356 | Returns: 357 | JSON: A JSON response indicating success or failure of the operation. 358 | """ 359 | ip = request.form['ip'] 360 | old_port_number = request.form['old_port_number'] 361 | new_port_number = request.form['new_port_number'] 362 | 363 | try: 364 | port = Port.query.filter_by(ip_address=ip, port_number=old_port_number).first() 365 | if port: 366 | port.port_number = new_port_number 367 | db.session.commit() 368 | return jsonify({'success': True, 'message': 'Port number changed successfully'}) 369 | else: 370 | return jsonify({'success': False, 'message': 'Port not found'}), 404 371 | except Exception as e: 372 | db.session.rollback() 373 | return jsonify({'success': False, 'message': str(e)}), 500 374 | 375 | ## IP Addresses ## 376 | 377 | @ports_bp.route('/edit_ip', methods=['POST']) 378 | def edit_ip(): 379 | """ 380 | Edit an IP address and its associated nickname. 381 | 382 | This function updates the IP address and nickname for all ports associated with the old IP. 383 | 384 | Returns: 385 | JSON: A JSON response indicating success or failure of the operation. 386 | """ 387 | old_ip = request.form['old_ip'] 388 | new_ip = request.form['new_ip'] 389 | new_nickname = request.form['new_nickname'] 390 | 391 | try: 392 | # Find all ports associated with the old IP 393 | ports = Port.query.filter_by(ip_address=old_ip).all() 394 | 395 | if not ports: 396 | return jsonify({'success': False, 'message': 'No ports found for the given IP'}), 404 397 | 398 | # Update IP and nickname for all associated ports 399 | for port in ports: 400 | port.ip_address = new_ip 401 | port.nickname = new_nickname 402 | 403 | # Commit changes to the database 404 | db.session.commit() 405 | 406 | return jsonify({'success': True, 'message': 'IP updated successfully'}) 407 | except Exception as e: 408 | db.session.rollback() 409 | return jsonify({'success': False, 'message': f'Error updating IP: {str(e)}'}), 500 410 | 411 | @ports_bp.route('/delete_ip', methods=['POST']) 412 | def delete_ip(): 413 | """ 414 | Delete an IP address and all its associated ports. 415 | 416 | This function removes an IP address and all ports assigned to it from the database. 417 | 418 | Returns: 419 | JSON: A JSON response indicating success or failure of the operation. 420 | """ 421 | ip = request.form['ip'] 422 | 423 | try: 424 | # Delete all ports associated with the IP 425 | ports = Port.query.filter_by(ip_address=ip).all() 426 | for port in ports: 427 | db.session.delete(port) 428 | 429 | # Commit changes to the database 430 | db.session.commit() 431 | 432 | return jsonify({'success': True, 'message': 'IP and all assigned ports deleted successfully'}) 433 | except Exception as e: 434 | db.session.rollback() 435 | return jsonify({'success': False, 'message': f'Error deleting IP: {str(e)}'}), 500 436 | 437 | @ports_bp.route('/update_ip_order', methods=['POST']) 438 | def update_ip_order(): 439 | """ 440 | Update the order of IP address panels. 441 | 442 | This function receives a new order for IP addresses and updates the database accordingly. 443 | It uses a large step between IP orders to allow for many ports per IP. 444 | 445 | Returns: 446 | JSON: A JSON response indicating success or failure of the operation. 447 | """ 448 | data = json.loads(request.data) 449 | ip_order = data.get('ip_order', []) 450 | 451 | try: 452 | # Update the order for each IP 453 | for index, ip in enumerate(ip_order): 454 | base_order = index * 1000 # Use a large step to allow for many ports per IP 455 | ports = Port.query.filter_by(ip_address=ip).order_by(Port.order).all() 456 | for i, port in enumerate(ports): 457 | port.order = base_order + i 458 | 459 | db.session.commit() 460 | return jsonify({'success': True, 'message': 'IP panel order updated successfully'}) 461 | except Exception as e: 462 | db.session.rollback() 463 | app.logger.error(f"Error updating IP panel order: {str(e)}") 464 | return jsonify({'success': False, 'message': f'Error updating IP panel order: {str(e)}'}), 500 -------------------------------------------------------------------------------- /utils/routes/settings.py: -------------------------------------------------------------------------------- 1 | # utils/routes/settings.py 2 | 3 | # Standard Imports 4 | import json # For JSON operations 5 | import os # For file operations 6 | import re # For regular expressions 7 | 8 | # External Imports 9 | from datetime import datetime 10 | from io import BytesIO 11 | from flask import Blueprint # For creating a blueprint 12 | from flask import current_app as app # For accessing the Flask app 13 | from flask import jsonify # For returning JSON responses 14 | from flask import render_template # For rendering HTML templates 15 | from flask import request # For handling HTTP requests 16 | from flask import send_file # For serving files 17 | from flask import send_from_directory # For serving static files 18 | from flask import session # For storing session data 19 | import markdown # For rendering Markdown text 20 | 21 | # Local Imports 22 | from utils.database import db, Port, Setting # For accessing the database models 23 | 24 | # Create the blueprint 25 | settings_bp = Blueprint('settings', __name__) 26 | 27 | @settings_bp.route('/settings', methods=['GET', 'POST']) 28 | def settings(): 29 | """ 30 | Handle the settings page for the application. 31 | This function manages both GET and POST requests for the settings page. 32 | For GET requests, it retrieves and displays current settings. 33 | For POST requests, it updates the settings based on form data. 34 | 35 | Returns: 36 | For GET: Rendered settings.html template 37 | For POST: JSON response indicating success or failure 38 | """ 39 | if request.method == 'POST': 40 | # Extract form data 41 | default_ip = request.form.get('default_ip') 42 | theme = request.form.get('theme') 43 | custom_css = request.form.get('custom_css') 44 | 45 | # Update settings only if they are provided 46 | settings_to_update = {} 47 | if default_ip is not None: 48 | settings_to_update['default_ip'] = default_ip 49 | if theme is not None: 50 | settings_to_update['theme'] = theme 51 | if custom_css is not None: 52 | settings_to_update['custom_css'] = custom_css 53 | 54 | for key, value in settings_to_update.items(): 55 | setting = Setting.query.filter_by(key=key).first() 56 | if setting: 57 | setting.value = value 58 | else: 59 | setting = Setting(key=key, value=value) 60 | db.session.add(setting) 61 | 62 | try: 63 | db.session.commit() 64 | if 'theme' in settings_to_update: 65 | session['theme'] = theme 66 | return jsonify({'success': True}) 67 | except Exception as e: 68 | db.session.rollback() 69 | app.logger.error(f"Error saving settings: {str(e)}") 70 | return jsonify({'success': False, 'error': 'Error saving settings'}), 500 71 | 72 | # Retrieve unique IP addresses from the database 73 | ip_addresses = [ip[0] for ip in db.session.query(Port.ip_address).distinct()] 74 | 75 | # Get the default IP address from settings 76 | default_ip = Setting.query.filter_by(key='default_ip').first() 77 | default_ip = default_ip.value if default_ip else '' 78 | 79 | # Retrieve theme from session or database 80 | if 'theme' not in session: 81 | theme_setting = Setting.query.filter_by(key='theme').first() 82 | theme = theme_setting.value if theme_setting else 'light' 83 | session['theme'] = theme 84 | else: 85 | theme = session['theme'] 86 | 87 | # Get available themes from the themes directory 88 | theme_dir = os.path.join(app.static_folder, 'css', 'themes') 89 | themes = [f.split('.')[0] for f in os.listdir(theme_dir) if f.endswith('.css') and not f.startswith('global-')] 90 | 91 | # Retrieve custom CSS from settings 92 | custom_css = Setting.query.filter_by(key='custom_css').first() 93 | custom_css = custom_css.value if custom_css else '' 94 | 95 | # Get version from README 96 | def get_version_from_readme(): 97 | try: 98 | readme_path = os.path.join(os.path.dirname(__file__), '..', '..', 'README.md') 99 | if not os.path.exists(readme_path): 100 | app.logger.error(f"README.md not found at {readme_path}") 101 | return "Unknown (File Not Found)" 102 | with open(readme_path, 'r') as file: 103 | content = file.read() 104 | match = re.search(r'version-(\d+\.\d+\.\d+)-blue\.svg', content) 105 | if match: 106 | version = match.group(1) 107 | app.logger.info(f"version: {version}") 108 | return version 109 | else: 110 | app.logger.warning("Version pattern not found in README") 111 | return "Unknown (Pattern Not Found)" 112 | except Exception as e: 113 | app.logger.error(f"Error reading version from README: {str(e)}") 114 | return f"Unknown (Error: {str(e)})" 115 | 116 | # Get app version from README 117 | version = get_version_from_readme() 118 | 119 | # Render the settings template with all necessary data 120 | return render_template('settings.html', ip_addresses=ip_addresses, default_ip=default_ip, 121 | current_theme=theme, themes=themes, theme=theme, custom_css=custom_css, 122 | version=version) 123 | 124 | @settings_bp.route('/port_settings', methods=['GET', 'POST']) 125 | def port_settings(): 126 | """ 127 | Handle GET and POST requests for port settings. 128 | 129 | GET: Retrieve current port settings from the database. 130 | If settings don't exist, default values are provided. 131 | 132 | POST: Update port settings in the database with values from the form. 133 | If a setting is not provided, it uses a default value. 134 | 135 | Port settings include: 136 | - port_start: Starting port number 137 | - port_end: Ending port number 138 | - port_exclude: Comma-separated list of ports to exclude 139 | - port_length: Number of digits in port number (default: '4') 140 | - copy_format: Format for copying port info (default: 'port_only') 141 | 142 | Returns: 143 | - For GET: JSON object containing current port settings 144 | - For POST: JSON object indicating success or failure of the update operation 145 | 146 | Raises: 147 | - Logs any exceptions and returns a 500 error response 148 | """ 149 | if request.method == 'GET': 150 | try: 151 | port_settings = {} 152 | for key in ['port_start', 'port_end', 'port_exclude', 'port_length', 'copy_format']: 153 | setting = Setting.query.filter_by(key=key).first() 154 | if setting: 155 | port_settings[key] = setting.value 156 | elif key == 'copy_format': 157 | port_settings[key] = 'port_only' 158 | elif key == 'port_length': 159 | port_settings[key] = '4' # Set default to '4' 160 | else: 161 | port_settings[key] = '' 162 | 163 | app.logger.debug(f"Retrieved port settings: {port_settings}") 164 | return jsonify(port_settings) 165 | except Exception as e: 166 | app.logger.error(f"Error retrieving port settings: {str(e)}", exc_info=True) 167 | return jsonify({'error': str(e)}), 500 168 | 169 | elif request.method == 'POST': 170 | try: 171 | # Extract port settings from form data 172 | port_settings = { 173 | 'port_start': request.form.get('port_start', ''), 174 | 'port_end': request.form.get('port_end', ''), 175 | 'port_exclude': request.form.get('port_exclude', ''), 176 | 'port_length': request.form.get('port_length', '4'), # Default to '4' if not provided 177 | 'copy_format': request.form.get('copy_format', 'port_only') 178 | } 179 | 180 | app.logger.debug(f"Received port settings: {port_settings}") 181 | 182 | # Update or create port settings in the database 183 | for key, value in port_settings.items(): 184 | setting = Setting.query.filter_by(key=key).first() 185 | if setting: 186 | setting.value = value 187 | else: 188 | new_setting = Setting(key=key, value=value) 189 | db.session.add(new_setting) 190 | 191 | db.session.commit() 192 | app.logger.info("Port settings updated successfully") 193 | return jsonify({'success': True, 'message': 'Port settings updated successfully'}) 194 | except Exception as e: 195 | db.session.rollback() 196 | app.logger.error(f"Error saving port settings: {str(e)}", exc_info=True) 197 | return jsonify({'success': False, 'error': str(e)}), 500 198 | 199 | @settings_bp.route('/static/css/themes/') 200 | def serve_theme(filename): 201 | """ 202 | Serve CSS theme files from the themes directory. 203 | 204 | This function handles requests for theme CSS files and serves them directly from the themes directory. 205 | 206 | Args: 207 | filename (str): The name of the CSS file to serve. 208 | 209 | Returns: 210 | The requested CSS file. 211 | """ 212 | return send_from_directory('static/css/themes', filename) 213 | 214 | @settings_bp.route('/export_entries', methods=['GET']) 215 | def export_entries(): 216 | """ 217 | Export all port entries from the database as a JSON file. 218 | 219 | This function fetches all ports from the database, formats them into a list of dictionaries, 220 | converts the list to a JSON string, and returns the JSON data as a downloadable file. The 221 | filename includes the current date. 222 | 223 | Returns: 224 | Response: A Flask response object containing the JSON file for download. 225 | """ 226 | try: 227 | # Fetch all ports from the database 228 | ports = Port.query.all() 229 | 230 | # Create a list of dictionaries containing port data 231 | port_data = [ 232 | { 233 | 'ip_address': port.ip_address, 234 | 'nickname': port.nickname, 235 | 'port_number': port.port_number, 236 | 'description': port.description, 237 | 'port_protocol': port.port_protocol, 238 | 'order': port.order 239 | } for port in ports 240 | ] 241 | 242 | # Convert data to JSON 243 | json_data = json.dumps(port_data, indent=2) 244 | 245 | # Create a BytesIO object 246 | buffer = BytesIO() 247 | buffer.write(json_data.encode()) 248 | buffer.seek(0) 249 | 250 | # Generate filename with current date 251 | current_date = datetime.now().strftime("%Y-%m-%d") 252 | filename = f"portall_export_{current_date}.json" 253 | 254 | # Log the export 255 | app.logger.info(f"Exporting Data to: {filename}") 256 | 257 | return send_file( 258 | buffer, 259 | as_attachment=True, 260 | download_name=filename, 261 | mimetype='application/json' 262 | ) 263 | except Exception as e: 264 | app.logger.error(f"Error in export_entries: {str(e)}") 265 | return jsonify({"error": str(e)}), 500 266 | 267 | @settings_bp.route('/purge_entries', methods=['POST']) 268 | def purge_entries(): 269 | """ 270 | Purge all entries from the Port table in the database. 271 | 272 | This function handles POST requests to delete all records from the Port table. 273 | It's typically used for maintenance or reset purposes. 274 | 275 | Returns: 276 | JSON response indicating success or failure, along with the number of deleted entries. 277 | """ 278 | try: 279 | num_deleted = Port.query.delete() 280 | db.session.commit() 281 | app.logger.info(f"Purged {num_deleted} entries from the database") 282 | return jsonify({'success': True, 'message': f'All entries have been purged. {num_deleted} entries deleted.'}) 283 | except Exception as e: 284 | db.session.rollback() 285 | app.logger.error(f"Error purging entries: {str(e)}") 286 | return jsonify({'success': False, 'error': str(e)}), 500 287 | 288 | @settings_bp.route('/get_about_content') 289 | def get_about_content(): 290 | def read_md_file(filename): 291 | filepath = os.path.join(app.root_path, filename) 292 | if os.path.exists(filepath): 293 | with open(filepath, 'r') as file: 294 | content = file.read() 295 | return markdown.markdown(content) 296 | return "" 297 | 298 | planned_features = read_md_file('planned_features.md') 299 | changelog = read_md_file('changelog.md') 300 | 301 | return jsonify({ 302 | 'planned_features': planned_features, 303 | 'changelog': changelog 304 | }) --------------------------------------------------------------------------------