├── .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 | 
4 | 
5 | 
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 |
201 | Generated URL: ${response.full_url}
202 | Copy
203 |
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 |
215 | Error: ${xhr.responseJSON ? xhr.responseJSON.error : 'Unknown error occurred'}
216 |
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 |
69 | ${message}
70 |
71 |
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 |
66 | ${message}
67 |
68 |
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 |
12 | ${message}
13 |
14 |
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 |
21 |
44 |
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 |
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 |
IP Address
8 |
9 |
10 | {% for ip, nickname in ip_addresses %}
11 |
12 | {{ ip }}{% if nickname %} ({{ nickname }}){% endif %}
13 |
14 | {% endfor %}
15 |
16 | Add IP
17 |
18 |
19 |
20 | Description
21 |
22 |
23 |
24 | Protocol
25 |
26 | TCP
27 | UDP
28 |
29 |
30 | Generate
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 |
11 |
12 |
13 |
14 |
15 |
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 |
39 |
40 |
41 | {% endfor %}
42 |
43 |
44 |
72 |
73 |
74 |
112 |
113 |
114 |
115 |
116 |
117 |
122 |
123 |
124 |
125 |
126 |
127 | Port Number
128 |
129 |
130 |
131 | Description
132 |
133 |
134 |
135 |
136 | TCP
137 | UDP
138 |
139 |
140 |
141 |
142 |
143 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
159 |
160 | Are you sure you want to delete this port?
161 |
162 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
178 |
179 | Are you sure you want to delete IP and all its assigned ports?
180 |
181 |
185 |
186 |
187 |
188 |
189 |
190 |
192 |
193 |
194 |
198 |
199 |
Port is already registered with this IP.
200 |
You can either change the port number of the migrating port, or you can change the number of the port
201 | that is already there.
202 |
What would you like to do?
203 |
204 |
209 |
210 |
211 |
212 |
213 |
214 |
215 |
216 |
217 |
221 |
222 |
Enter a new port number for the port:
223 |
224 |
225 |
229 |
230 |
231 |
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 |
8 |
9 |
10 |
11 | General
13 |
14 |
15 |
16 |
17 | Ports
19 |
20 |
21 |
22 |
23 | Appearance
25 |
26 |
27 |
28 |
29 | Data Management
31 |
32 |
33 |
34 |
35 | About
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
General Settings
47 |
48 |
49 | Default IP Address
50 |
51 | {% for ip in ip_addresses %}
52 | {{ ip }}
53 | {% endfor %}
54 |
55 |
56 | Save
57 |
58 |
59 |
60 |
61 |
62 |
Port Generation Settings
63 |
64 |
65 |
66 |
67 | Port Number (Start)
68 |
69 |
70 |
71 |
72 |
73 | Port Number (End)
74 |
75 |
76 |
77 |
78 |
79 | Exclude Port Numbers (comma-separated)
80 |
81 |
82 |
83 |
84 |
85 |
Port Number Length
86 |
101 |
102 | Port length is determined by Start/End values when provided.
103 |
104 |
105 |
106 |
107 |
108 |
Copy to Clipboard Format
109 |
125 |
126 |
127 |
128 |
129 | Save Port Settings
130 | Clear Values
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
Appearance
140 |
141 |
142 | Theme
143 |
144 | {% for theme_name in themes %}
145 |
146 | {{ theme_name.replace('_', ' ').title() }}
147 |
148 | {% endfor %}
149 |
150 |
151 |
152 |
Custom CSS
153 |
154 |
155 |
156 | Save
157 |
158 |
159 |
160 |
161 |
162 |
Data Management
163 |
164 |
165 |
166 |
Export Data
167 |
Export all Port entries to a file.
168 |
Export Entries
169 |
170 |
171 |
172 |
173 |
Purge Data
174 |
Purge all entries from the database. This action cannot be undone.
175 |
Purge All Entries
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 |
214 |
215 |
216 |
220 |
221 | Are you sure you want to purge all entries? This action cannot be undone.
222 |
223 |
227 |
228 |
229 |
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 | })
--------------------------------------------------------------------------------