batman (working version kinda)
This commit is contained in:
commit
6dd38036e7
65 changed files with 3950 additions and 0 deletions
31
.devcontainer.json
Normal file
31
.devcontainer.json
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"name": "Python 3 for Ditto",
|
||||||
|
"image": "mcr.microsoft.com/devcontainers/python:3.12",
|
||||||
|
"forwardPorts": [
|
||||||
|
8080
|
||||||
|
],
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"settings": {
|
||||||
|
"python.defaultInterpreterPath": "/usr/local/bin/python",
|
||||||
|
"python.linting.enabled": true,
|
||||||
|
"python.linting.pylintEnabled": true,
|
||||||
|
"python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8",
|
||||||
|
"python.formatting.blackPath": "/usr/local/py-utils/bin/black",
|
||||||
|
"python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf",
|
||||||
|
"python.linting.banditPath": "/usr/local/py-utils/bin/bandit",
|
||||||
|
"python.linting.flake8Path": "/usr/local/py-utils/bin/flake8",
|
||||||
|
"python.linting.mypyPath": "/usr/local/py-utils/bin/mypy",
|
||||||
|
"python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle",
|
||||||
|
"python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle",
|
||||||
|
"python.linting.pylintPath": "/usr/local/py-utils/bin/pylint"
|
||||||
|
},
|
||||||
|
"extensions": [
|
||||||
|
"ms-python.python",
|
||||||
|
"ms-python.vscode-pylance",
|
||||||
|
"njpwerner.autodocstring"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"postCreateCommand": "pip install -r requirements.txt"
|
||||||
|
}
|
21
LICENSE
Normal file
21
LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Yohei Nakajima
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
154
app/__init__.py
Normal file
154
app/__init__.py
Normal file
|
@ -0,0 +1,154 @@
|
||||||
|
from flask import Flask, g, redirect, url_for, render_template
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
|
||||||
|
def create_app(config_name='development'):
|
||||||
|
app = Flask(__name__,
|
||||||
|
template_folder='templates',
|
||||||
|
static_folder='static')
|
||||||
|
|
||||||
|
# Import config
|
||||||
|
try:
|
||||||
|
from config.settings import config
|
||||||
|
app.config.from_object(config.get(config_name, 'default'))
|
||||||
|
except ImportError:
|
||||||
|
# Fallback configuration
|
||||||
|
app.config['SECRET_KEY'] = 'dev-key-placeholder'
|
||||||
|
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///app.db'
|
||||||
|
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||||
|
|
||||||
|
# Create the app instance folder if it doesn't exist
|
||||||
|
# This is where SQLite database will be stored
|
||||||
|
os.makedirs(os.path.join(app.instance_path), exist_ok=True)
|
||||||
|
|
||||||
|
# Initialize extensions
|
||||||
|
from app.core.extensions import db, bcrypt, limiter, login_manager, csrf
|
||||||
|
db.init_app(app)
|
||||||
|
bcrypt.init_app(app)
|
||||||
|
limiter.init_app(app)
|
||||||
|
csrf.init_app(app)
|
||||||
|
|
||||||
|
# Initialize login manager
|
||||||
|
from app.core.auth import User
|
||||||
|
login_manager.init_app(app)
|
||||||
|
|
||||||
|
@login_manager.user_loader
|
||||||
|
def load_user(user_id):
|
||||||
|
# Make sure we're in app context
|
||||||
|
with app.app_context():
|
||||||
|
return User.query.get(int(user_id))
|
||||||
|
|
||||||
|
# Initialize CSRF protection
|
||||||
|
from flask_wtf.csrf import CSRFProtect
|
||||||
|
csrf = CSRFProtect()
|
||||||
|
csrf.init_app(app)
|
||||||
|
|
||||||
|
# Request hooks
|
||||||
|
@app.before_request
|
||||||
|
def before_request():
|
||||||
|
g.user = None
|
||||||
|
from flask_login import current_user
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
g.user = current_user
|
||||||
|
|
||||||
|
# Add datetime to all templates
|
||||||
|
g.now = datetime.datetime.utcnow()
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def inject_now():
|
||||||
|
return {'now': datetime.datetime.utcnow()}
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def add_security_headers(response):
|
||||||
|
# Security headers
|
||||||
|
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||||
|
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
|
||||||
|
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||||
|
|
||||||
|
# Update last_seen for the user
|
||||||
|
if hasattr(g, 'user') and g.user and g.user.is_authenticated:
|
||||||
|
g.user.last_seen = datetime.datetime.utcnow()
|
||||||
|
db.session.commit()
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Add a basic index route that redirects to login or dashboard
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
from flask_login import current_user
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for('dashboard.dashboard_home'))
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
# Register blueprints - order matters!
|
||||||
|
# First auth blueprint
|
||||||
|
from app.routes.auth import bp as auth_bp
|
||||||
|
app.register_blueprint(auth_bp)
|
||||||
|
print("Registered Auth blueprint")
|
||||||
|
|
||||||
|
# Then other blueprints
|
||||||
|
try:
|
||||||
|
from app.routes.dashboard import bp as dashboard_bp
|
||||||
|
app.register_blueprint(dashboard_bp)
|
||||||
|
print("Registered Dashboard blueprint")
|
||||||
|
except ImportError as e:
|
||||||
|
print(f"Could not import dashboard blueprint: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.routes.ipam import bp as ipam_bp
|
||||||
|
app.register_blueprint(ipam_bp)
|
||||||
|
print("Registered IPAM blueprint")
|
||||||
|
except ImportError as e:
|
||||||
|
print(f"Could not import ipam blueprint: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.routes.api import bp as api_bp
|
||||||
|
app.register_blueprint(api_bp)
|
||||||
|
print("Registered API blueprint")
|
||||||
|
except ImportError as e:
|
||||||
|
print(f"Could not import API blueprint: {e}")
|
||||||
|
|
||||||
|
# Create database tables
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
db.create_all()
|
||||||
|
print("Database tables created successfully")
|
||||||
|
|
||||||
|
# Check if we need to seed the database
|
||||||
|
from app.core.auth import User
|
||||||
|
if User.query.count() == 0:
|
||||||
|
# Run the seed database function if we have no users
|
||||||
|
try:
|
||||||
|
from app.scripts.db_seed import seed_database
|
||||||
|
seed_database()
|
||||||
|
print("Database seeded with initial data")
|
||||||
|
|
||||||
|
# Create an admin user
|
||||||
|
admin = User(email="admin@example.com", is_admin=True)
|
||||||
|
admin.set_password("admin")
|
||||||
|
db.session.add(admin)
|
||||||
|
db.session.commit()
|
||||||
|
print("Admin user created: admin@example.com / admin")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error seeding database: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error with database setup: {e}")
|
||||||
|
|
||||||
|
# After all blueprint registrations, add error handlers
|
||||||
|
|
||||||
|
@app.errorhandler(404)
|
||||||
|
def page_not_found(e):
|
||||||
|
return render_template('errors/404.html', title='Page Not Found'), 404
|
||||||
|
|
||||||
|
@app.errorhandler(500)
|
||||||
|
def internal_server_error(e):
|
||||||
|
return render_template('errors/500.html', title='Server Error'), 500
|
||||||
|
|
||||||
|
@app.errorhandler(403)
|
||||||
|
def forbidden(e):
|
||||||
|
return render_template('errors/403.html', title='Forbidden'), 403
|
||||||
|
|
||||||
|
@app.errorhandler(401)
|
||||||
|
def unauthorized(e):
|
||||||
|
return render_template('errors/401.html', title='Unauthorized'), 401
|
||||||
|
|
||||||
|
return app
|
BIN
app/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
app/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/core/__pycache__/auth.cpython-313.pyc
Normal file
BIN
app/core/__pycache__/auth.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/core/__pycache__/extensions.cpython-313.pyc
Normal file
BIN
app/core/__pycache__/extensions.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/core/__pycache__/models.cpython-313.pyc
Normal file
BIN
app/core/__pycache__/models.cpython-313.pyc
Normal file
Binary file not shown.
31
app/core/auth.py
Normal file
31
app/core/auth.py
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
from flask_login import LoginManager, UserMixin
|
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
from .extensions import db
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
login_manager = LoginManager()
|
||||||
|
login_manager.login_view = 'auth.login'
|
||||||
|
|
||||||
|
class User(UserMixin, db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
email = db.Column(db.String(120), unique=True, nullable=False)
|
||||||
|
password_hash = db.Column(db.String(128), nullable=False)
|
||||||
|
is_admin = db.Column(db.Boolean, default=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
last_seen = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
def set_password(self, password):
|
||||||
|
self.password_hash = generate_password_hash(password)
|
||||||
|
|
||||||
|
def check_password(self, password):
|
||||||
|
return check_password_hash(self.password_hash, password)
|
||||||
|
|
||||||
|
def get_id(self):
|
||||||
|
return str(self.id)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<User {self.email}>'
|
||||||
|
|
||||||
|
@login_manager.user_loader
|
||||||
|
def load_user(user_id):
|
||||||
|
return User.query.get(int(user_id))
|
28
app/core/extensions.py
Normal file
28
app/core/extensions.py
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_bcrypt import Bcrypt
|
||||||
|
from flask_limiter import Limiter
|
||||||
|
from flask_limiter.util import get_remote_address
|
||||||
|
from flask_login import LoginManager
|
||||||
|
from flask_wtf.csrf import CSRFProtect
|
||||||
|
|
||||||
|
# Initialize extensions
|
||||||
|
db = SQLAlchemy()
|
||||||
|
bcrypt = Bcrypt()
|
||||||
|
login_manager = LoginManager()
|
||||||
|
login_manager.login_view = 'auth.login'
|
||||||
|
csrf = CSRFProtect()
|
||||||
|
|
||||||
|
# Initialize rate limiter with fallback storage
|
||||||
|
try:
|
||||||
|
limiter = Limiter(
|
||||||
|
key_func=get_remote_address,
|
||||||
|
default_limits=["200 per day", "50 per hour"],
|
||||||
|
storage_uri="memory://" # Use memory storage for development
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error initializing rate limiter: {e}")
|
||||||
|
# Fallback limiter with very basic functionality
|
||||||
|
limiter = Limiter(
|
||||||
|
key_func=get_remote_address,
|
||||||
|
default_limits=["200 per day", "50 per hour"]
|
||||||
|
)
|
77
app/core/models.py
Normal file
77
app/core/models.py
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
from .extensions import db
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
import ipaddress
|
||||||
|
|
||||||
|
class Subnet(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
cidr = db.Column(db.String(18), unique=True) # Format: "192.168.1.0/24"
|
||||||
|
location = db.Column(db.String(80))
|
||||||
|
auto_scan = db.Column(db.Boolean, default=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def network(self):
|
||||||
|
return ipaddress.ip_network(self.cidr)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def num_addresses(self):
|
||||||
|
return self.network.num_addresses
|
||||||
|
|
||||||
|
@property
|
||||||
|
def used_ips(self):
|
||||||
|
# Count servers in this subnet
|
||||||
|
return Server.query.filter_by(subnet_id=self.id).count()
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Subnet {self.cidr}>'
|
||||||
|
|
||||||
|
|
||||||
|
class Server(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
hostname = db.Column(db.String(80), unique=True)
|
||||||
|
ip_address = db.Column(db.String(15), unique=True)
|
||||||
|
subnet_id = db.Column(db.Integer, db.ForeignKey('subnet.id'))
|
||||||
|
subnet = db.relationship('Subnet', backref=db.backref('servers', lazy=True))
|
||||||
|
documentation = db.Column(db.Text)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Store ports as JSON in the database
|
||||||
|
_ports = db.Column(db.Text, default='[]')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ports(self):
|
||||||
|
return json.loads(self._ports) if self._ports else []
|
||||||
|
|
||||||
|
@ports.setter
|
||||||
|
def ports(self, value):
|
||||||
|
self._ports = json.dumps(value) if value else '[]'
|
||||||
|
|
||||||
|
def get_open_ports(self):
|
||||||
|
return self.ports
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Server {self.hostname}>'
|
||||||
|
|
||||||
|
|
||||||
|
class App(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(80))
|
||||||
|
server_id = db.Column(db.Integer, db.ForeignKey('server.id'))
|
||||||
|
server = db.relationship('Server', backref=db.backref('apps', lazy=True))
|
||||||
|
documentation = db.Column(db.Text)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Store ports as JSON in the database
|
||||||
|
_ports = db.Column(db.Text, default='[]')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ports(self):
|
||||||
|
return json.loads(self._ports) if self._ports else []
|
||||||
|
|
||||||
|
@ports.setter
|
||||||
|
def ports(self, value):
|
||||||
|
self._ports = json.dumps(value) if value else '[]'
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<App {self.name}>'
|
BIN
app/routes/__pycache__/api.cpython-313.pyc
Normal file
BIN
app/routes/__pycache__/api.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/routes/__pycache__/auth.cpython-313.pyc
Normal file
BIN
app/routes/__pycache__/auth.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/routes/__pycache__/dashboard.cpython-313.pyc
Normal file
BIN
app/routes/__pycache__/dashboard.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/routes/__pycache__/ipam.cpython-313.pyc
Normal file
BIN
app/routes/__pycache__/ipam.cpython-313.pyc
Normal file
Binary file not shown.
199
app/routes/api.py
Normal file
199
app/routes/api.py
Normal file
|
@ -0,0 +1,199 @@
|
||||||
|
from flask import Blueprint, jsonify, request, abort
|
||||||
|
from flask_login import login_required
|
||||||
|
from app.core.models import Subnet, Server, App
|
||||||
|
from app.core.extensions import db
|
||||||
|
from app.scripts.ip_scanner import scan
|
||||||
|
|
||||||
|
bp = Blueprint('api', __name__, url_prefix='/api')
|
||||||
|
|
||||||
|
@bp.route('/subnets', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
def get_subnets():
|
||||||
|
"""Get all subnets"""
|
||||||
|
subnets = Subnet.query.all()
|
||||||
|
result = []
|
||||||
|
|
||||||
|
for subnet in subnets:
|
||||||
|
result.append({
|
||||||
|
'id': subnet.id,
|
||||||
|
'cidr': subnet.cidr,
|
||||||
|
'location': subnet.location,
|
||||||
|
'used_ips': subnet.used_ips,
|
||||||
|
'auto_scan': subnet.auto_scan,
|
||||||
|
'created_at': subnet.created_at.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({'subnets': result})
|
||||||
|
|
||||||
|
@bp.route('/subnets/<int:subnet_id>', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
def get_subnet(subnet_id):
|
||||||
|
"""Get details for a specific subnet"""
|
||||||
|
subnet = Subnet.query.get_or_404(subnet_id)
|
||||||
|
|
||||||
|
servers = []
|
||||||
|
for server in Server.query.filter_by(subnet_id=subnet_id).all():
|
||||||
|
servers.append({
|
||||||
|
'id': server.id,
|
||||||
|
'hostname': server.hostname,
|
||||||
|
'ip_address': server.ip_address,
|
||||||
|
'created_at': server.created_at.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
})
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'id': subnet.id,
|
||||||
|
'cidr': subnet.cidr,
|
||||||
|
'location': subnet.location,
|
||||||
|
'used_ips': subnet.used_ips,
|
||||||
|
'auto_scan': subnet.auto_scan,
|
||||||
|
'created_at': subnet.created_at.strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
|
'servers': servers
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
@bp.route('/subnets/<int:subnet_id>/scan', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_subnet_scan(subnet_id):
|
||||||
|
"""Scan a subnet via API"""
|
||||||
|
subnet = Subnet.query.get_or_404(subnet_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = scan(subnet.cidr, save_results=True)
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'subnet': subnet.cidr,
|
||||||
|
'hosts_found': len(results),
|
||||||
|
'results': results
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': f'Error scanning subnet: {str(e)}'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
@bp.route('/servers', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
def get_servers():
|
||||||
|
"""Get all servers"""
|
||||||
|
servers = Server.query.all()
|
||||||
|
result = []
|
||||||
|
|
||||||
|
for server in servers:
|
||||||
|
result.append({
|
||||||
|
'id': server.id,
|
||||||
|
'hostname': server.hostname,
|
||||||
|
'ip_address': server.ip_address,
|
||||||
|
'subnet_id': server.subnet_id,
|
||||||
|
'created_at': server.created_at.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({'servers': result})
|
||||||
|
|
||||||
|
@bp.route('/servers/<int:server_id>', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
def get_server(server_id):
|
||||||
|
"""Get details for a specific server"""
|
||||||
|
server = Server.query.get_or_404(server_id)
|
||||||
|
|
||||||
|
apps = []
|
||||||
|
for app in App.query.filter_by(server_id=server_id).all():
|
||||||
|
apps.append({
|
||||||
|
'id': app.id,
|
||||||
|
'name': app.name,
|
||||||
|
'created_at': app.created_at.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
})
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'id': server.id,
|
||||||
|
'hostname': server.hostname,
|
||||||
|
'ip_address': server.ip_address,
|
||||||
|
'subnet_id': server.subnet_id,
|
||||||
|
'documentation': server.documentation,
|
||||||
|
'created_at': server.created_at.strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
|
'ports': server.ports,
|
||||||
|
'apps': apps
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
@bp.route('/apps', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
def get_apps():
|
||||||
|
"""Get all applications"""
|
||||||
|
apps = App.query.all()
|
||||||
|
result = []
|
||||||
|
|
||||||
|
for app in apps:
|
||||||
|
result.append({
|
||||||
|
'id': app.id,
|
||||||
|
'name': app.name,
|
||||||
|
'server_id': app.server_id,
|
||||||
|
'created_at': app.created_at.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({'apps': result})
|
||||||
|
|
||||||
|
@bp.route('/apps/<int:app_id>', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
def get_app(app_id):
|
||||||
|
"""Get details for a specific application"""
|
||||||
|
app = App.query.get_or_404(app_id)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'id': app.id,
|
||||||
|
'name': app.name,
|
||||||
|
'server_id': app.server_id,
|
||||||
|
'documentation': app.documentation,
|
||||||
|
'created_at': app.created_at.strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
|
'ports': app.ports
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
@bp.route('/status', methods=['GET'])
|
||||||
|
def status():
|
||||||
|
return jsonify({'status': 'OK'})
|
||||||
|
|
||||||
|
@bp.route('/markdown-preview', methods=['POST'])
|
||||||
|
def markdown_preview():
|
||||||
|
data = request.json
|
||||||
|
md_content = data.get('markdown', '')
|
||||||
|
html = markdown.markdown(md_content)
|
||||||
|
return jsonify({'html': html})
|
||||||
|
|
||||||
|
@bp.route('/ports/suggest', methods=['GET'])
|
||||||
|
def suggest_ports():
|
||||||
|
app_type = request.args.get('type', '').lower()
|
||||||
|
|
||||||
|
# Common port suggestions based on app type
|
||||||
|
suggestions = {
|
||||||
|
'web': [
|
||||||
|
{'port': 80, 'type': 'tcp', 'desc': 'HTTP'},
|
||||||
|
{'port': 443, 'type': 'tcp', 'desc': 'HTTPS'}
|
||||||
|
],
|
||||||
|
'database': [
|
||||||
|
{'port': 3306, 'type': 'tcp', 'desc': 'MySQL'},
|
||||||
|
{'port': 5432, 'type': 'tcp', 'desc': 'PostgreSQL'},
|
||||||
|
{'port': 1521, 'type': 'tcp', 'desc': 'Oracle'}
|
||||||
|
],
|
||||||
|
'mail': [
|
||||||
|
{'port': 25, 'type': 'tcp', 'desc': 'SMTP'},
|
||||||
|
{'port': 143, 'type': 'tcp', 'desc': 'IMAP'},
|
||||||
|
{'port': 110, 'type': 'tcp', 'desc': 'POP3'}
|
||||||
|
],
|
||||||
|
'file': [
|
||||||
|
{'port': 21, 'type': 'tcp', 'desc': 'FTP'},
|
||||||
|
{'port': 22, 'type': 'tcp', 'desc': 'SFTP/SSH'},
|
||||||
|
{'port': 445, 'type': 'tcp', 'desc': 'SMB'}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if app_type in suggestions:
|
||||||
|
return jsonify(suggestions[app_type])
|
||||||
|
|
||||||
|
# Default suggestions
|
||||||
|
return jsonify([
|
||||||
|
{'port': 80, 'type': 'tcp', 'desc': 'HTTP'},
|
||||||
|
{'port': 22, 'type': 'tcp', 'desc': 'SSH'}
|
||||||
|
])
|
76
app/routes/auth.py
Normal file
76
app/routes/auth.py
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||||
|
from flask_login import login_user, logout_user, current_user, login_required
|
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
from app.core.extensions import db
|
||||||
|
from app.core.auth import User
|
||||||
|
|
||||||
|
bp = Blueprint('auth', __name__, url_prefix='/auth')
|
||||||
|
|
||||||
|
@bp.route('/login', methods=['GET', 'POST'])
|
||||||
|
def login():
|
||||||
|
# If already logged in, redirect to dashboard
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for('dashboard.dashboard_home'))
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
email = request.form.get('email')
|
||||||
|
password = request.form.get('password')
|
||||||
|
remember = 'remember' in request.form
|
||||||
|
|
||||||
|
user = User.query.filter_by(email=email).first()
|
||||||
|
|
||||||
|
if user and user.check_password(password):
|
||||||
|
login_user(user, remember=remember)
|
||||||
|
next_page = request.args.get('next')
|
||||||
|
if next_page:
|
||||||
|
return redirect(next_page)
|
||||||
|
return redirect(url_for('dashboard.dashboard_home'))
|
||||||
|
|
||||||
|
flash('Invalid email or password', 'danger')
|
||||||
|
|
||||||
|
return render_template('auth/login.html', title='Login')
|
||||||
|
|
||||||
|
@bp.route('/register', methods=['GET', 'POST'])
|
||||||
|
def register():
|
||||||
|
# If already logged in, redirect to dashboard
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for('dashboard.dashboard_home'))
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
email = request.form.get('email')
|
||||||
|
password = request.form.get('password')
|
||||||
|
password_confirm = request.form.get('password_confirm')
|
||||||
|
|
||||||
|
# Check if email already exists
|
||||||
|
existing_user = User.query.filter_by(email=email).first()
|
||||||
|
if existing_user:
|
||||||
|
flash('Email already registered', 'danger')
|
||||||
|
return render_template('auth/register.html', title='Register')
|
||||||
|
|
||||||
|
# Check if passwords match
|
||||||
|
if password != password_confirm:
|
||||||
|
flash('Passwords do not match', 'danger')
|
||||||
|
return render_template('auth/register.html', title='Register')
|
||||||
|
|
||||||
|
# Create new user
|
||||||
|
user = User(email=email)
|
||||||
|
user.set_password(password)
|
||||||
|
|
||||||
|
# Make first user an admin
|
||||||
|
if User.query.count() == 0:
|
||||||
|
user.is_admin = True
|
||||||
|
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash('Registration successful! Please log in.', 'success')
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
return render_template('auth/register.html', title='Register')
|
||||||
|
|
||||||
|
@bp.route('/logout')
|
||||||
|
@login_required
|
||||||
|
def logout():
|
||||||
|
logout_user()
|
||||||
|
flash('You have been logged out', 'info')
|
||||||
|
return redirect(url_for('auth.login'))
|
303
app/routes/dashboard.py
Normal file
303
app/routes/dashboard.py
Normal file
|
@ -0,0 +1,303 @@
|
||||||
|
from flask import Blueprint, render_template, redirect, url_for, request, flash, jsonify
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
import markdown
|
||||||
|
from app.core.models import Server, App, Subnet
|
||||||
|
from app.core.extensions import db, limiter
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
bp = Blueprint('dashboard', __name__, url_prefix='/dashboard')
|
||||||
|
|
||||||
|
@bp.route('/')
|
||||||
|
@login_required
|
||||||
|
def dashboard_home():
|
||||||
|
"""Main dashboard view showing server statistics"""
|
||||||
|
server_count = Server.query.count()
|
||||||
|
app_count = App.query.count()
|
||||||
|
subnet_count = Subnet.query.count()
|
||||||
|
|
||||||
|
# Get latest added servers
|
||||||
|
latest_servers = Server.query.order_by(Server.created_at.desc()).limit(5).all()
|
||||||
|
|
||||||
|
# Get subnets with usage stats
|
||||||
|
subnets = Subnet.query.all()
|
||||||
|
for subnet in subnets:
|
||||||
|
subnet.usage_percent = subnet.used_ips / 254 * 100 if subnet.cidr.endswith('/24') else 0
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'dashboard/index.html',
|
||||||
|
title='Dashboard',
|
||||||
|
server_count=server_count,
|
||||||
|
app_count=app_count,
|
||||||
|
subnet_count=subnet_count,
|
||||||
|
latest_servers=latest_servers,
|
||||||
|
subnets=subnets,
|
||||||
|
now=datetime.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
@bp.route('/servers')
|
||||||
|
@login_required
|
||||||
|
def server_list():
|
||||||
|
"""List all servers"""
|
||||||
|
servers = Server.query.order_by(Server.hostname).all()
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'dashboard/server_list.html',
|
||||||
|
title='Servers',
|
||||||
|
servers=servers,
|
||||||
|
now=datetime.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
@bp.route('/server/<int:server_id>')
|
||||||
|
@login_required
|
||||||
|
def server_view(server_id):
|
||||||
|
"""View server details"""
|
||||||
|
server = Server.query.get_or_404(server_id)
|
||||||
|
apps = App.query.filter_by(server_id=server_id).all()
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'dashboard/server_view.html',
|
||||||
|
title=f'Server - {server.hostname}',
|
||||||
|
server=server,
|
||||||
|
apps=apps,
|
||||||
|
markdown=markdown.markdown,
|
||||||
|
now=datetime.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
@bp.route('/server/new', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def server_new():
|
||||||
|
"""Create a new server"""
|
||||||
|
subnets = Subnet.query.all()
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
hostname = request.form.get('hostname')
|
||||||
|
ip_address = request.form.get('ip_address')
|
||||||
|
subnet_id = request.form.get('subnet_id')
|
||||||
|
documentation = request.form.get('documentation', '')
|
||||||
|
|
||||||
|
# Basic validation
|
||||||
|
if not hostname or not ip_address or not subnet_id:
|
||||||
|
flash('Please fill in all required fields', 'danger')
|
||||||
|
return render_template(
|
||||||
|
'dashboard/server_form.html',
|
||||||
|
title='New Server',
|
||||||
|
subnets=subnets,
|
||||||
|
now=datetime.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if hostname or IP already exists
|
||||||
|
if Server.query.filter_by(hostname=hostname).first():
|
||||||
|
flash('Hostname already exists', 'danger')
|
||||||
|
return render_template(
|
||||||
|
'dashboard/server_form.html',
|
||||||
|
title='New Server',
|
||||||
|
subnets=subnets,
|
||||||
|
now=datetime.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
if Server.query.filter_by(ip_address=ip_address).first():
|
||||||
|
flash('IP address already exists', 'danger')
|
||||||
|
return render_template(
|
||||||
|
'dashboard/server_form.html',
|
||||||
|
title='New Server',
|
||||||
|
subnets=subnets,
|
||||||
|
now=datetime.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create new server
|
||||||
|
server = Server(
|
||||||
|
hostname=hostname,
|
||||||
|
ip_address=ip_address,
|
||||||
|
subnet_id=subnet_id,
|
||||||
|
documentation=documentation
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(server)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash('Server created successfully', 'success')
|
||||||
|
return redirect(url_for('dashboard.server_view', server_id=server.id))
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'dashboard/server_form.html',
|
||||||
|
title='New Server',
|
||||||
|
subnets=subnets,
|
||||||
|
now=datetime.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
@bp.route('/server/<int:server_id>/edit', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def server_edit(server_id):
|
||||||
|
"""Edit an existing server"""
|
||||||
|
server = Server.query.get_or_404(server_id)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
hostname = request.form.get('hostname')
|
||||||
|
ip_address = request.form.get('ip_address')
|
||||||
|
subnet_id = request.form.get('subnet_id')
|
||||||
|
|
||||||
|
if not hostname or not ip_address or not subnet_id:
|
||||||
|
flash('All fields are required', 'danger')
|
||||||
|
return redirect(url_for('dashboard.server_edit', server_id=server_id))
|
||||||
|
|
||||||
|
# Check if hostname changed and already exists
|
||||||
|
if hostname != server.hostname and Server.query.filter_by(hostname=hostname).first():
|
||||||
|
flash('Hostname already exists', 'danger')
|
||||||
|
return redirect(url_for('dashboard.server_edit', server_id=server_id))
|
||||||
|
|
||||||
|
# Check if IP changed and already exists
|
||||||
|
if ip_address != server.ip_address and Server.query.filter_by(ip_address=ip_address).first():
|
||||||
|
flash('IP address already exists', 'danger')
|
||||||
|
return redirect(url_for('dashboard.server_edit', server_id=server_id))
|
||||||
|
|
||||||
|
# Update server
|
||||||
|
server.hostname = hostname
|
||||||
|
server.ip_address = ip_address
|
||||||
|
server.subnet_id = subnet_id
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash('Server updated successfully', 'success')
|
||||||
|
return redirect(url_for('dashboard.server_view', server_id=server.id))
|
||||||
|
|
||||||
|
# GET request - show form with current values
|
||||||
|
subnets = Subnet.query.all()
|
||||||
|
return render_template(
|
||||||
|
'dashboard/server_edit.html',
|
||||||
|
title=f'Edit Server - {server.hostname}',
|
||||||
|
server=server,
|
||||||
|
subnets=subnets
|
||||||
|
)
|
||||||
|
|
||||||
|
@bp.route('/server/<int:server_id>/delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def server_delete(server_id):
|
||||||
|
"""Delete a server"""
|
||||||
|
server = Server.query.get_or_404(server_id)
|
||||||
|
|
||||||
|
# Delete all apps associated with this server
|
||||||
|
App.query.filter_by(server_id=server_id).delete()
|
||||||
|
|
||||||
|
# Delete the server
|
||||||
|
db.session.delete(server)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash('Server deleted successfully', 'success')
|
||||||
|
return redirect(url_for('dashboard.dashboard_home'))
|
||||||
|
|
||||||
|
@bp.route('/app/new', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def app_new():
|
||||||
|
"""Create a new application"""
|
||||||
|
servers = Server.query.all()
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
name = request.form.get('name')
|
||||||
|
server_id = request.form.get('server_id')
|
||||||
|
documentation = request.form.get('documentation', '')
|
||||||
|
|
||||||
|
# Basic validation
|
||||||
|
if not name or not server_id:
|
||||||
|
flash('Please fill in all required fields', 'danger')
|
||||||
|
return render_template(
|
||||||
|
'dashboard/app_form.html',
|
||||||
|
title='New Application',
|
||||||
|
servers=servers,
|
||||||
|
now=datetime.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
app = App(
|
||||||
|
name=name,
|
||||||
|
server_id=server_id,
|
||||||
|
documentation=documentation
|
||||||
|
)
|
||||||
|
db.session.add(app)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash('Application created successfully', 'success')
|
||||||
|
return redirect(url_for('dashboard.server_view', server_id=server_id))
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'dashboard/app_form.html',
|
||||||
|
title='New Application',
|
||||||
|
servers=servers,
|
||||||
|
now=datetime.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
@bp.route('/app/<int:app_id>', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
def app_view(app_id):
|
||||||
|
"""View a specific application"""
|
||||||
|
app = App.query.get_or_404(app_id)
|
||||||
|
server = Server.query.get(app.server_id)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'dashboard/app_view.html',
|
||||||
|
title=f'Application - {app.name}',
|
||||||
|
app=app,
|
||||||
|
server=server,
|
||||||
|
markdown=markdown.markdown,
|
||||||
|
now=datetime.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
@bp.route('/app/<int:app_id>/edit', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def app_edit(app_id):
|
||||||
|
"""Edit an existing application"""
|
||||||
|
app = App.query.get_or_404(app_id)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
name = request.form.get('name')
|
||||||
|
server_id = request.form.get('server_id')
|
||||||
|
documentation = request.form.get('documentation', '')
|
||||||
|
|
||||||
|
# Process ports
|
||||||
|
ports = []
|
||||||
|
port_numbers = request.form.getlist('port[]')
|
||||||
|
port_types = request.form.getlist('port_type[]')
|
||||||
|
port_descs = request.form.getlist('port_desc[]')
|
||||||
|
|
||||||
|
for i in range(len(port_numbers)):
|
||||||
|
if port_numbers[i]:
|
||||||
|
port = {
|
||||||
|
'port': int(port_numbers[i]),
|
||||||
|
'type': port_types[i] if i < len(port_types) else 'tcp',
|
||||||
|
'desc': port_descs[i] if i < len(port_descs) else '',
|
||||||
|
'status': 'open'
|
||||||
|
}
|
||||||
|
ports.append(port)
|
||||||
|
|
||||||
|
# Update app
|
||||||
|
app.name = name
|
||||||
|
app.server_id = server_id
|
||||||
|
app.documentation = documentation
|
||||||
|
app.ports = ports
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash('Application updated successfully', 'success')
|
||||||
|
return redirect(url_for('dashboard.app_view', app_id=app.id))
|
||||||
|
|
||||||
|
# GET request - show form with current values
|
||||||
|
servers = Server.query.all()
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'dashboard/app_edit.html',
|
||||||
|
title=f'Edit Application - {app.name}',
|
||||||
|
app=app,
|
||||||
|
servers=servers,
|
||||||
|
use_editor=True
|
||||||
|
)
|
||||||
|
|
||||||
|
@bp.route('/app/<int:app_id>/delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def app_delete(app_id):
|
||||||
|
"""Delete an application"""
|
||||||
|
app = App.query.get_or_404(app_id)
|
||||||
|
server_id = app.server_id
|
||||||
|
|
||||||
|
db.session.delete(app)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash('Application deleted successfully', 'success')
|
||||||
|
return redirect(url_for('dashboard.server_view', server_id=server_id))
|
104
app/routes/importexport.py
Normal file
104
app/routes/importexport.py
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
from flask import Blueprint, request, jsonify, make_response, render_template
|
||||||
|
from app.core.models import Subnet, Server, App
|
||||||
|
from app.core.extensions import db
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
bp = Blueprint('importexport', __name__, url_prefix='/import-export')
|
||||||
|
|
||||||
|
MODEL_MAP = {
|
||||||
|
'subnet': Subnet,
|
||||||
|
'server': Server,
|
||||||
|
'app': App
|
||||||
|
}
|
||||||
|
|
||||||
|
@bp.route('/export/<model_name>', methods=['GET'])
|
||||||
|
def export_model(model_name):
|
||||||
|
if model_name not in MODEL_MAP:
|
||||||
|
return jsonify({'error': 'Invalid model name'}), 400
|
||||||
|
|
||||||
|
model = MODEL_MAP[model_name]
|
||||||
|
instances = model.query.all()
|
||||||
|
|
||||||
|
# Create a CSV file in memory
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
|
||||||
|
# Get column names from model
|
||||||
|
columns = [column.name for column in model.__table__.columns]
|
||||||
|
writer.writerow(columns)
|
||||||
|
|
||||||
|
# Write data
|
||||||
|
for instance in instances:
|
||||||
|
row = [getattr(instance, column) for column in columns]
|
||||||
|
writer.writerow(row)
|
||||||
|
|
||||||
|
# Create response
|
||||||
|
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
|
filename = f"{model_name}_{timestamp}.csv"
|
||||||
|
|
||||||
|
response = make_response(output.getvalue())
|
||||||
|
response.headers['Content-Disposition'] = f'attachment; filename={filename}'
|
||||||
|
response.headers['Content-type'] = 'text/csv'
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
@bp.route('/import/<model_name>', methods=['GET', 'POST'])
|
||||||
|
def import_model(model_name):
|
||||||
|
if model_name not in MODEL_MAP:
|
||||||
|
return jsonify({'error': 'Invalid model name'}), 400
|
||||||
|
|
||||||
|
model = MODEL_MAP[model_name]
|
||||||
|
|
||||||
|
if request.method == 'GET':
|
||||||
|
# Show import form
|
||||||
|
return render_template('import_form.html', model_name=model_name)
|
||||||
|
|
||||||
|
# Process CSV upload
|
||||||
|
if 'file' not in request.files:
|
||||||
|
return jsonify({'error': 'No file part'}), 400
|
||||||
|
|
||||||
|
file = request.files['file']
|
||||||
|
if file.filename == '':
|
||||||
|
return jsonify({'error': 'No selected file'}), 400
|
||||||
|
|
||||||
|
if not file.filename.endswith('.csv'):
|
||||||
|
return jsonify({'error': 'File must be CSV format'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Read CSV
|
||||||
|
stream = io.StringIO(file.stream.read().decode("UTF8"), newline=None)
|
||||||
|
csv_reader = csv.reader(stream)
|
||||||
|
|
||||||
|
# Get headers
|
||||||
|
headers = next(csv_reader)
|
||||||
|
|
||||||
|
# Validate required columns
|
||||||
|
required_columns = [col.name for col in model.__table__.columns
|
||||||
|
if not col.nullable and col.name != 'id']
|
||||||
|
|
||||||
|
for col in required_columns:
|
||||||
|
if col not in headers:
|
||||||
|
return jsonify({'error': f'Required column {col} missing'}), 400
|
||||||
|
|
||||||
|
# Process rows
|
||||||
|
imported = 0
|
||||||
|
for row in csv_reader:
|
||||||
|
data = dict(zip(headers, row))
|
||||||
|
|
||||||
|
# Remove id to create new record
|
||||||
|
if 'id' in data:
|
||||||
|
del data['id']
|
||||||
|
|
||||||
|
# Create new instance
|
||||||
|
instance = model(**data)
|
||||||
|
db.session.add(instance)
|
||||||
|
imported += 1
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({'success': f'Imported {imported} records successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
145
app/routes/ipam.py
Normal file
145
app/routes/ipam.py
Normal file
|
@ -0,0 +1,145 @@
|
||||||
|
from flask import Blueprint, render_template, redirect, url_for, request, flash, jsonify
|
||||||
|
from flask_login import login_required
|
||||||
|
from app.core.models import Subnet, Server
|
||||||
|
from app.core.extensions import db
|
||||||
|
from app.scripts.ip_scanner import scan
|
||||||
|
import ipaddress
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
bp = Blueprint('ipam', __name__, url_prefix='/ipam')
|
||||||
|
|
||||||
|
@bp.route('/')
|
||||||
|
@login_required
|
||||||
|
def ipam_home():
|
||||||
|
"""Main IPAM dashboard"""
|
||||||
|
subnets = Subnet.query.all()
|
||||||
|
|
||||||
|
# Calculate usage for each subnet
|
||||||
|
for subnet in subnets:
|
||||||
|
subnet.usage_percent = subnet.used_ips / 254 * 100 if subnet.cidr.endswith('/24') else 0
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'ipam/index.html',
|
||||||
|
title='IPAM Dashboard',
|
||||||
|
subnets=subnets,
|
||||||
|
now=datetime.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
@bp.route('/subnet/new', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def subnet_new():
|
||||||
|
"""Create a new subnet"""
|
||||||
|
if request.method == 'POST':
|
||||||
|
cidr = request.form.get('cidr')
|
||||||
|
location = request.form.get('location')
|
||||||
|
auto_scan = 'auto_scan' in request.form
|
||||||
|
|
||||||
|
# Basic validation
|
||||||
|
if not cidr or not location:
|
||||||
|
flash('Please fill in all required fields', 'danger')
|
||||||
|
return render_template(
|
||||||
|
'ipam/subnet_form.html',
|
||||||
|
title='New Subnet',
|
||||||
|
now=datetime.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if valid CIDR
|
||||||
|
try:
|
||||||
|
ipaddress.ip_network(cidr)
|
||||||
|
except ValueError:
|
||||||
|
flash('Invalid CIDR notation', 'danger')
|
||||||
|
return render_template(
|
||||||
|
'ipam/subnet_form.html',
|
||||||
|
title='New Subnet',
|
||||||
|
now=datetime.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if subnet already exists
|
||||||
|
if Subnet.query.filter_by(cidr=cidr).first():
|
||||||
|
flash('Subnet already exists', 'danger')
|
||||||
|
return render_template(
|
||||||
|
'ipam/subnet_form.html',
|
||||||
|
title='New Subnet',
|
||||||
|
now=datetime.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
subnet = Subnet(
|
||||||
|
cidr=cidr,
|
||||||
|
location=location,
|
||||||
|
auto_scan=auto_scan
|
||||||
|
)
|
||||||
|
db.session.add(subnet)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash('Subnet created successfully', 'success')
|
||||||
|
return redirect(url_for('ipam.subnet_view', subnet_id=subnet.id))
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'ipam/subnet_form.html',
|
||||||
|
title='New Subnet',
|
||||||
|
now=datetime.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
@bp.route('/subnet/<int:subnet_id>')
|
||||||
|
@login_required
|
||||||
|
def subnet_view(subnet_id):
|
||||||
|
"""View subnet details"""
|
||||||
|
subnet = Subnet.query.get_or_404(subnet_id)
|
||||||
|
servers = Server.query.filter_by(subnet_id=subnet_id).all()
|
||||||
|
|
||||||
|
# Get network info
|
||||||
|
network = ipaddress.ip_network(subnet.cidr)
|
||||||
|
total_ips = network.num_addresses - 2 # Excluding network and broadcast addresses
|
||||||
|
used_ips = len(servers)
|
||||||
|
usage_percent = (used_ips / total_ips) * 100 if total_ips > 0 else 0
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'ipam/subnet_view.html',
|
||||||
|
title=f'Subnet - {subnet.cidr}',
|
||||||
|
subnet=subnet,
|
||||||
|
servers=servers,
|
||||||
|
total_ips=total_ips,
|
||||||
|
used_ips=used_ips,
|
||||||
|
usage_percent=usage_percent,
|
||||||
|
now=datetime.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
@bp.route('/subnet/<int:subnet_id>/scan')
|
||||||
|
@login_required
|
||||||
|
def subnet_scan(subnet_id):
|
||||||
|
"""Scan a subnet for active hosts"""
|
||||||
|
subnet = Subnet.query.get_or_404(subnet_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = scan(subnet.cidr, save_results=True)
|
||||||
|
flash(f'Scan completed for subnet {subnet.cidr}. Found {len(results)} active hosts.', 'success')
|
||||||
|
except Exception as e:
|
||||||
|
flash(f'Error scanning subnet: {e}', 'danger')
|
||||||
|
|
||||||
|
return redirect(url_for('ipam.subnet_view', subnet_id=subnet_id))
|
||||||
|
|
||||||
|
@bp.route('/subnet/<int:subnet_id>/visualize')
|
||||||
|
@login_required
|
||||||
|
def subnet_visualize(subnet_id):
|
||||||
|
"""Visualize IP usage in a subnet"""
|
||||||
|
subnet = Subnet.query.get_or_404(subnet_id)
|
||||||
|
servers = Server.query.filter_by(subnet_id=subnet_id).all()
|
||||||
|
|
||||||
|
# Create a dictionary of used IPs
|
||||||
|
used_ips = {server.ip_address: server.hostname for server in servers}
|
||||||
|
|
||||||
|
# Get network info
|
||||||
|
network = ipaddress.ip_network(subnet.cidr)
|
||||||
|
total_ips = network.num_addresses - 2 # Excluding network and broadcast addresses
|
||||||
|
used_ip_count = len(servers)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'ipam/subnet_visualization.html',
|
||||||
|
title=f'Subnet Visualization - {subnet.cidr}',
|
||||||
|
subnet=subnet,
|
||||||
|
network=network,
|
||||||
|
used_ips=used_ips,
|
||||||
|
total_ips=total_ips,
|
||||||
|
used_ip_count=used_ip_count,
|
||||||
|
now=datetime.now()
|
||||||
|
)
|
BIN
app/scripts/__pycache__/db_seed.cpython-313.pyc
Normal file
BIN
app/scripts/__pycache__/db_seed.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/scripts/__pycache__/ip_scanner.cpython-313.pyc
Normal file
BIN
app/scripts/__pycache__/ip_scanner.cpython-313.pyc
Normal file
Binary file not shown.
27
app/scripts/db_seed.py
Normal file
27
app/scripts/db_seed.py
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
from app.core.extensions import db
|
||||||
|
from app.core.models import Subnet, Server, App
|
||||||
|
from app.core.auth import User
|
||||||
|
|
||||||
|
def seed_database():
|
||||||
|
# Example seed data for network objects
|
||||||
|
subnet = Subnet(cidr='192.168.1.0/24', location='Office', auto_scan=True)
|
||||||
|
server = Server(hostname='server1', ip_address='192.168.1.10', subnet=subnet)
|
||||||
|
app = App(name='Web App', server=server, documentation='# Welcome to Web App',
|
||||||
|
_ports='[{"port": 80, "type": "tcp", "desc": "Web"}]')
|
||||||
|
|
||||||
|
# Create a default user if none exists
|
||||||
|
if User.query.count() == 0:
|
||||||
|
admin = User(email="admin@example.com", is_admin=True)
|
||||||
|
admin.set_password("admin")
|
||||||
|
db.session.add(admin)
|
||||||
|
|
||||||
|
db.session.add(subnet)
|
||||||
|
db.session.add(server)
|
||||||
|
db.session.add(app)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.session.commit()
|
||||||
|
print("Database seeded successfully")
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
print(f"Error seeding database: {e}")
|
178
app/scripts/ip_scanner.py
Normal file
178
app/scripts/ip_scanner.py
Normal file
|
@ -0,0 +1,178 @@
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import ipaddress
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from app.core.extensions import db
|
||||||
|
from app.core.models import Subnet, Server
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
def scan(cidr, max_threads=10, save_results=False):
|
||||||
|
"""
|
||||||
|
Scan a subnet for active hosts
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cidr: The subnet in CIDR notation (e.g. "192.168.1.0/24")
|
||||||
|
max_threads: Maximum number of threads to use
|
||||||
|
save_results: Whether to save results to the database
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of dictionaries with IP, hostname, and status
|
||||||
|
"""
|
||||||
|
print(f"Starting scan of {cidr}")
|
||||||
|
network = ipaddress.ip_network(cidr)
|
||||||
|
|
||||||
|
# Skip network and broadcast addresses for IPv4
|
||||||
|
if network.version == 4:
|
||||||
|
hosts = list(network.hosts())
|
||||||
|
else:
|
||||||
|
# For IPv6, just take the first 100 addresses to avoid scanning too many
|
||||||
|
hosts = list(network.hosts())[:100]
|
||||||
|
|
||||||
|
# Split the hosts into chunks for multithreading
|
||||||
|
chunks = [[] for _ in range(max_threads)]
|
||||||
|
for i, host in enumerate(hosts):
|
||||||
|
chunks[i % max_threads].append(host)
|
||||||
|
|
||||||
|
# Initialize results
|
||||||
|
results = [[] for _ in range(max_threads)]
|
||||||
|
|
||||||
|
# Create and start threads
|
||||||
|
threads = []
|
||||||
|
for i in range(max_threads):
|
||||||
|
if chunks[i]: # Only start a thread if there are IPs to scan
|
||||||
|
t = threading.Thread(target=scan_worker, args=(chunks[i], results, i))
|
||||||
|
threads.append(t)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
# Wait for all threads to complete
|
||||||
|
for t in threads:
|
||||||
|
t.join()
|
||||||
|
|
||||||
|
# Combine results
|
||||||
|
all_results = []
|
||||||
|
for r in results:
|
||||||
|
all_results.extend(r)
|
||||||
|
|
||||||
|
# Save results to database if requested
|
||||||
|
if save_results:
|
||||||
|
try:
|
||||||
|
save_scan_results(cidr, all_results)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error saving scan results: {e}")
|
||||||
|
|
||||||
|
print(f"Scan completed. Found {len(all_results)} active hosts.")
|
||||||
|
return all_results
|
||||||
|
|
||||||
|
def scan_worker(ip_list, results, index):
|
||||||
|
"""Worker function for threading"""
|
||||||
|
for ip in ip_list:
|
||||||
|
if ping(ip):
|
||||||
|
hostname = get_hostname(ip)
|
||||||
|
results[index].append({
|
||||||
|
'ip': str(ip),
|
||||||
|
'hostname': hostname if hostname else str(ip),
|
||||||
|
'status': 'up'
|
||||||
|
})
|
||||||
|
|
||||||
|
def ping(ip):
|
||||||
|
"""Ping an IP address and return True if it responds"""
|
||||||
|
try:
|
||||||
|
# Faster timeout (1 second)
|
||||||
|
subprocess.check_output(['ping', '-c', '1', '-W', '1', str(ip)], stderr=subprocess.STDOUT)
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_hostname(ip):
|
||||||
|
"""Try to get the hostname for an IP address"""
|
||||||
|
try:
|
||||||
|
hostname = socket.gethostbyaddr(str(ip))[0]
|
||||||
|
return hostname
|
||||||
|
except (socket.herror, socket.gaierror):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def is_host_active_ping(ip):
|
||||||
|
"""Simple ICMP ping test (platform dependent)"""
|
||||||
|
import platform
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
param = '-n' if platform.system().lower() == 'windows' else '-c'
|
||||||
|
command = ['ping', param, '1', '-w', '1', ip]
|
||||||
|
|
||||||
|
try:
|
||||||
|
return subprocess.call(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0
|
||||||
|
except:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def save_scan_results(cidr, results):
|
||||||
|
"""Save scan results to the database"""
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
# Need to be in application context
|
||||||
|
if not hasattr(current_app, 'app_context'):
|
||||||
|
print("Not in Flask application context, cannot save results")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Find subnet by CIDR
|
||||||
|
subnet = Subnet.query.filter_by(cidr=cidr).first()
|
||||||
|
if not subnet:
|
||||||
|
print(f"Subnet {cidr} not found in database")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get existing servers in this subnet
|
||||||
|
existing_servers = {server.ip_address: server for server in Server.query.filter_by(subnet_id=subnet.id).all()}
|
||||||
|
|
||||||
|
# Process scan results
|
||||||
|
for host in results:
|
||||||
|
ip = host['ip']
|
||||||
|
hostname = host['hostname']
|
||||||
|
|
||||||
|
# Check if server already exists
|
||||||
|
if ip in existing_servers:
|
||||||
|
# Update hostname if it was previously unknown
|
||||||
|
if existing_servers[ip].hostname == ip and hostname != ip:
|
||||||
|
existing_servers[ip].hostname = hostname
|
||||||
|
db.session.add(existing_servers[ip])
|
||||||
|
else:
|
||||||
|
# Create new server
|
||||||
|
server = Server(
|
||||||
|
hostname=hostname,
|
||||||
|
ip_address=ip,
|
||||||
|
subnet_id=subnet.id,
|
||||||
|
documentation=f"# {hostname}\n\nAutomatically discovered by network scan on {time.strftime('%Y-%m-%d %H:%M:%S')}"
|
||||||
|
)
|
||||||
|
db.session.add(server)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
print(f"Saved scan results for {cidr}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
print(f"Error saving scan results: {e}")
|
||||||
|
|
||||||
|
def schedule_subnet_scans():
|
||||||
|
"""Schedule automatic scans for subnets marked as auto_scan"""
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
with current_app.app_context():
|
||||||
|
try:
|
||||||
|
# Find all subnets with auto_scan enabled
|
||||||
|
subnets = Subnet.query.filter_by(auto_scan=True).all()
|
||||||
|
|
||||||
|
for subnet in subnets:
|
||||||
|
# Start a thread for each subnet
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=scan,
|
||||||
|
args=(subnet.cidr,),
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
# Sleep briefly to avoid overloading
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error scheduling subnet scans: {e}")
|
183
app/static/css/app.css
Normal file
183
app/static/css/app.css
Normal file
|
@ -0,0 +1,183 @@
|
||||||
|
/* Custom styles for the app */
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
background-color: #f5f7fb;
|
||||||
|
color: #232e3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body {
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.125);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body pre {
|
||||||
|
background-color: #f6f8fa;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 16px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body th,
|
||||||
|
.markdown-body td {
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body th {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* IP Grid for subnet visualization */
|
||||||
|
.ip-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-cell {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.125);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-cell.used {
|
||||||
|
background-color: rgba(234, 88, 12, 0.1);
|
||||||
|
border-color: rgba(234, 88, 12, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-cell.available {
|
||||||
|
background-color: rgba(5, 150, 105, 0.1);
|
||||||
|
border-color: rgba(5, 150, 105, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats cards on dashboard */
|
||||||
|
.stats-card {
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom navbar styles */
|
||||||
|
.navbar-brand {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar styles */
|
||||||
|
.sidebar {
|
||||||
|
width: 260px;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 0 2rem 0 rgba(136, 152, 170, .15);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand {
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand-text {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-heading {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #8898aa;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item {
|
||||||
|
display: block;
|
||||||
|
padding: 0.675rem 1.2rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #525f7f;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item:hover {
|
||||||
|
color: #5e72e4;
|
||||||
|
background: rgba(94, 114, 228, 0.1);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item.active {
|
||||||
|
color: #5e72e4;
|
||||||
|
background: rgba(94, 114, 228, 0.1);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
margin-left: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive sidebar */
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.sidebar {
|
||||||
|
left: -260px;
|
||||||
|
transition: left 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.show {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Notification area */
|
||||||
|
#notification-area {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 9999;
|
||||||
|
width: 300px;
|
||||||
|
}
|
14
app/static/css/github-markdown.css
Normal file
14
app/static/css/github-markdown.css
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
/* GitHub Markdown CSS (simplified version) */
|
||||||
|
.markdown-body {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 45px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.markdown-body {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
}
|
142
app/static/js/app.js
Normal file
142
app/static/js/app.js
Normal file
|
@ -0,0 +1,142 @@
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
console.log('App script loaded.');
|
||||||
|
|
||||||
|
// Initialize Tiptap editor if element exists
|
||||||
|
const editorElement = document.getElementById('editor');
|
||||||
|
if (editorElement) {
|
||||||
|
initTiptapEditor(editorElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Bootstrap tooltips
|
||||||
|
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||||
|
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||||
|
return new bootstrap.Tooltip(tooltipTriggerEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close flash messages after 5 seconds
|
||||||
|
setTimeout(function () {
|
||||||
|
var alerts = document.querySelectorAll('.alert:not(.alert-persistent)');
|
||||||
|
alerts.forEach(function (alert) {
|
||||||
|
var bsAlert = bootstrap.Alert.getInstance(alert);
|
||||||
|
if (bsAlert) {
|
||||||
|
bsAlert.close();
|
||||||
|
} else {
|
||||||
|
alert.classList.add('fade');
|
||||||
|
setTimeout(function () {
|
||||||
|
alert.remove();
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
// Add event listener for subnet scan buttons with HTMX
|
||||||
|
document.body.addEventListener('htmx:afterOnLoad', function (event) {
|
||||||
|
if (event.detail.xhr.status === 200) {
|
||||||
|
showNotification('Subnet scan started successfully', 'success');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add markdown preview for documentation fields
|
||||||
|
const docTextareas = document.querySelectorAll('textarea[name="documentation"]');
|
||||||
|
docTextareas.forEach(function (textarea) {
|
||||||
|
// Only if preview container exists
|
||||||
|
const previewContainer = document.getElementById('markdown-preview');
|
||||||
|
if (previewContainer && textarea) {
|
||||||
|
textarea.addEventListener('input', function () {
|
||||||
|
// Use the server to render the markdown (safer)
|
||||||
|
fetch('/api/markdown-preview', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ content: textarea.value })
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
previewContainer.innerHTML = data.html;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function initTiptapEditor(element) {
|
||||||
|
// Load required Tiptap scripts
|
||||||
|
const editorContainer = document.getElementById('editor-container');
|
||||||
|
const preview = document.getElementById('markdown-preview');
|
||||||
|
|
||||||
|
// Initialize the Tiptap editor
|
||||||
|
const { Editor } = window.tiptap;
|
||||||
|
const { StarterKit } = window.tiptapExtensions;
|
||||||
|
|
||||||
|
const editor = new Editor({
|
||||||
|
element: element,
|
||||||
|
extensions: [
|
||||||
|
StarterKit
|
||||||
|
],
|
||||||
|
content: element.getAttribute('data-content') || '',
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
// Update preview with current content
|
||||||
|
if (preview) {
|
||||||
|
const markdown = editor.getHTML();
|
||||||
|
fetch('/api/markdown-preview', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ markdown: markdown })
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
preview.innerHTML = data.html;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store editor reference
|
||||||
|
window.editor = editor;
|
||||||
|
|
||||||
|
// Form submission handling
|
||||||
|
const form = element.closest('form');
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', () => {
|
||||||
|
const contentInput = form.querySelector('input[name="content"]');
|
||||||
|
if (contentInput) {
|
||||||
|
contentInput.value = editor.getHTML();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy to clipboard function
|
||||||
|
function copyToClipboard(text) {
|
||||||
|
navigator.clipboard.writeText(text).then(function () {
|
||||||
|
// Success notification
|
||||||
|
showNotification('Copied to clipboard!', 'success');
|
||||||
|
}, function (err) {
|
||||||
|
// Error notification
|
||||||
|
showNotification('Could not copy text', 'danger');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show notification
|
||||||
|
function showNotification(message, type = 'info') {
|
||||||
|
const notificationArea = document.getElementById('notification-area');
|
||||||
|
if (!notificationArea) return;
|
||||||
|
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.className = `alert alert-${type} alert-dismissible fade show`;
|
||||||
|
notification.innerHTML = `
|
||||||
|
${message}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
notificationArea.appendChild(notification);
|
||||||
|
|
||||||
|
// Remove notification after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.classList.remove('show');
|
||||||
|
setTimeout(() => notification.remove(), 150);
|
||||||
|
}, 3000);
|
||||||
|
}
|
9
app/static/js/clipboard.js
Normal file
9
app/static/js/clipboard.js
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
function copyToClipboard(text) {
|
||||||
|
const elem = document.createElement('textarea');
|
||||||
|
elem.value = text;
|
||||||
|
document.body.appendChild(elem);
|
||||||
|
elem.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(elem);
|
||||||
|
alert('Copied to clipboard');
|
||||||
|
}
|
50
app/templates/auth/login.html
Normal file
50
app/templates/auth/login.html
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6 col-lg-5">
|
||||||
|
<div class="card shadow-sm mt-5">
|
||||||
|
<div class="card-body p-5">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<h2 class="card-title">Login</h2>
|
||||||
|
<p class="text-muted">Sign in to your account</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('auth.login') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="email" class="form-label">Email address</label>
|
||||||
|
<input type="email" class="form-control" id="email" name="email" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="password" class="form-label">Password</label>
|
||||||
|
<input type="password" class="form-control" id="password" name="password" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4 form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="remember" name="remember">
|
||||||
|
<label class="form-check-label" for="remember">Remember me</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100">Login</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-4 text-center">
|
||||||
|
<p>Don't have an account? <a href="{{ url_for('auth.register') }}">Register here</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
50
app/templates/auth/register.html
Normal file
50
app/templates/auth/register.html
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6 col-lg-5">
|
||||||
|
<div class="card shadow-sm mt-5">
|
||||||
|
<div class="card-body p-5">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<h2 class="card-title">Register</h2>
|
||||||
|
<p class="text-muted">Create a new account</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('auth.register') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="email" class="form-label">Email address</label>
|
||||||
|
<input type="email" class="form-control" id="email" name="email" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Password</label>
|
||||||
|
<input type="password" class="form-control" id="password" name="password" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="password_confirm" class="form-label">Confirm Password</label>
|
||||||
|
<input type="password" class="form-control" id="password_confirm" name="password_confirm" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100">Register</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-4 text-center">
|
||||||
|
<p>Already have an account? <a href="{{ url_for('auth.login') }}">Login here</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
173
app/templates/components/app_form.html
Normal file
173
app/templates/components/app_form.html
Normal file
|
@ -0,0 +1,173 @@
|
||||||
|
<form hx-post="{{ action_url }}" hx-target="#content">
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label class="form-label">App Name</label>
|
||||||
|
<input type="text" name="name" class="form-control" value="{{ app.name if app else '' }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label class="form-label">App Type</label>
|
||||||
|
<select id="app-type" name="app_type" class="form-select">
|
||||||
|
<option value="">-- Select Type --</option>
|
||||||
|
<option value="web">Web Application</option>
|
||||||
|
<option value="database">Database</option>
|
||||||
|
<option value="mail">Mail Server</option>
|
||||||
|
<option value="file">File Server</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label class="form-label">Server</label>
|
||||||
|
<select name="server_id" class="form-select" required>
|
||||||
|
{% for server in servers %}
|
||||||
|
<option value="{{ server.id }}" {% if app and app.server_id==server.id %}selected{% endif %}>
|
||||||
|
{{ server.hostname }} ({{ server.ip_address }})
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label class="form-label">Documentation</label>
|
||||||
|
{% include "components/markdown_editor.html" with content=app.documentation if app else "" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="d-flex">
|
||||||
|
<h3 class="card-title">Ports</h3>
|
||||||
|
<div class="ms-auto">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" id="suggest-ports">
|
||||||
|
Suggest Ports
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="ports-container">
|
||||||
|
{% if app and app.ports %}
|
||||||
|
{% for port in app.ports %}
|
||||||
|
<div class="port-entry mb-2 row">
|
||||||
|
<div class="col-3">
|
||||||
|
<input type="number" name="port[]" class="form-control" value="{{ port.port }}" placeholder="Port" min="1"
|
||||||
|
max="65535">
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<select name="port_type[]" class="form-select">
|
||||||
|
<option value="tcp" {% if port.type=='tcp' %}selected{% endif %}>TCP</option>
|
||||||
|
<option value="udp" {% if port.type=='udp' %}selected{% endif %}>UDP</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-5">
|
||||||
|
<input type="text" name="port_desc[]" class="form-control" value="{{ port.desc }}"
|
||||||
|
placeholder="Description">
|
||||||
|
</div>
|
||||||
|
<div class="col-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-danger remove-port">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="port-entry mb-2 row">
|
||||||
|
<div class="col-3">
|
||||||
|
<input type="number" name="port[]" class="form-control" placeholder="Port" min="1" max="65535">
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<select name="port_type[]" class="form-select">
|
||||||
|
<option value="tcp">TCP</option>
|
||||||
|
<option value="udp">UDP</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-5">
|
||||||
|
<input type="text" name="port_desc[]" class="form-control" placeholder="Description">
|
||||||
|
</div>
|
||||||
|
<div class="col-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-danger remove-port">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" id="add-port">
|
||||||
|
Add Port
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<a href="{{ cancel_url }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
// Add new port entry
|
||||||
|
document.getElementById('add-port').addEventListener('click', function () {
|
||||||
|
const template = document.querySelector('.port-entry').cloneNode(true);
|
||||||
|
const inputs = template.querySelectorAll('input, select');
|
||||||
|
inputs.forEach(input => input.value = '');
|
||||||
|
if (inputs[0].type === 'number') inputs[0].value = '';
|
||||||
|
|
||||||
|
document.getElementById('ports-container').appendChild(template);
|
||||||
|
|
||||||
|
// Re-attach remove handlers
|
||||||
|
attachRemoveHandlers();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove port entry
|
||||||
|
function attachRemoveHandlers() {
|
||||||
|
document.querySelectorAll('.remove-port').forEach(button => {
|
||||||
|
button.onclick = function () {
|
||||||
|
const portEntries = document.querySelectorAll('.port-entry');
|
||||||
|
if (portEntries.length > 1) {
|
||||||
|
this.closest('.port-entry').remove();
|
||||||
|
} else {
|
||||||
|
const inputs = this.closest('.port-entry').querySelectorAll('input, select');
|
||||||
|
inputs.forEach(input => input.value = '');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
attachRemoveHandlers();
|
||||||
|
|
||||||
|
// Port suggestions based on app type
|
||||||
|
document.getElementById('suggest-ports').addEventListener('click', function () {
|
||||||
|
const appType = document.getElementById('app-type').value;
|
||||||
|
|
||||||
|
fetch(`/api/ports/suggest?type=${appType}`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
// Clear existing ports
|
||||||
|
const portsContainer = document.getElementById('ports-container');
|
||||||
|
portsContainer.innerHTML = '';
|
||||||
|
|
||||||
|
// Add suggested ports
|
||||||
|
data.forEach(port => {
|
||||||
|
const template = document.createElement('div');
|
||||||
|
template.className = 'port-entry mb-2 row';
|
||||||
|
template.innerHTML = `
|
||||||
|
<div class="col-3">
|
||||||
|
<input type="number" name="port[]" class="form-control" value="${port.port}" min="1" max="65535">
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<select name="port_type[]" class="form-select">
|
||||||
|
<option value="tcp" ${port.type === 'tcp' ? 'selected' : ''}>TCP</option>
|
||||||
|
<option value="udp" ${port.type === 'udp' ? 'selected' : ''}>UDP</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-5">
|
||||||
|
<input type="text" name="port_desc[]" class="form-control" value="${port.desc}" placeholder="Description">
|
||||||
|
</div>
|
||||||
|
<div class="col-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-danger remove-port">×</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
portsContainer.appendChild(template);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-attach remove handlers
|
||||||
|
attachRemoveHandlers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
105
app/templates/components/markdown_editor.html
Normal file
105
app/templates/components/markdown_editor.html
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
<div class="markdown-editor">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="editor-toolbar">
|
||||||
|
<button type="button" data-action="bold" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="ti ti-bold"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-action="italic" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="ti ti-italic"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-action="heading" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="ti ti-h-1"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-action="bulletList" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="ti ti-list"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-action="orderedList" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="ti ti-list-numbers"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-action="link" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="ti ti-link"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-action="code" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="ti ti-code"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="editor-container" class="mt-2">
|
||||||
|
<div id="editor"></div>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="{{ field_name }}" id="markdown-content" value="{{ content }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Preview</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div id="markdown-preview" class="markdown-body p-3"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
// Initialize Tiptap editor
|
||||||
|
const editor = new window.tiptap.Editor({
|
||||||
|
element: document.getElementById('editor'),
|
||||||
|
extensions: [
|
||||||
|
window.tiptapStarterKit.StarterKit,
|
||||||
|
window.tiptapLink.Link,
|
||||||
|
window.tiptapCodeBlock.CodeBlock,
|
||||||
|
window.tiptapImage.Image
|
||||||
|
],
|
||||||
|
content: document.getElementById('markdown-content').value || '',
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
// Update the hidden input with the markdown content
|
||||||
|
const markdown = editor.storage.markdown.getMarkdown();
|
||||||
|
document.getElementById('markdown-content').value = markdown;
|
||||||
|
|
||||||
|
// Update preview
|
||||||
|
updatePreview(markdown);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize the preview with the current content
|
||||||
|
updatePreview(editor.storage.markdown.getMarkdown());
|
||||||
|
|
||||||
|
// Initialize the toolbar buttons
|
||||||
|
document.querySelectorAll('.editor-toolbar button').forEach(button => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const action = button.dataset.action;
|
||||||
|
|
||||||
|
if (action === 'link') {
|
||||||
|
const url = prompt('URL');
|
||||||
|
if (url) {
|
||||||
|
editor.chain().focus().setLink({ href: url }).run();
|
||||||
|
}
|
||||||
|
} else if (action === 'heading') {
|
||||||
|
editor.chain().focus().toggleHeading({ level: 1 }).run();
|
||||||
|
} else {
|
||||||
|
editor.chain().focus()[`toggle${action.charAt(0).toUpperCase() + action.slice(1)}`]().run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Function to update preview
|
||||||
|
function updatePreview(markdown) {
|
||||||
|
const converter = new showdown.Converter({
|
||||||
|
tables: true,
|
||||||
|
tasklists: true,
|
||||||
|
strikethrough: true,
|
||||||
|
ghCodeBlocks: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const html = converter.makeHtml(markdown);
|
||||||
|
document.getElementById('markdown-preview').innerHTML = html;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
6
app/templates/components/port_row.html
Normal file
6
app/templates/components/port_row.html
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<div class="port-row" hx-target="this">
|
||||||
|
<span>{{ port.number }}/{{ port.protocol }}</span>
|
||||||
|
<button hx-get="/port/{{ port.id }}/copy" class="btn btn-sm">
|
||||||
|
Kopieren
|
||||||
|
</button>
|
||||||
|
</div>
|
9
app/templates/dashboard.html
Normal file
9
app/templates/dashboard.html
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
<div id="server-list">
|
||||||
|
<!-- Server list will be dynamically loaded here -->
|
||||||
|
</div>
|
||||||
|
<script src="/static/js/app.js"></script>
|
||||||
|
{% endblock %}
|
57
app/templates/dashboard/app_form.html
Normal file
57
app/templates/dashboard/app_form.html
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-xl">
|
||||||
|
<div class="page-header d-print-none">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col">
|
||||||
|
<h2 class="page-title">
|
||||||
|
Add New Application
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-body">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('dashboard.app_new') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label required">Application Name</label>
|
||||||
|
<input type="text" class="form-control" name="name" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label required">Server</label>
|
||||||
|
<select class="form-select" name="server_id" required>
|
||||||
|
<option value="">Select a server</option>
|
||||||
|
{% for server in servers %}
|
||||||
|
<option value="{{ server.id }}">{{ server.hostname }} ({{ server.ip_address }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Documentation</label>
|
||||||
|
<textarea class="form-control" name="documentation" rows="6"
|
||||||
|
placeholder="Use Markdown for formatting"></textarea>
|
||||||
|
<small class="form-hint">Supports Markdown formatting</small>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<a href="{{ url_for('dashboard.server_list') }}" class="btn btn-link me-2">Cancel</a>
|
||||||
|
<button type="submit" class="btn btn-primary">Save Application</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
125
app/templates/dashboard/index.html
Normal file
125
app/templates/dashboard/index.html
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-xl">
|
||||||
|
<div class="page-header d-print-none">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col">
|
||||||
|
<h2 class="page-title">Dashboard</h2>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto ms-auto d-print-none">
|
||||||
|
<div class="btn-list">
|
||||||
|
<a href="{{ url_for('dashboard.server_new') }}" class="btn btn-primary d-none d-sm-inline-block">
|
||||||
|
<i class="ti ti-plus"></i> New Server
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card stats-card">
|
||||||
|
<div class="card-body p-4 text-center">
|
||||||
|
<div class="h1 m-0">{{ server_count }}</div>
|
||||||
|
<div class="text-muted mb-3">Servers</div>
|
||||||
|
<div class="d-flex justify-content-center">
|
||||||
|
<a href="{{ url_for('dashboard.server_list') }}" class="btn btn-sm btn-primary">
|
||||||
|
View All
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('dashboard.server_new') }}" class="btn btn-sm btn-outline-primary ms-2">
|
||||||
|
Add New
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card stats-card">
|
||||||
|
<div class="card-body p-4 text-center">
|
||||||
|
<div class="h1 m-0">{{ subnet_count }}</div>
|
||||||
|
<div class="text-muted mb-3">Subnets</div>
|
||||||
|
<div class="d-flex justify-content-center">
|
||||||
|
<a href="{{ url_for('ipam.ipam_home') }}" class="btn btn-sm btn-primary">
|
||||||
|
View All
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card stats-card">
|
||||||
|
<div class="card-body p-4 text-center">
|
||||||
|
<div class="h1 m-0">{{ app_count }}</div>
|
||||||
|
<div class="text-muted mb-3">Applications</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Recent Servers</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if latest_servers %}
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
{% for server in latest_servers %}
|
||||||
|
<a href="{{ url_for('dashboard.server_view', server_id=server.id) }}"
|
||||||
|
class="list-group-item list-group-item-action">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-auto">
|
||||||
|
<span class="avatar bg-primary text-white">
|
||||||
|
{{ server.hostname[0].upper() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="col text-truncate">
|
||||||
|
<div class="d-block text-truncate">{{ server.hostname }}</div>
|
||||||
|
<div class="text-muted text-truncate small">{{ server.ip_address }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-3">
|
||||||
|
<div class="mb-3">No servers added yet</div>
|
||||||
|
<a href="{{ url_for('dashboard.server_new') }}" class="btn btn-outline-primary">
|
||||||
|
Add Your First Server
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Subnet Utilization</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if subnets %}
|
||||||
|
{% for subnet in subnets %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<div>{{ subnet.cidr }}</div>
|
||||||
|
<div>{{ subnet.used_ips }} / 254 IPs</div>
|
||||||
|
</div>
|
||||||
|
<div class="progress">
|
||||||
|
<div class="progress-bar bg-primary" style="width: {{ subnet.usage_percent }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-3">
|
||||||
|
<div class="mb-3">No subnets added yet</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
61
app/templates/dashboard/server_form.html
Normal file
61
app/templates/dashboard/server_form.html
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-xl">
|
||||||
|
<div class="page-header d-print-none">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col">
|
||||||
|
<h2 class="page-title">
|
||||||
|
Add New Server
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-body">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('dashboard.server_new') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label required">Hostname</label>
|
||||||
|
<input type="text" class="form-control" name="hostname" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label required">IP Address</label>
|
||||||
|
<input type="text" class="form-control" name="ip_address" placeholder="192.168.1.10" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label required">Subnet</label>
|
||||||
|
<select class="form-select" name="subnet_id" required>
|
||||||
|
<option value="">Select a subnet</option>
|
||||||
|
{% for subnet in subnets %}
|
||||||
|
<option value="{{ subnet.id }}">{{ subnet.cidr }} ({{ subnet.location }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Documentation</label>
|
||||||
|
<textarea class="form-control" name="documentation" rows="6"
|
||||||
|
placeholder="Use Markdown for formatting"></textarea>
|
||||||
|
<small class="form-hint">Supports Markdown formatting</small>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<a href="{{ url_for('dashboard.server_list') }}" class="btn btn-link me-2">Cancel</a>
|
||||||
|
<button type="submit" class="btn btn-primary">Save Server</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
62
app/templates/dashboard/server_list.html
Normal file
62
app/templates/dashboard/server_list.html
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-xl">
|
||||||
|
<div class="page-header d-print-none">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col">
|
||||||
|
<h2 class="page-title">
|
||||||
|
Servers
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto ms-auto">
|
||||||
|
<a href="{{ url_for('dashboard.server_new') }}" class="btn btn-primary">
|
||||||
|
<i class="fas fa-plus me-2"></i> Add Server
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-body">
|
||||||
|
{% if servers %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-vcenter table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Hostname</th>
|
||||||
|
<th>IP Address</th>
|
||||||
|
<th>Subnet</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th class="w-1"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for server in servers %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ server.hostname }}</td>
|
||||||
|
<td>{{ server.ip_address }}</td>
|
||||||
|
<td>{{ server.subnet.cidr if server.subnet else 'N/A' }}</td>
|
||||||
|
<td>{{ server.created_at.strftime('%Y-%m-%d') }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('dashboard.server_view', server_id=server.id) }}" class="btn btn-sm btn-primary">
|
||||||
|
View
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="mb-3">No servers added yet</div>
|
||||||
|
<a href="{{ url_for('dashboard.server_new') }}" class="btn btn-primary">
|
||||||
|
Add Your First Server
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
142
app/templates/dashboard/server_view.html
Normal file
142
app/templates/dashboard/server_view.html
Normal file
|
@ -0,0 +1,142 @@
|
||||||
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-xl">
|
||||||
|
<div class="page-header d-print-none">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col">
|
||||||
|
<h2 class="page-title">
|
||||||
|
{{ server.hostname }}
|
||||||
|
</h2>
|
||||||
|
<div class="text-muted mt-1">{{ server.ip_address }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto ms-auto d-print-none">
|
||||||
|
<a href="{{ url_for('dashboard.server_list') }}" class="btn btn-link">
|
||||||
|
Back to Servers
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Documentation</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body markdown-body">
|
||||||
|
{% if server.documentation %}
|
||||||
|
{{ markdown(server.documentation)|safe }}
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center text-muted py-3">
|
||||||
|
No documentation available for this server.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<h3 class="card-title">Applications</h3>
|
||||||
|
<a href="{{ url_for('dashboard.app_new') }}" class="btn btn-sm btn-primary">
|
||||||
|
Add Application
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if apps %}
|
||||||
|
<table class="table table-vcenter">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Ports</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for app in apps %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ app.name }}</td>
|
||||||
|
<td>
|
||||||
|
{% for port in app.ports %}
|
||||||
|
<span class="badge bg-primary">
|
||||||
|
{{ port.port }}/{{ port.type }} {% if port.desc %}({{ port.desc }}){% endif %}
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-3">
|
||||||
|
<div class="mb-3">No applications registered for this server</div>
|
||||||
|
<a href="{{ url_for('dashboard.app_new') }}" class="btn btn-outline-primary">
|
||||||
|
Add Application
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Server Information</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-2">
|
||||||
|
<strong>Hostname:</strong> {{ server.hostname }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<strong>IP Address:</strong> {{ server.ip_address }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<strong>Subnet:</strong> {{ server.subnet.cidr if server.subnet else 'N/A' }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<strong>Location:</strong> {{ server.subnet.location if server.subnet else 'N/A' }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<strong>Created:</strong> {{ server.created_at.strftime('%Y-%m-%d') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Open Ports</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if server.get_open_ports() %}
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
{% for port in server.get_open_ports() %}
|
||||||
|
<div class="list-group-item">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-auto">
|
||||||
|
<span class="badge bg-primary">{{ port.port }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="text-truncate">
|
||||||
|
{{ port.type|upper }}
|
||||||
|
{% if port.desc %}
|
||||||
|
<span class="text-muted">{{ port.desc }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center text-muted py-3">
|
||||||
|
No open ports detected.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
14
app/templates/errors/404.html
Normal file
14
app/templates/errors/404.html
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container text-center py-5">
|
||||||
|
<div class="display-1 text-muted mb-3">404</div>
|
||||||
|
<h1 class="h2 mb-3">Page not found</h1>
|
||||||
|
<p class="h4 text-muted font-weight-normal mb-4">
|
||||||
|
We are sorry but the page you are looking for was not found.
|
||||||
|
</p>
|
||||||
|
<a href="{{ url_for('dashboard.dashboard_home') }}" class="btn btn-primary">
|
||||||
|
<i class="ti ti-arrow-left me-2"></i> Return to dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
14
app/templates/errors/500.html
Normal file
14
app/templates/errors/500.html
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container text-center py-5">
|
||||||
|
<div class="display-1 text-muted mb-3">500</div>
|
||||||
|
<h1 class="h2 mb-3">Internal Server Error</h1>
|
||||||
|
<p class="h4 text-muted font-weight-normal mb-4">
|
||||||
|
Something went wrong on our end. Please try again later.
|
||||||
|
</p>
|
||||||
|
<a href="{{ url_for('dashboard.dashboard_home') }}" class="btn btn-primary">
|
||||||
|
<i class="ti ti-arrow-left me-2"></i> Return to dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
56
app/templates/import_form.html
Normal file
56
app/templates/import_form.html
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container">
|
||||||
|
<h1>Import {{ model_name|capitalize }} Data</h1>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" enctype="multipart/form-data" id="import-form">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">CSV File</label>
|
||||||
|
<input type="file" name="file" class="form-control" accept=".csv" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<button type="submit" class="btn btn-primary">Import</button>
|
||||||
|
<a href="/import-export/template/{{ model_name }}" class="btn btn-outline-secondary">
|
||||||
|
Download Template
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="import-result" class="mt-3" style="display: none;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('import-form').addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formData = new FormData(this);
|
||||||
|
|
||||||
|
fetch('/import-export/import/{{ model_name }}', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
const resultDiv = document.getElementById('import-result');
|
||||||
|
resultDiv.style.display = 'block';
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
resultDiv.innerHTML = `<div class="alert alert-danger">${data.error}</div>`;
|
||||||
|
} else {
|
||||||
|
resultDiv.innerHTML = `<div class="alert alert-success">${data.success}</div>`;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
document.getElementById('import-result').innerHTML =
|
||||||
|
'<div class="alert alert-danger">An error occurred during import</div>';
|
||||||
|
document.getElementById('import-result').style.display = 'block';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
60
app/templates/ipam/dashboard.html
Normal file
60
app/templates/ipam/dashboard.html
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container">
|
||||||
|
<h1>IPAM Dashboard</h1>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Subnetz-Übersicht</h3>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class="btn btn-primary" hx-get="/ipam/subnet/new" hx-target="#modal-container">
|
||||||
|
Neues Subnetz
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-vcenter card-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>CIDR</th>
|
||||||
|
<th>Standort</th>
|
||||||
|
<th>IP-Belegung</th>
|
||||||
|
<th>Auto-Scan</th>
|
||||||
|
<th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for subnet in subnets %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ subnet.cidr }}</td>
|
||||||
|
<td>{{ subnet.location }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="progress progress-sm">
|
||||||
|
{% set percentage = (subnet.used_ips / subnet.total_ips * 100) | int %}
|
||||||
|
<div class="progress-bar bg-blue" style="width: {{ percentage }}%" role="progressbar"
|
||||||
|
aria-valuenow="{{ percentage }}" aria-valuemin="0" aria-valuemax="100">
|
||||||
|
{{ percentage }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ "Ja" if subnet.auto_scan else "Nein" }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="/ipam/subnet/{{ subnet.id }}" class="btn btn-sm btn-outline-primary">Details</a>
|
||||||
|
<button hx-post="/ipam/subnet/{{ subnet.id }}/scan" hx-swap="none"
|
||||||
|
class="btn btn-sm btn-outline-secondary" {% if not subnet.auto_scan %}disabled{% endif %}>
|
||||||
|
Scan starten
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modal-container"></div>
|
||||||
|
{% endblock %}
|
81
app/templates/ipam/index.html
Normal file
81
app/templates/ipam/index.html
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-xl">
|
||||||
|
<div class="page-header d-print-none">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col">
|
||||||
|
<h2 class="page-title">
|
||||||
|
IP Address Management
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto ms-auto">
|
||||||
|
<a href="{{ url_for('ipam.subnet_new') }}" class="btn btn-primary">
|
||||||
|
<i class="fas fa-plus me-2"></i> Add Subnet
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-body">
|
||||||
|
{% if subnets %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-vcenter table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Subnet</th>
|
||||||
|
<th>Location</th>
|
||||||
|
<th>Usage</th>
|
||||||
|
<th>Auto Scan</th>
|
||||||
|
<th class="w-1"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for subnet in subnets %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ subnet.cidr }}</td>
|
||||||
|
<td>{{ subnet.location }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="me-2">{{ subnet.used_ips }}/254</div>
|
||||||
|
<div class="progress flex-grow-1" style="height: 5px;">
|
||||||
|
<div class="progress-bar bg-primary" style="width: {{ subnet.usage_percent }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if subnet.auto_scan %}
|
||||||
|
<span class="badge bg-success">Enabled</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Disabled</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group">
|
||||||
|
<a href="{{ url_for('ipam.subnet_view', subnet_id=subnet.id) }}" class="btn btn-sm btn-primary">
|
||||||
|
View
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('ipam.subnet_scan', subnet_id=subnet.id) }}"
|
||||||
|
class="btn btn-sm btn-outline-primary">
|
||||||
|
Scan
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="mb-3">No subnets added yet</div>
|
||||||
|
<a href="{{ url_for('ipam.subnet_new') }}" class="btn btn-primary">
|
||||||
|
Add Your First Subnet
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
149
app/templates/ipam/subnet_detail.html
Normal file
149
app/templates/ipam/subnet_detail.html
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container">
|
||||||
|
<div class="d-flex mb-3">
|
||||||
|
<div>
|
||||||
|
<h1>Subnetz: {{ subnet.cidr }}</h1>
|
||||||
|
<p>Standort: {{ subnet.location }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="ms-auto">
|
||||||
|
<button class="btn btn-outline-primary" hx-get="/ipam/subnet/{{ subnet.id }}/export" hx-swap="none">
|
||||||
|
CSV-Export
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title">Subnetz-Details</h3>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Netzwerk-Adresse</label>
|
||||||
|
<div>{{ subnet.cidr.split('/')[0] }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Präfixlänge</label>
|
||||||
|
<div>{{ subnet.cidr.split('/')[1] }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Erste nutzbare IP</label>
|
||||||
|
<div>{{ first_ip }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Letzte nutzbare IP</label>
|
||||||
|
<div>{{ last_ip }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Gesamte IPs</label>
|
||||||
|
<div>{{ total_ips }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Belegte IPs</label>
|
||||||
|
<div>{{ used_ips }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">IP-Belegung</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="subnet-visualization" class="subnet-map">
|
||||||
|
<!-- Wird via JavaScript befüllt -->
|
||||||
|
<div class="text-center p-3">
|
||||||
|
<div class="spinner-border text-blue" role="status"></div>
|
||||||
|
<div>Lade Daten...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
// Load the subnet visualization
|
||||||
|
fetch('/ipam/subnet/{{ subnet.id }}/visualization')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
const container = document.getElementById('subnet-visualization');
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
// Create a grid of IP boxes
|
||||||
|
const grid = document.createElement('div');
|
||||||
|
grid.className = 'ip-grid';
|
||||||
|
|
||||||
|
data.forEach(ip => {
|
||||||
|
const box = document.createElement('div');
|
||||||
|
box.className = `ip-box ${ip.status}`;
|
||||||
|
box.title = ip.hostname ? `${ip.ip} - ${ip.hostname}` : ip.ip;
|
||||||
|
|
||||||
|
const addressSpan = document.createElement('span');
|
||||||
|
addressSpan.className = 'ip-address';
|
||||||
|
addressSpan.textContent = ip.ip.split('.')[3]; // Only show last octet
|
||||||
|
|
||||||
|
box.appendChild(addressSpan);
|
||||||
|
|
||||||
|
if (ip.hostname) {
|
||||||
|
const hostnameSpan = document.createElement('span');
|
||||||
|
hostnameSpan.className = 'ip-hostname';
|
||||||
|
hostnameSpan.textContent = ip.hostname;
|
||||||
|
box.appendChild(hostnameSpan);
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.appendChild(box);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.appendChild(grid);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error loading subnet visualization:', error);
|
||||||
|
document.getElementById('subnet-visualization').innerHTML =
|
||||||
|
'<div class="alert alert-danger">Fehler beim Laden der Visualisierung</div>';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ip-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-box {
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
padding: 5px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-box.used {
|
||||||
|
background-color: #e6f2ff;
|
||||||
|
border-color: #99c2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-box.available {
|
||||||
|
background-color: #f2f2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-address {
|
||||||
|
display: block;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-hostname {
|
||||||
|
display: block;
|
||||||
|
font-size: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
51
app/templates/ipam/subnet_form.html
Normal file
51
app/templates/ipam/subnet_form.html
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-xl">
|
||||||
|
<div class="page-header d-print-none">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col">
|
||||||
|
<h2 class="page-title">
|
||||||
|
Add New Subnet
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-body">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('ipam.subnet_new') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label required">CIDR Notation</label>
|
||||||
|
<input type="text" class="form-control" name="cidr" placeholder="192.168.1.0/24" required>
|
||||||
|
<small class="form-hint">Example: 192.168.1.0/24</small>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label required">Location</label>
|
||||||
|
<input type="text" class="form-control" name="location" placeholder="Office, Datacenter, etc." required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="auto_scan" name="auto_scan">
|
||||||
|
<label class="form-check-label" for="auto_scan">Enable automatic scanning</label>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<a href="{{ url_for('ipam.ipam_home') }}" class="btn btn-link me-2">Cancel</a>
|
||||||
|
<button type="submit" class="btn btn-primary">Save Subnet</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
132
app/templates/ipam/subnet_view.html
Normal file
132
app/templates/ipam/subnet_view.html
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-xl">
|
||||||
|
<div class="page-header d-print-none">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col">
|
||||||
|
<h2 class="page-title">
|
||||||
|
{{ subnet.cidr }}
|
||||||
|
</h2>
|
||||||
|
<div class="text-muted mt-1">{{ subnet.location }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto ms-auto d-print-none">
|
||||||
|
<div class="btn-list">
|
||||||
|
<a href="{{ url_for('ipam.subnet_scan', subnet_id=subnet.id) }}" class="btn btn-outline-primary">
|
||||||
|
<i class="fas fa-search me-2"></i> Scan Subnet
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('ipam.subnet_visualize', subnet_id=subnet.id) }}" class="btn btn-outline-primary">
|
||||||
|
<i class="fas fa-chart-network me-2"></i> Visualize
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('ipam.ipam_home') }}" class="btn btn-link">
|
||||||
|
Back to IPAM
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Registered Hosts</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if servers %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-vcenter">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Hostname</th>
|
||||||
|
<th>IP Address</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th class="w-1"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for server in servers %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ server.hostname }}</td>
|
||||||
|
<td>{{ server.ip_address }}</td>
|
||||||
|
<td>{{ server.created_at.strftime('%Y-%m-%d') }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('dashboard.server_view', server_id=server.id) }}"
|
||||||
|
class="btn btn-sm btn-primary">
|
||||||
|
View
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-3">
|
||||||
|
<div class="mb-3">No hosts registered in this subnet</div>
|
||||||
|
<a href="{{ url_for('dashboard.server_new') }}" class="btn btn-outline-primary">
|
||||||
|
Add New Server
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('ipam.subnet_scan', subnet_id=subnet.id) }}" class="btn btn-outline-primary ms-2">
|
||||||
|
Scan Subnet
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Subnet Information</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-2">
|
||||||
|
<strong>Network:</strong> {{ subnet.cidr }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<strong>Location:</strong> {{ subnet.location }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<strong>Total IPs:</strong> {{ total_ips }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<strong>Used IPs:</strong> {{ used_ips }} ({{ '%.1f'|format(usage_percent) }}%)
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<strong>Available IPs:</strong> {{ total_ips - used_ips }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<strong>Auto Scan:</strong>
|
||||||
|
{% if subnet.auto_scan %}
|
||||||
|
<span class="badge bg-success">Enabled</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Disabled</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<strong>Created:</strong> {{ subnet.created_at.strftime('%Y-%m-%d') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Actions</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<a href="{{ url_for('ipam.subnet_scan', subnet_id=subnet.id) }}" class="btn btn-outline-primary">
|
||||||
|
<i class="fas fa-search me-2"></i> Scan Now
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('ipam.subnet_visualize', subnet_id=subnet.id) }}" class="btn btn-outline-primary">
|
||||||
|
<i class="fas fa-chart-network me-2"></i> IP Visualization
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
206
app/templates/ipam/subnet_visualization.html
Normal file
206
app/templates/ipam/subnet_visualization.html
Normal file
|
@ -0,0 +1,206 @@
|
||||||
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-xl">
|
||||||
|
<div class="page-header d-print-none">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col">
|
||||||
|
<h2 class="page-title">
|
||||||
|
IP Visualization - {{ subnet.cidr }}
|
||||||
|
</h2>
|
||||||
|
<div class="text-muted mt-1">{{ subnet.location }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto ms-auto d-print-none">
|
||||||
|
<a href="{{ url_for('ipam.subnet_view', subnet_id=subnet.id) }}" class="btn btn-link">
|
||||||
|
Back to Subnet
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<h3 class="card-title">IP Address Map</h3>
|
||||||
|
<div>
|
||||||
|
<span class="badge bg-success me-2">Available: {{ total_ips - used_ip_count }}</span>
|
||||||
|
<span class="badge bg-danger">Used: {{ used_ip_count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="ip-grid">
|
||||||
|
{% set network_parts = subnet.cidr.split('/')[0].split('.') %}
|
||||||
|
{% set network_prefix = network_parts[0] + '.' + network_parts[1] + '.' + network_parts[2] + '.' %}
|
||||||
|
|
||||||
|
{% for i in range(1, 255) %}
|
||||||
|
{% set ip = network_prefix + i|string %}
|
||||||
|
{% if ip in used_ips %}
|
||||||
|
<div class="ip-cell used" title="{{ used_ips[ip] }}">
|
||||||
|
{{ ip }}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="ip-cell available" title="Available">
|
||||||
|
{{ ip }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
loadIpMap();
|
||||||
|
|
||||||
|
// Handle scan button response
|
||||||
|
document.body.addEventListener('htmx:afterRequest', function (event) {
|
||||||
|
if (event.detail.target.matches('button[hx-post^="/ipam/subnet/"][hx-post$="/scan"]')) {
|
||||||
|
if (event.detail.successful) {
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'toast align-items-center show position-fixed bottom-0 end-0 m-3';
|
||||||
|
toast.setAttribute('role', 'alert');
|
||||||
|
toast.setAttribute('aria-live', 'assertive');
|
||||||
|
toast.setAttribute('aria-atomic', 'true');
|
||||||
|
|
||||||
|
toast.innerHTML = `
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="toast-body">
|
||||||
|
Subnet scan started. Results will be available shortly.
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
// Auto-remove toast after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.remove();
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
// Reload IP map after a delay to allow scan to complete
|
||||||
|
setTimeout(() => {
|
||||||
|
loadIpMap();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function loadIpMap() {
|
||||||
|
const ipMap = document.getElementById('ip-map');
|
||||||
|
const loadingIndicator = document.getElementById('loading-indicator');
|
||||||
|
|
||||||
|
fetch('/ipam/subnet/{{ subnet.id }}/visualization')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
// Hide loading indicator
|
||||||
|
loadingIndicator.style.display = 'none';
|
||||||
|
|
||||||
|
// Create IP grid
|
||||||
|
const grid = document.createElement('div');
|
||||||
|
grid.className = 'ip-grid';
|
||||||
|
|
||||||
|
// Add each IP address to the grid
|
||||||
|
data.forEach(ip => {
|
||||||
|
const cell = document.createElement('div');
|
||||||
|
cell.className = `ip-cell ${ip.status}`;
|
||||||
|
|
||||||
|
// Add IP address
|
||||||
|
const ipAddress = document.createElement('span');
|
||||||
|
ipAddress.className = 'ip-address';
|
||||||
|
ipAddress.textContent = ip.ip;
|
||||||
|
cell.appendChild(ipAddress);
|
||||||
|
|
||||||
|
// Add hostname if exists
|
||||||
|
if (ip.hostname) {
|
||||||
|
const hostname = document.createElement('span');
|
||||||
|
hostname.className = 'ip-hostname';
|
||||||
|
hostname.textContent = ip.hostname;
|
||||||
|
cell.appendChild(hostname);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add click handler for details
|
||||||
|
cell.addEventListener('click', () => {
|
||||||
|
if (ip.status === 'used') {
|
||||||
|
window.location.href = `/dashboard/server/${ip.server_id}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
grid.appendChild(cell);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipMap.appendChild(grid);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error loading subnet visualization:', error);
|
||||||
|
document.getElementById('ip-map').innerHTML =
|
||||||
|
'<div class="alert alert-danger">Error loading subnet visualization. Please try again later.</div>';
|
||||||
|
document.getElementById('loading-indicator').style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-grid-container {
|
||||||
|
min-height: 400px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-cell {
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
height: 50px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-cell:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
z-index: 10;
|
||||||
|
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-cell.used {
|
||||||
|
background-color: rgba(32, 107, 196, 0.1);
|
||||||
|
border-color: rgba(32, 107, 196, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-cell.available {
|
||||||
|
background-color: rgba(5, 150, 105, 0.1);
|
||||||
|
border-color: rgba(5, 150, 105, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-address {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-hostname {
|
||||||
|
font-size: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
147
app/templates/layout.html
Normal file
147
app/templates/layout.html
Normal file
|
@ -0,0 +1,147 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ title if title else 'Network Documentation' }}</title>
|
||||||
|
<!-- Bootstrap CSS -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<!-- Tabler Icons -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@2.22.0/tabler-icons.min.css">
|
||||||
|
<!-- Font Awesome -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
<!-- Google Fonts -->
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<!-- Custom CSS -->
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
|
||||||
|
{% block styles %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="{{ 'auth-page' if not current_user.is_authenticated else '' }}">
|
||||||
|
<!-- Notification Area -->
|
||||||
|
<div id="notification-area"></div>
|
||||||
|
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<!-- Sidebar for authenticated users -->
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-brand">
|
||||||
|
<span class="ti ti-network"></span>
|
||||||
|
<span class="sidebar-brand-text">NetDocs</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-nav">
|
||||||
|
<div class="sidebar-heading">Main</div>
|
||||||
|
<a href="{{ url_for('dashboard.dashboard_home') }}"
|
||||||
|
class="sidebar-item {{ 'active' if request.endpoint == 'dashboard.dashboard_home' }}">
|
||||||
|
<span class="ti ti-dashboard me-2"></span> Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('dashboard.server_list') }}"
|
||||||
|
class="sidebar-item {{ 'active' if request.endpoint and request.endpoint.startswith('dashboard.server') }}">
|
||||||
|
<span class="ti ti-server me-2"></span> Servers
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('ipam.ipam_home') }}"
|
||||||
|
class="sidebar-item {{ 'active' if request.endpoint and request.endpoint.startswith('ipam.') }}">
|
||||||
|
<span class="ti ti-network me-2"></span> IPAM
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="sidebar-heading">Management</div>
|
||||||
|
<a href="{{ url_for('dashboard.server_new') }}" class="sidebar-item">
|
||||||
|
<span class="ti ti-plus me-2"></span> Add Server
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('ipam.subnet_new') }}" class="sidebar-item">
|
||||||
|
<span class="ti ti-plus me-2"></span> Add Subnet
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="sidebar-heading">User</div>
|
||||||
|
<a href="{{ url_for('auth.logout') }}" class="sidebar-item">
|
||||||
|
<span class="ti ti-logout me-2"></span> Logout
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Content for authenticated users -->
|
||||||
|
<div class="main-content">
|
||||||
|
<!-- Top Navbar -->
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-light bg-white border-bottom mb-4">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<button class="sidebar-toggler btn btn-outline-secondary d-lg-none me-2">
|
||||||
|
<span class="ti ti-menu-2"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span class="navbar-brand d-none d-lg-block">
|
||||||
|
{{ title if title else 'Network Documentation' }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="dropdown">
|
||||||
|
<a href="#" class="d-flex align-items-center text-decoration-none dropdown-toggle"
|
||||||
|
id="user-dropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<span class="d-none d-md-inline me-2">{{ current_user.email }}</span>
|
||||||
|
<span class="ti ti-user"></span>
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="user-dropdown">
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">Logout</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Flash Messages -->
|
||||||
|
<div class="container-fluid mt-3">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<!-- Simple flash message container for non-authenticated users -->
|
||||||
|
<div class="container mt-3">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- ONLY ONE CONTENT BLOCK FOR BOTH AUTHENTICATED AND NON-AUTHENTICATED STATES -->
|
||||||
|
<div class="{{ 'py-4' if current_user.is_authenticated else '' }}">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
</div> <!-- End of main-content div that was opened for authenticated users -->
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Bootstrap JS -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<!-- HTMX for dynamic content -->
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.2"></script>
|
||||||
|
<!-- Custom JS -->
|
||||||
|
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
// Sidebar toggle for mobile - using modern event listener approach
|
||||||
|
const sidebarToggler = document.querySelector('.sidebar-toggler');
|
||||||
|
if (sidebarToggler) {
|
||||||
|
sidebarToggler.addEventListener('click', () => {
|
||||||
|
document.querySelector('.sidebar').classList.toggle('show');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
15
app/templates/server.html
Normal file
15
app/templates/server.html
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
{% extends 'layout.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>Server Details - {{ server.hostname }}</h2>
|
||||||
|
<div>
|
||||||
|
<p><strong>IP Address:</strong> {{ server.ip_address }}</p>
|
||||||
|
<p><strong>Open Ports:</strong> {{ server.get_open_ports() | join(', ') }}</p>
|
||||||
|
<div class="markdown-body">
|
||||||
|
<h3>Documentation:</h3>
|
||||||
|
<div id="markdown-preview">
|
||||||
|
{{ markdown(server.documentation) | safe }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
25
config/Dockerfile
Normal file
25
config/Dockerfile
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
gcc \
|
||||||
|
libpq-dev \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
RUN pip install gunicorn psycopg2-binary redis
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
CMD ["gunicorn", "-w", "4", "-b", ":8000", "app:create_app()"]
|
BIN
config/__pycache__/settings.cpython-313.pyc
Normal file
BIN
config/__pycache__/settings.cpython-313.pyc
Normal file
Binary file not shown.
53
config/docker-compose.yml
Normal file
53
config/docker-compose.yml
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
version: '3'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: config/Dockerfile
|
||||||
|
command: gunicorn -w 4 -b :8000 "app:create_app()" --access-logfile - --error-logfile -
|
||||||
|
volumes:
|
||||||
|
- ../app:/app/app
|
||||||
|
environment:
|
||||||
|
- FLASK_APP=app
|
||||||
|
- FLASK_ENV=production
|
||||||
|
- DATABASE_URL=postgresql://user:password@db:5432/app_db
|
||||||
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
- SECRET_KEY=${SECRET_KEY:-default_secret_key_change_in_production}
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- redis
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:1.25
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
|
||||||
|
- ./nginx/conf.d:/etc/nginx/conf.d
|
||||||
|
- ./nginx/ssl:/etc/nginx/ssl
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:15
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: user
|
||||||
|
POSTGRES_PASSWORD: password
|
||||||
|
POSTGRES_DB: app_db
|
||||||
|
volumes:
|
||||||
|
- pg_data:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pg_data:
|
||||||
|
redis_data:
|
64
config/settings.py
Normal file
64
config/settings.py
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
basedir = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""Base config."""
|
||||||
|
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-key-placeholder')
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
SESSION_COOKIE_SECURE = True
|
||||||
|
SESSION_COOKIE_HTTPONLY = True
|
||||||
|
REMEMBER_COOKIE_DURATION = timedelta(days=14)
|
||||||
|
PERMANENT_SESSION_LIFETIME = timedelta(days=1)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def init_app(app):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class DevelopmentConfig(Config):
|
||||||
|
DEBUG = True
|
||||||
|
SESSION_COOKIE_SECURE = False
|
||||||
|
SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL') or \
|
||||||
|
'sqlite:///' + os.path.join(basedir, '..', 'instance', 'development.db')
|
||||||
|
|
||||||
|
class TestingConfig(Config):
|
||||||
|
TESTING = True
|
||||||
|
SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL') or \
|
||||||
|
'sqlite:///' + os.path.join(basedir, '..', 'instance', 'testing.db')
|
||||||
|
WTF_CSRF_ENABLED = False
|
||||||
|
|
||||||
|
class ProductionConfig(Config):
|
||||||
|
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
|
||||||
|
'sqlite:///' + os.path.join(basedir, '..', 'instance', 'production.db')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def init_app(cls, app):
|
||||||
|
Config.init_app(app)
|
||||||
|
|
||||||
|
# Production-specific logging
|
||||||
|
import logging
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
log_dir = os.path.join(basedir, '..', 'logs')
|
||||||
|
os.makedirs(log_dir, exist_ok=True)
|
||||||
|
|
||||||
|
file_handler = RotatingFileHandler(
|
||||||
|
os.path.join(log_dir, 'app.log'),
|
||||||
|
maxBytes=10485760, # 10MB
|
||||||
|
backupCount=10
|
||||||
|
)
|
||||||
|
file_handler.setFormatter(logging.Formatter(
|
||||||
|
'%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'
|
||||||
|
))
|
||||||
|
file_handler.setLevel(logging.INFO)
|
||||||
|
app.logger.addHandler(file_handler)
|
||||||
|
app.logger.setLevel(logging.INFO)
|
||||||
|
app.logger.info('App startup')
|
||||||
|
|
||||||
|
config = {
|
||||||
|
'development': DevelopmentConfig,
|
||||||
|
'testing': TestingConfig,
|
||||||
|
'production': ProductionConfig,
|
||||||
|
|
||||||
|
'default': DevelopmentConfig
|
||||||
|
}
|
BIN
instance/development.db
Normal file
BIN
instance/development.db
Normal file
Binary file not shown.
BIN
instance/production.db
Normal file
BIN
instance/production.db
Normal file
Binary file not shown.
44
requirements.txt
Normal file
44
requirements.txt
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
# Flask and core extensions
|
||||||
|
Flask==2.3.3
|
||||||
|
flask-sqlalchemy==3.1.1
|
||||||
|
flask-login==0.6.3
|
||||||
|
flask-limiter==3.5.0
|
||||||
|
flask-bcrypt==1.0.1
|
||||||
|
flask-wtf==1.2.1
|
||||||
|
ipaddress==1.0.23
|
||||||
|
gunicorn==21.2.0
|
||||||
|
Werkzeug==2.3.7
|
||||||
|
Jinja2==3.1.2
|
||||||
|
MarkupSafe==2.1.3
|
||||||
|
itsdangerous==2.1.2
|
||||||
|
# SQLAlchemy==2.0.23
|
||||||
|
WTForms==3.1.0
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
markdown==3.5.1
|
||||||
|
|
||||||
|
# SQLAlchemy - use newer version for Python 3.13 compatibility
|
||||||
|
SQLAlchemy>=2.0.27
|
||||||
|
|
||||||
|
# Security
|
||||||
|
Flask-Bcrypt>=1.0.1
|
||||||
|
itsdangerous>=2.1.2
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
Flask-Limiter>=3.5.0
|
||||||
|
|
||||||
|
# Removed psycopg2-binary dependency
|
||||||
|
|
||||||
|
# Caching
|
||||||
|
redis>=5.0.1
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
requests>=2.31.0
|
||||||
|
markdown>=3.5.1
|
||||||
|
ipaddress>=1.0.23
|
||||||
|
gunicorn>=21.2.0
|
||||||
|
|
||||||
|
# WSGI
|
||||||
|
gunicorn==21.2.0
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
pytest>=7.4.3
|
||||||
|
pytest-flask>=1.3.0
|
0
routes/__init__.py
Normal file
0
routes/__init__.py
Normal file
71
run.py
Normal file
71
run.py
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import importlib.util
|
||||||
|
from flask import Flask, render_template
|
||||||
|
from app import create_app
|
||||||
|
|
||||||
|
# Add the current directory to Python path
|
||||||
|
current_dir = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
sys.path.insert(0, current_dir)
|
||||||
|
|
||||||
|
def create_basic_app():
|
||||||
|
"""Create a Flask app without database dependencies"""
|
||||||
|
app = Flask(__name__,
|
||||||
|
template_folder=os.path.join(current_dir, 'app', 'templates'),
|
||||||
|
static_folder=os.path.join(current_dir, 'app', 'static'))
|
||||||
|
|
||||||
|
# Basic configuration
|
||||||
|
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev-key-placeholder')
|
||||||
|
app.config['DEBUG'] = True
|
||||||
|
|
||||||
|
# Register basic routes
|
||||||
|
register_routes(app)
|
||||||
|
|
||||||
|
# Add a fallback index route if no routes match
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
return "Your Network Management Flask Application is running! Navigate to /dashboard to see content."
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
def register_routes(app):
|
||||||
|
"""Register blueprints without database dependencies"""
|
||||||
|
routes_dir = os.path.join(current_dir, 'app', 'routes')
|
||||||
|
|
||||||
|
# Check if routes directory exists
|
||||||
|
if not os.path.isdir(routes_dir):
|
||||||
|
print(f"Warning: Routes directory {routes_dir} not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Try to register API blueprint which is simplest
|
||||||
|
try:
|
||||||
|
from app.routes.api import bp as api_bp
|
||||||
|
app.register_blueprint(api_bp)
|
||||||
|
print("Registered API blueprint")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Could not register API blueprint: {e}")
|
||||||
|
|
||||||
|
# Try to register other blueprints with basic error handling
|
||||||
|
try:
|
||||||
|
from app.routes.dashboard import bp as dashboard_bp
|
||||||
|
app.register_blueprint(dashboard_bp)
|
||||||
|
print("Registered dashboard blueprint")
|
||||||
|
except ImportError as e:
|
||||||
|
print(f"Could not import dashboard blueprint: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.routes.ipam import bp as ipam_bp
|
||||||
|
app.register_blueprint(ipam_bp)
|
||||||
|
print("Registered IPAM blueprint")
|
||||||
|
except ImportError as e:
|
||||||
|
print(f"Could not import IPAM blueprint: {e}")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
print("Starting Flask app with SQLite database...")
|
||||||
|
app = create_app('development')
|
||||||
|
app.run(debug=True, host='0.0.0.0', port=5000)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error starting Flask app: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
47
scripts/setup.sh
Normal file
47
scripts/setup.sh
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Setup script for Docker deployment
|
||||||
|
|
||||||
|
# Create SSL certificates for NGINX
|
||||||
|
mkdir -p config/nginx/ssl
|
||||||
|
|
||||||
|
if [ ! -f config/nginx/ssl/server.crt ]; then
|
||||||
|
echo "Generating self-signed SSL certificates..."
|
||||||
|
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||||
|
-keyout config/nginx/ssl/server.key \
|
||||||
|
-out config/nginx/ssl/server.crt \
|
||||||
|
-subj "/C=US/ST=State/L=City/O=Organization/CN=localhost"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create empty .env file if it doesn't exist
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
echo "Creating .env file with default values..."
|
||||||
|
cat > .env << EOF
|
||||||
|
# Flask application settings
|
||||||
|
FLASK_APP=app
|
||||||
|
FLASK_ENV=production
|
||||||
|
SECRET_KEY=$(openssl rand -hex 24)
|
||||||
|
|
||||||
|
# Database settings
|
||||||
|
DATABASE_URL=postgresql://user:password@db:5432/app_db
|
||||||
|
|
||||||
|
# Redis settings
|
||||||
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
|
||||||
|
# Email settings
|
||||||
|
MAIL_SERVER=smtp.example.com
|
||||||
|
MAIL_PORT=587
|
||||||
|
MAIL_USE_TLS=True
|
||||||
|
MAIL_USERNAME=user@example.com
|
||||||
|
MAIL_PASSWORD=password
|
||||||
|
MAIL_DEFAULT_SENDER=user@example.com
|
||||||
|
|
||||||
|
# Security settings
|
||||||
|
HTTPS_ENABLED=True
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create uploads directory
|
||||||
|
mkdir -p app/uploads
|
||||||
|
|
||||||
|
echo "Setup complete. You can now run 'docker-compose up -d' to start the application."
|
24
tests/conftest.py
Normal file
24
tests/conftest.py
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import pytest
|
||||||
|
from app import create_app
|
||||||
|
from app.core.extensions import db as _db
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app():
|
||||||
|
app = create_app('testing')
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
_db.create_all()
|
||||||
|
yield app
|
||||||
|
_db.session.remove()
|
||||||
|
_db.drop_all()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(app):
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db(app):
|
||||||
|
with app.app_context():
|
||||||
|
yield _db
|
4
tests/pytest.ini
Normal file
4
tests/pytest.ini
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
[pytest]
|
||||||
|
minversion = 6.0
|
||||||
|
cache_dir = .pytest_cache
|
||||||
|
addopts = --strict-markers
|
11
tests/test_app.py
Normal file
11
tests/test_app.py
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import pytest
|
||||||
|
from app.core import models
|
||||||
|
|
||||||
|
# Example test
|
||||||
|
@pytest.mark.parametrize("cidr, expected", [
|
||||||
|
('192.168.1.0/24', 256),
|
||||||
|
('10.0.0.0/8', 16777216),
|
||||||
|
])
|
||||||
|
def test_subnet_cidr_parsing(cidr, expected):
|
||||||
|
subnet = models.Subnet(cidr=cidr)
|
||||||
|
assert subnet.used_ips == expected
|
51
tests/test_models.py
Normal file
51
tests/test_models.py
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
import pytest
|
||||||
|
from app.core.models import Subnet, Server, App
|
||||||
|
import ipaddress
|
||||||
|
|
||||||
|
def test_subnet_cidr_validation():
|
||||||
|
"""Test CIDR format validation for Subnet model"""
|
||||||
|
# Valid CIDR formats
|
||||||
|
valid_cidrs = [
|
||||||
|
'192.168.1.0/24',
|
||||||
|
'10.0.0.0/8',
|
||||||
|
'172.16.0.0/16'
|
||||||
|
]
|
||||||
|
|
||||||
|
for cidr in valid_cidrs:
|
||||||
|
subnet = Subnet(cidr=cidr, location='Test')
|
||||||
|
# This shouldn't raise an exception
|
||||||
|
ipaddress.ip_network(subnet.cidr)
|
||||||
|
|
||||||
|
# Invalid CIDR formats should raise ValueError
|
||||||
|
invalid_cidrs = [
|
||||||
|
'192.168.1.0', # Missing mask
|
||||||
|
'192.168.1.0/33', # Invalid mask
|
||||||
|
'256.0.0.0/24' # Invalid IP
|
||||||
|
]
|
||||||
|
|
||||||
|
for cidr in invalid_cidrs:
|
||||||
|
subnet = Subnet(cidr=cidr, location='Test')
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
ipaddress.ip_network(subnet.cidr)
|
||||||
|
|
||||||
|
def test_server_open_ports(app):
|
||||||
|
"""Test the get_open_ports method"""
|
||||||
|
# Create test server with app having specific ports
|
||||||
|
subnet = Subnet(cidr='192.168.1.0/24', location='Test')
|
||||||
|
server = Server(hostname='test-server', ip_address='192.168.1.10', subnet=subnet)
|
||||||
|
app1 = App(
|
||||||
|
name='Test App',
|
||||||
|
server=server,
|
||||||
|
ports=[
|
||||||
|
{'port': 80, 'type': 'tcp', 'status': 'open', 'desc': 'HTTP'},
|
||||||
|
{'port': 443, 'type': 'tcp', 'status': 'open', 'desc': 'HTTPS'},
|
||||||
|
{'port': 8080, 'type': 'tcp', 'status': 'closed', 'desc': 'Alt HTTP'}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# The get_open_ports should only return ports with status 'open'
|
||||||
|
open_ports = server.get_open_ports()
|
||||||
|
assert len(open_ports) == 2
|
||||||
|
assert {'port': 80, 'type': 'tcp', 'status': 'open', 'desc': 'HTTP'} in open_ports
|
||||||
|
assert {'port': 443, 'type': 'tcp', 'status': 'open', 'desc': 'HTTPS'} in open_ports
|
||||||
|
assert {'port': 8080, 'type': 'tcp', 'status': 'closed', 'desc': 'Alt HTTP'} not in open_ports
|
62
tests/test_routes.py
Normal file
62
tests/test_routes.py
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import pytest
|
||||||
|
from flask import url_for
|
||||||
|
|
||||||
|
def test_dashboard_home(client):
|
||||||
|
"""Test dashboard home route"""
|
||||||
|
response = client.get('/dashboard/')
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b"Dashboard" in response.data
|
||||||
|
|
||||||
|
def test_server_view(client, app):
|
||||||
|
"""Test server detail view"""
|
||||||
|
# Create test server
|
||||||
|
from app.core.models import Subnet, Server
|
||||||
|
from app.core.extensions import db
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
subnet = Subnet(cidr='192.168.1.0/24', location='Test')
|
||||||
|
server = Server(hostname='test-server', ip_address='192.168.1.10', subnet=subnet)
|
||||||
|
db.session.add(subnet)
|
||||||
|
db.session.add(server)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Test viewing the server
|
||||||
|
response = client.get(f'/dashboard/server/{server.id}')
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b'test-server' in response.data
|
||||||
|
assert b'192.168.1.10' in response.data
|
||||||
|
|
||||||
|
def test_api_status(client):
|
||||||
|
"""Test API status endpoint"""
|
||||||
|
response = client.get('/api/status')
|
||||||
|
assert response.status_code == 200
|
||||||
|
json_data = response.get_json()
|
||||||
|
assert json_data['status'] == 'OK'
|
||||||
|
|
||||||
|
def test_markdown_preview(client):
|
||||||
|
"""Test markdown preview API"""
|
||||||
|
md_content = "# Test Heading\nThis is a test."
|
||||||
|
response = client.post('/api/markdown-preview',
|
||||||
|
json={'markdown': md_content})
|
||||||
|
assert response.status_code == 200
|
||||||
|
json_data = response.get_json()
|
||||||
|
assert '<h1>Test Heading</h1>' in json_data['html']
|
||||||
|
assert '<p>This is a test.</p>' in json_data['html']
|
||||||
|
|
||||||
|
def test_subnet_scan(client, app):
|
||||||
|
"""Test subnet scanning API"""
|
||||||
|
# Create test subnet
|
||||||
|
from app.core.models import Subnet
|
||||||
|
from app.core.extensions import db
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
subnet = Subnet(cidr='192.168.1.0/24', location='Test', auto_scan=True)
|
||||||
|
db.session.add(subnet)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Test scanning endpoint
|
||||||
|
response = client.post(f'/ipam/subnet/{subnet.id}/scan')
|
||||||
|
assert response.status_code == 200
|
||||||
|
json_data = response.get_json()
|
||||||
|
assert json_data['status'] == 'scanning'
|
||||||
|
assert json_data['subnet_id'] == subnet.id
|
7
wsgi.py
Normal file
7
wsgi.py
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
from app import create_app
|
||||||
|
|
||||||
|
# Create application instance - production
|
||||||
|
app = create_app('production')
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run()
|
Loading…
Add table
Add a link
Reference in a new issue