wip
This commit is contained in:
parent
6dd38036e7
commit
097b3dbf09
34 changed files with 1719 additions and 520 deletions
|
@ -22,15 +22,16 @@ def create_app(config_name='development'):
|
|||
os.makedirs(os.path.join(app.instance_path), exist_ok=True)
|
||||
|
||||
# Initialize extensions
|
||||
from app.core.extensions import db, bcrypt, limiter, login_manager, csrf
|
||||
from app.core.extensions import db, migrate, login_manager, bcrypt, limiter, csrf
|
||||
db.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
login_manager.init_app(app)
|
||||
bcrypt.init_app(app)
|
||||
limiter.init_app(app)
|
||||
csrf.init_app(app)
|
||||
limiter.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):
|
||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -1,31 +1,34 @@
|
|||
from flask_login import LoginManager, UserMixin
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from .extensions import db
|
||||
from .extensions import db, bcrypt
|
||||
from datetime import datetime
|
||||
|
||||
login_manager = LoginManager()
|
||||
login_manager.login_view = 'auth.login'
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
__tablename__ = 'users'
|
||||
|
||||
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)
|
||||
username = db.Column(db.String(64), unique=True, index=True)
|
||||
email = db.Column(db.String(120), unique=True, index=True)
|
||||
password_hash = db.Column(db.String(128))
|
||||
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 __repr__(self):
|
||||
return f'<User {self.username}>'
|
||||
|
||||
def set_password(self, password):
|
||||
self.password_hash = generate_password_hash(password)
|
||||
|
||||
self.password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
|
||||
|
||||
def check_password(self, password):
|
||||
return check_password_hash(self.password_hash, password)
|
||||
return bcrypt.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))
|
|
@ -1,28 +1,22 @@
|
|||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
from flask_login import LoginManager
|
||||
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()
|
||||
migrate = Migrate()
|
||||
login_manager = LoginManager()
|
||||
login_manager.login_view = 'auth.login'
|
||||
csrf = CSRFProtect()
|
||||
login_manager.login_message = 'Please log in to access this page.'
|
||||
login_manager.login_message_category = 'info'
|
||||
|
||||
# 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"]
|
||||
)
|
||||
bcrypt = Bcrypt()
|
||||
csrf = CSRFProtect()
|
||||
limiter = Limiter(
|
||||
key_func=get_remote_address,
|
||||
default_limits=["200 per day", "50 per hour"]
|
||||
)
|
||||
|
|
|
@ -1,77 +1,93 @@
|
|||
from .extensions import db
|
||||
from app.core.extensions import db
|
||||
import json
|
||||
from datetime import datetime
|
||||
import ipaddress
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from flask_login import UserMixin
|
||||
|
||||
class Subnet(db.Model):
|
||||
# User model has been moved to app.core.auth
|
||||
# Import it from there instead if needed: from app.core.auth import User
|
||||
|
||||
class Port(db.Model):
|
||||
__tablename__ = 'ports'
|
||||
|
||||
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)
|
||||
app_id = db.Column(db.Integer, db.ForeignKey('apps.id', ondelete='CASCADE'), nullable=False)
|
||||
port_number = db.Column(db.Integer, nullable=False)
|
||||
protocol = db.Column(db.String(10), default='TCP') # TCP, UDP, etc.
|
||||
description = db.Column(db.String(200))
|
||||
|
||||
@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()
|
||||
# Relationship
|
||||
app = db.relationship('App', back_populates='ports')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Subnet {self.cidr}>'
|
||||
|
||||
return f'<Port {self.port_number}/{self.protocol}>'
|
||||
|
||||
class Server(db.Model):
|
||||
__tablename__ = 'servers'
|
||||
|
||||
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))
|
||||
hostname = db.Column(db.String(64), nullable=False)
|
||||
ip_address = db.Column(db.String(39), nullable=False) # IPv4 or IPv6
|
||||
subnet_id = db.Column(db.Integer, db.ForeignKey('subnets.id'), nullable=False)
|
||||
documentation = db.Column(db.Text)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=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
|
||||
# Relationships
|
||||
subnet = db.relationship('Subnet', back_populates='servers')
|
||||
apps = db.relationship('App', back_populates='server', cascade='all, delete-orphan')
|
||||
|
||||
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)
|
||||
class Subnet(db.Model):
|
||||
__tablename__ = 'subnets'
|
||||
|
||||
# Store ports as JSON in the database
|
||||
_ports = db.Column(db.Text, default='[]')
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
cidr = db.Column(db.String(18), unique=True, nullable=False) # e.g., 192.168.1.0/24
|
||||
location = db.Column(db.String(64))
|
||||
active_hosts = db.Column(db.Text) # Store as JSON string
|
||||
last_scanned = db.Column(db.DateTime)
|
||||
auto_scan = db.Column(db.Boolean, default=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
servers = db.relationship('Server', back_populates='subnet')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Subnet {self.cidr}>'
|
||||
|
||||
@property
|
||||
def ports(self):
|
||||
return json.loads(self._ports) if self._ports else []
|
||||
def used_ips(self):
|
||||
"""Number of IPs used in this subnet (servers)"""
|
||||
return len(self.servers)
|
||||
|
||||
@ports.setter
|
||||
def ports(self, value):
|
||||
self._ports = json.dumps(value) if value else '[]'
|
||||
# Getter and setter for active_hosts as JSON
|
||||
@property
|
||||
def active_hosts_list(self):
|
||||
if not self.active_hosts:
|
||||
return []
|
||||
return json.loads(self.active_hosts)
|
||||
|
||||
@active_hosts_list.setter
|
||||
def active_hosts_list(self, hosts):
|
||||
self.active_hosts = json.dumps(hosts)
|
||||
|
||||
class App(db.Model):
|
||||
__tablename__ = 'apps'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(64), nullable=False)
|
||||
server_id = db.Column(db.Integer, db.ForeignKey('servers.id'), nullable=False)
|
||||
documentation = db.Column(db.Text)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
server = db.relationship('Server', back_populates='apps')
|
||||
ports = db.relationship('Port', back_populates='app', cascade='all, delete-orphan')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<App {self.name}>'
|
36
app/core/template_filters.py
Normal file
36
app/core/template_filters.py
Normal file
|
@ -0,0 +1,36 @@
|
|||
import ipaddress
|
||||
from flask import Blueprint
|
||||
|
||||
bp = Blueprint('filters', __name__)
|
||||
|
||||
@bp.app_template_filter('ip_network')
|
||||
def ip_network_filter(cidr):
|
||||
"""Convert a CIDR string to an IP network object"""
|
||||
try:
|
||||
return ipaddress.ip_network(cidr, strict=False)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@bp.app_template_filter('ip_address')
|
||||
def ip_address_filter(ip):
|
||||
"""Convert an IP string to an IP address object"""
|
||||
try:
|
||||
return ipaddress.ip_address(ip)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@bp.app_template_filter('markdown')
|
||||
def markdown_filter(text):
|
||||
"""Convert markdown text to HTML"""
|
||||
import markdown
|
||||
if text:
|
||||
return markdown.markdown(text, extensions=['tables', 'fenced_code'])
|
||||
return ""
|
||||
|
||||
@bp.app_template_global('get_ip_network')
|
||||
def get_ip_network(cidr):
|
||||
"""Global function to get an IP network object from CIDR"""
|
||||
try:
|
||||
return ipaddress.ip_network(cidr, strict=False)
|
||||
except ValueError:
|
||||
return None
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -1,8 +1,10 @@
|
|||
from flask import Blueprint, jsonify, request, abort
|
||||
from flask_login import login_required
|
||||
from app.core.models import Subnet, Server, App
|
||||
from app.core.models import Subnet, Server, App, Port
|
||||
from app.core.extensions import db
|
||||
from app.scripts.ip_scanner import scan
|
||||
import random
|
||||
import ipaddress
|
||||
|
||||
bp = Blueprint('api', __name__, url_prefix='/api')
|
||||
|
||||
|
@ -93,14 +95,24 @@ def get_servers():
|
|||
@bp.route('/servers/<int:server_id>', methods=['GET'])
|
||||
@login_required
|
||||
def get_server(server_id):
|
||||
"""Get details for a specific server"""
|
||||
"""Get a specific server"""
|
||||
server = Server.query.get_or_404(server_id)
|
||||
|
||||
apps = []
|
||||
for app in App.query.filter_by(server_id=server_id).all():
|
||||
for app in server.apps:
|
||||
ports = []
|
||||
for port in app.ports:
|
||||
ports.append({
|
||||
'id': port.id,
|
||||
'port_number': port.port_number,
|
||||
'protocol': port.protocol,
|
||||
'description': port.description
|
||||
})
|
||||
|
||||
apps.append({
|
||||
'id': app.id,
|
||||
'name': app.name,
|
||||
'ports': ports,
|
||||
'created_at': app.created_at.strftime('%Y-%m-%d %H:%M:%S')
|
||||
})
|
||||
|
||||
|
@ -110,9 +122,8 @@ def get_server(server_id):
|
|||
'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
|
||||
'apps': apps,
|
||||
'created_at': server.created_at.strftime('%Y-%m-%d %H:%M:%S')
|
||||
}
|
||||
|
||||
return jsonify(result)
|
||||
|
@ -196,4 +207,103 @@ def suggest_ports():
|
|||
return jsonify([
|
||||
{'port': 80, 'type': 'tcp', 'desc': 'HTTP'},
|
||||
{'port': 22, 'type': 'tcp', 'desc': 'SSH'}
|
||||
])
|
||||
])
|
||||
|
||||
@bp.route('/servers/<int:server_id>/suggest_port', methods=['GET'])
|
||||
@login_required
|
||||
def suggest_port(server_id):
|
||||
"""Suggest a random unused port for a server"""
|
||||
server = Server.query.get_or_404(server_id)
|
||||
|
||||
# Get all used ports for this server
|
||||
used_ports = []
|
||||
for app in server.apps:
|
||||
for port in app.ports:
|
||||
used_ports.append(port.port_number)
|
||||
|
||||
# Find an unused port in the dynamic/private port range
|
||||
available_port = None
|
||||
attempts = 0
|
||||
|
||||
while attempts < 50: # Try 50 times to find a random port
|
||||
# Random port between 10000 and 65535
|
||||
port = random.randint(10000, 65535)
|
||||
|
||||
if port not in used_ports:
|
||||
available_port = port
|
||||
break
|
||||
|
||||
attempts += 1
|
||||
|
||||
if available_port is None:
|
||||
# If no random port found, find first available in sequence
|
||||
for port in range(10000, 65536):
|
||||
if port not in used_ports:
|
||||
available_port = port
|
||||
break
|
||||
|
||||
return jsonify({'port': available_port})
|
||||
|
||||
@bp.route('/apps/<int:app_id>/ports', methods=['GET'])
|
||||
@login_required
|
||||
def get_app_ports(app_id):
|
||||
"""Get all ports for an app"""
|
||||
app = App.query.get_or_404(app_id)
|
||||
|
||||
ports = []
|
||||
for port in app.ports:
|
||||
ports.append({
|
||||
'id': port.id,
|
||||
'port_number': port.port_number,
|
||||
'protocol': port.protocol,
|
||||
'description': port.description
|
||||
})
|
||||
|
||||
return jsonify({'ports': ports})
|
||||
|
||||
@bp.route('/apps/<int:app_id>/ports', methods=['POST'])
|
||||
@login_required
|
||||
def add_app_port(app_id):
|
||||
"""Add a new port to an app"""
|
||||
app = App.query.get_or_404(app_id)
|
||||
|
||||
data = request.json
|
||||
if not data or 'port_number' not in data:
|
||||
return jsonify({'error': 'Missing port number'}), 400
|
||||
|
||||
port_number = data.get('port_number')
|
||||
protocol = data.get('protocol', 'TCP')
|
||||
description = data.get('description', '')
|
||||
|
||||
# Check if port already exists for this app
|
||||
existing_port = Port.query.filter_by(app_id=app_id, port_number=port_number).first()
|
||||
if existing_port:
|
||||
return jsonify({'error': 'Port already exists for this app'}), 400
|
||||
|
||||
new_port = Port(
|
||||
app_id=app_id,
|
||||
port_number=port_number,
|
||||
protocol=protocol,
|
||||
description=description
|
||||
)
|
||||
|
||||
db.session.add(new_port)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'id': new_port.id,
|
||||
'port_number': new_port.port_number,
|
||||
'protocol': new_port.protocol,
|
||||
'description': new_port.description
|
||||
})
|
||||
|
||||
@bp.route('/ports/<int:port_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_port(port_id):
|
||||
"""Delete a port"""
|
||||
port = Port.query.get_or_404(port_id)
|
||||
|
||||
db.session.delete(port)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'success': True})
|
|
@ -8,7 +8,7 @@ bp = Blueprint('auth', __name__, url_prefix='/auth')
|
|||
|
||||
@bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
# If already logged in, redirect to dashboard
|
||||
"""User login"""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('dashboard.dashboard_home'))
|
||||
|
||||
|
@ -19,58 +19,64 @@ def login():
|
|||
|
||||
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'))
|
||||
if not user or not user.check_password(password):
|
||||
flash('Invalid email or password', 'danger')
|
||||
return render_template('auth/login.html', title='Login')
|
||||
|
||||
flash('Invalid email or password', 'danger')
|
||||
login_user(user, remember=remember)
|
||||
|
||||
next_page = request.args.get('next')
|
||||
if not next_page or not next_page.startswith('/'):
|
||||
next_page = url_for('dashboard.dashboard_home')
|
||||
|
||||
return redirect(next_page)
|
||||
|
||||
return render_template('auth/login.html', title='Login')
|
||||
|
||||
@bp.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
# If already logged in, redirect to dashboard
|
||||
"""User registration"""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('dashboard.dashboard_home'))
|
||||
|
||||
if request.method == 'POST':
|
||||
email = request.form.get('email')
|
||||
username = request.form.get('username')
|
||||
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')
|
||||
# Validation
|
||||
if not email or not username or not password:
|
||||
flash('All fields are required', 'danger')
|
||||
return render_template('auth/register.html', title='Register')
|
||||
|
||||
# Check if passwords match
|
||||
if password != password_confirm:
|
||||
flash('Passwords do not match', 'danger')
|
||||
if User.query.filter_by(email=email).first():
|
||||
flash('Email already registered', 'danger')
|
||||
return render_template('auth/register.html', title='Register')
|
||||
|
||||
if User.query.filter_by(username=username).first():
|
||||
flash('Username already taken', 'danger')
|
||||
return render_template('auth/register.html', title='Register')
|
||||
|
||||
# Create new user
|
||||
user = User(email=email)
|
||||
user = User(email=email, username=username)
|
||||
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'))
|
||||
flash('Registration successful! You are now logged in.', 'success')
|
||||
|
||||
# Auto-login after registration
|
||||
login_user(user)
|
||||
|
||||
return redirect(url_for('dashboard.dashboard_home'))
|
||||
|
||||
return render_template('auth/register.html', title='Register')
|
||||
|
||||
@bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
"""User logout"""
|
||||
logout_user()
|
||||
flash('You have been logged out', 'info')
|
||||
return redirect(url_for('auth.login'))
|
|
@ -1,7 +1,7 @@
|
|||
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.models import Server, App, Subnet, Port
|
||||
from app.core.extensions import db, limiter
|
||||
from datetime import datetime
|
||||
|
||||
|
@ -196,22 +196,50 @@ def app_new():
|
|||
server_id = request.form.get('server_id')
|
||||
documentation = request.form.get('documentation', '')
|
||||
|
||||
# Get port data from form
|
||||
port_numbers = request.form.getlist('port_numbers[]')
|
||||
protocols = request.form.getlist('protocols[]')
|
||||
port_descriptions = request.form.getlist('port_descriptions[]')
|
||||
|
||||
# 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()
|
||||
servers=servers
|
||||
)
|
||||
|
||||
# Create new app
|
||||
app = App(
|
||||
name=name,
|
||||
server_id=server_id,
|
||||
documentation=documentation
|
||||
)
|
||||
|
||||
db.session.add(app)
|
||||
db.session.flush() # Get the app ID without committing
|
||||
|
||||
# Add ports if provided
|
||||
for i in range(len(port_numbers)):
|
||||
if port_numbers[i] and port_numbers[i].strip():
|
||||
try:
|
||||
port_num = int(port_numbers[i])
|
||||
|
||||
# Get protocol and description, handling index errors
|
||||
protocol = protocols[i] if i < len(protocols) else 'TCP'
|
||||
description = port_descriptions[i] if i < len(port_descriptions) else ''
|
||||
|
||||
new_port = Port(
|
||||
app_id=app.id,
|
||||
port_number=port_num,
|
||||
protocol=protocol,
|
||||
description=description
|
||||
)
|
||||
db.session.add(new_port)
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
db.session.commit()
|
||||
|
||||
flash('Application created successfully', 'success')
|
||||
|
@ -220,8 +248,7 @@ def app_new():
|
|||
return render_template(
|
||||
'dashboard/app_form.html',
|
||||
title='New Application',
|
||||
servers=servers,
|
||||
now=datetime.now()
|
||||
servers=servers
|
||||
)
|
||||
|
||||
@bp.route('/app/<int:app_id>', methods=['GET'])
|
||||
|
@ -245,49 +272,69 @@ def app_view(app_id):
|
|||
def app_edit(app_id):
|
||||
"""Edit an existing application"""
|
||||
app = App.query.get_or_404(app_id)
|
||||
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', '')
|
||||
|
||||
# Process ports
|
||||
ports = []
|
||||
port_numbers = request.form.getlist('port[]')
|
||||
port_types = request.form.getlist('port_type[]')
|
||||
port_descs = request.form.getlist('port_desc[]')
|
||||
# Get port data from form
|
||||
port_numbers = request.form.getlist('port_numbers[]')
|
||||
protocols = request.form.getlist('protocols[]')
|
||||
port_descriptions = request.form.getlist('port_descriptions[]')
|
||||
|
||||
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)
|
||||
# Validate inputs
|
||||
if not all([name, server_id]):
|
||||
flash('All fields are required', 'danger')
|
||||
return render_template('dashboard/app_form.html',
|
||||
title='Edit Application',
|
||||
app=app,
|
||||
servers=servers,
|
||||
edit_mode=True)
|
||||
|
||||
# Update app
|
||||
app.name = name
|
||||
app.server_id = server_id
|
||||
app.documentation = documentation
|
||||
app.ports = ports
|
||||
|
||||
db.session.commit()
|
||||
# Delete existing ports and recreate them
|
||||
# This simplifies handling additions, deletions, and updates
|
||||
Port.query.filter_by(app_id=app.id).delete()
|
||||
|
||||
flash('Application updated successfully', 'success')
|
||||
return redirect(url_for('dashboard.app_view', app_id=app.id))
|
||||
# Add new ports
|
||||
for i in range(len(port_numbers)):
|
||||
if port_numbers[i] and port_numbers[i].strip():
|
||||
try:
|
||||
port_num = int(port_numbers[i])
|
||||
|
||||
# Get protocol and description, handling index errors
|
||||
protocol = protocols[i] if i < len(protocols) else 'TCP'
|
||||
description = port_descriptions[i] if i < len(port_descriptions) else ''
|
||||
|
||||
new_port = Port(
|
||||
app_id=app.id,
|
||||
port_number=port_num,
|
||||
protocol=protocol,
|
||||
description=description
|
||||
)
|
||||
db.session.add(new_port)
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
flash(f'Application {name} has been updated', 'success')
|
||||
return redirect(url_for('dashboard.server_view', server_id=app.server_id))
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error updating application: {str(e)}', 'danger')
|
||||
|
||||
# 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
|
||||
)
|
||||
return render_template('dashboard/app_form.html',
|
||||
title='Edit Application',
|
||||
app=app,
|
||||
servers=servers,
|
||||
edit_mode=True)
|
||||
|
||||
@bp.route('/app/<int:app_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
|
|
|
@ -5,6 +5,7 @@ from app.core.extensions import db
|
|||
from app.scripts.ip_scanner import scan
|
||||
import ipaddress
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
bp = Blueprint('ipam', __name__, url_prefix='/ipam')
|
||||
|
||||
|
@ -16,7 +17,10 @@ def ipam_home():
|
|||
|
||||
# Calculate usage for each subnet
|
||||
for subnet in subnets:
|
||||
subnet.usage_percent = subnet.used_ips / 254 * 100 if subnet.cidr.endswith('/24') else 0
|
||||
network = ipaddress.ip_network(subnet.cidr, strict=False)
|
||||
max_hosts = network.num_addresses - 2 if network.prefixlen < 31 else network.num_addresses
|
||||
used_count = Server.query.filter_by(subnet_id=subnet.id).count()
|
||||
subnet.usage_percent = (used_count / max_hosts) * 100 if max_hosts > 0 else 0
|
||||
|
||||
return render_template(
|
||||
'ipam/index.html',
|
||||
|
@ -32,42 +36,43 @@ def subnet_new():
|
|||
if request.method == 'POST':
|
||||
cidr = request.form.get('cidr')
|
||||
location = request.form.get('location')
|
||||
auto_scan = 'auto_scan' in request.form
|
||||
auto_scan = request.form.get('auto_scan') == 'on'
|
||||
|
||||
# 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()
|
||||
title='New Subnet'
|
||||
)
|
||||
|
||||
# Check if valid CIDR
|
||||
# Validate CIDR format
|
||||
try:
|
||||
ipaddress.ip_network(cidr)
|
||||
ipaddress.ip_network(cidr, strict=False)
|
||||
except ValueError:
|
||||
flash('Invalid CIDR notation', 'danger')
|
||||
flash('Invalid CIDR format', 'danger')
|
||||
return render_template(
|
||||
'ipam/subnet_form.html',
|
||||
title='New Subnet',
|
||||
now=datetime.now()
|
||||
title='New Subnet'
|
||||
)
|
||||
|
||||
# Check if subnet already exists
|
||||
# Check if CIDR 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()
|
||||
title='New Subnet'
|
||||
)
|
||||
|
||||
# Create new subnet with JSON string for active_hosts, not a Python list
|
||||
subnet = Subnet(
|
||||
cidr=cidr,
|
||||
location=location,
|
||||
active_hosts=json.dumps([]), # Convert empty list to JSON string
|
||||
last_scanned=None,
|
||||
auto_scan=auto_scan
|
||||
)
|
||||
|
||||
db.session.add(subnet)
|
||||
db.session.commit()
|
||||
|
||||
|
@ -76,70 +81,115 @@ def subnet_new():
|
|||
|
||||
return render_template(
|
||||
'ipam/subnet_form.html',
|
||||
title='New Subnet',
|
||||
now=datetime.now()
|
||||
title='New Subnet'
|
||||
)
|
||||
|
||||
@bp.route('/subnet/<int:subnet_id>')
|
||||
@login_required
|
||||
def subnet_view(subnet_id):
|
||||
"""View subnet details"""
|
||||
"""View a specific subnet"""
|
||||
subnet = Subnet.query.get_or_404(subnet_id)
|
||||
|
||||
# Get all servers in this subnet
|
||||
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
|
||||
# Parse CIDR for display
|
||||
network = ipaddress.ip_network(subnet.cidr, strict=False)
|
||||
subnet_info = {
|
||||
'network_address': str(network.network_address),
|
||||
'broadcast_address': str(network.broadcast_address),
|
||||
'netmask': str(network.netmask),
|
||||
'num_addresses': network.num_addresses,
|
||||
'host_range': f"{str(network.network_address + 1)} - {str(network.broadcast_address - 1)}" if network.prefixlen < 31 else subnet.cidr
|
||||
}
|
||||
|
||||
return render_template(
|
||||
'ipam/subnet_view.html',
|
||||
title=f'Subnet - {subnet.cidr}',
|
||||
title=subnet.cidr,
|
||||
subnet=subnet,
|
||||
subnet_info=subnet_info,
|
||||
servers=servers,
|
||||
total_ips=total_ips,
|
||||
used_ips=used_ips,
|
||||
usage_percent=usage_percent,
|
||||
now=datetime.now()
|
||||
)
|
||||
|
||||
@bp.route('/subnet/<int:subnet_id>/scan')
|
||||
@bp.route('/subnet/<int:subnet_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def subnet_edit(subnet_id):
|
||||
"""Edit a subnet"""
|
||||
subnet = Subnet.query.get_or_404(subnet_id)
|
||||
|
||||
if request.method == 'POST':
|
||||
cidr = request.form.get('cidr')
|
||||
location = request.form.get('location')
|
||||
auto_scan = request.form.get('auto_scan') == 'on'
|
||||
|
||||
# Validate inputs
|
||||
if not all([cidr, location]):
|
||||
flash('All fields are required', 'danger')
|
||||
return render_template('ipam/subnet_form.html',
|
||||
title='Edit Subnet',
|
||||
subnet=subnet,
|
||||
edit_mode=True)
|
||||
|
||||
# Validate CIDR format
|
||||
try:
|
||||
ipaddress.ip_network(cidr, strict=False)
|
||||
except ValueError:
|
||||
flash('Invalid CIDR format', 'danger')
|
||||
return render_template('ipam/subnet_form.html',
|
||||
title='Edit Subnet',
|
||||
subnet=subnet,
|
||||
edit_mode=True)
|
||||
|
||||
# Update subnet
|
||||
subnet.cidr = cidr
|
||||
subnet.location = location
|
||||
subnet.auto_scan = auto_scan
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
flash(f'Subnet {cidr} has been updated', 'success')
|
||||
return redirect(url_for('ipam.subnet_view', subnet_id=subnet.id))
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error updating subnet: {str(e)}', 'danger')
|
||||
|
||||
return render_template('ipam/subnet_form.html',
|
||||
title='Edit Subnet',
|
||||
subnet=subnet,
|
||||
edit_mode=True)
|
||||
|
||||
@bp.route('/subnet/<int:subnet_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def subnet_delete(subnet_id):
|
||||
"""Delete a subnet"""
|
||||
subnet = Subnet.query.get_or_404(subnet_id)
|
||||
|
||||
# Check if subnet has servers
|
||||
servers_count = Server.query.filter_by(subnet_id=subnet_id).count()
|
||||
if servers_count > 0:
|
||||
flash(f'Cannot delete subnet {subnet.cidr}. It has {servers_count} servers assigned.', 'danger')
|
||||
return redirect(url_for('ipam.subnet_view', subnet_id=subnet_id))
|
||||
|
||||
db.session.delete(subnet)
|
||||
db.session.commit()
|
||||
|
||||
flash(f'Subnet {subnet.cidr} has been deleted', 'success')
|
||||
return redirect(url_for('ipam.ipam_home'))
|
||||
|
||||
@bp.route('/subnet/<int:subnet_id>/scan', methods=['POST'])
|
||||
@login_required
|
||||
def subnet_scan(subnet_id):
|
||||
"""Scan a subnet for active hosts"""
|
||||
"""Manually scan a subnet"""
|
||||
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')
|
||||
# Call the scan function with manual_trigger=True
|
||||
scan(subnet, manual_trigger=True)
|
||||
db.session.commit()
|
||||
flash(f'Scan completed for subnet {subnet.cidr}', 'success')
|
||||
except Exception as e:
|
||||
flash(f'Error scanning subnet: {e}', 'danger')
|
||||
db.session.rollback()
|
||||
flash(f'Error scanning subnet: {str(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()
|
||||
)
|
||||
return redirect(url_for('ipam.subnet_view', subnet_id=subnet_id))
|
Binary file not shown.
|
@ -1,23 +1,49 @@
|
|||
from app.core.extensions import db
|
||||
from app.core.models import Subnet, Server, App
|
||||
from app.core.auth import User
|
||||
from app.core.models import Subnet, Server, App, Port
|
||||
from app.core.auth import User # Import User from auth module
|
||||
import json
|
||||
|
||||
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"}]')
|
||||
|
||||
"""Add sample data to the database"""
|
||||
# Create a default subnet if none exists
|
||||
if Subnet.query.count() == 0:
|
||||
subnet = Subnet(
|
||||
cidr='192.168.1.0/24',
|
||||
location='Office',
|
||||
auto_scan=True,
|
||||
active_hosts=json.dumps([])
|
||||
)
|
||||
db.session.add(subnet)
|
||||
|
||||
# Create a sample server
|
||||
server = Server(
|
||||
hostname='server1',
|
||||
ip_address='192.168.1.10',
|
||||
subnet=subnet,
|
||||
documentation='# Server 1\n\nThis is a sample server.'
|
||||
)
|
||||
db.session.add(server)
|
||||
|
||||
# Create a sample app
|
||||
app = App(
|
||||
name='Web App',
|
||||
server=server,
|
||||
documentation='# Welcome to Web App\n\nThis is a sample application.'
|
||||
)
|
||||
db.session.add(app)
|
||||
|
||||
# Add some ports
|
||||
ports = [
|
||||
Port(app=app, port_number=80, protocol='TCP', description='HTTP'),
|
||||
Port(app=app, port_number=443, protocol='TCP', description='HTTPS')
|
||||
]
|
||||
db.session.add_all(ports)
|
||||
|
||||
# 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")
|
||||
admin = User(username='admin', 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()
|
||||
|
|
|
@ -7,63 +7,41 @@ from app.core.extensions import db
|
|||
from app.core.models import Subnet, Server
|
||||
import json
|
||||
import subprocess
|
||||
import concurrent.futures
|
||||
from datetime import datetime
|
||||
import platform
|
||||
|
||||
def scan(cidr, max_threads=10, save_results=False):
|
||||
def scan(subnet, manual_trigger=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
|
||||
subnet: The subnet object to scan
|
||||
manual_trigger: If False, only scan if the subnet hasn't been scanned recently
|
||||
"""
|
||||
print(f"Starting scan of {cidr}")
|
||||
network = ipaddress.ip_network(cidr)
|
||||
# Skip if not auto scan and not manually triggered
|
||||
if not subnet.auto_scan and not manual_trigger:
|
||||
return False
|
||||
|
||||
# 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]
|
||||
active_hosts = []
|
||||
|
||||
# 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
|
||||
try:
|
||||
# Parse the CIDR notation
|
||||
network = ipaddress.ip_network(subnet.cidr, strict=False)
|
||||
|
||||
# For each address in this network, ping it
|
||||
for ip in network.hosts():
|
||||
if ping(str(ip)):
|
||||
active_hosts.append(str(ip))
|
||||
|
||||
# Update subnet with scan results
|
||||
subnet.active_hosts = json.dumps(active_hosts)
|
||||
subnet.last_scanned = datetime.utcnow()
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error scanning subnet {subnet.cidr}: {str(e)}")
|
||||
return False
|
||||
|
||||
def scan_worker(ip_list, results, index):
|
||||
"""Worker function for threading"""
|
||||
|
@ -76,13 +54,23 @@ def scan_worker(ip_list, results, index):
|
|||
'status': 'up'
|
||||
})
|
||||
|
||||
def ping(ip):
|
||||
"""Ping an IP address and return True if it responds"""
|
||||
def ping(host):
|
||||
"""
|
||||
Returns True if host responds to a ping request
|
||||
"""
|
||||
# Ping parameters based on OS
|
||||
param = '-n' if platform.system().lower() == 'windows' else '-c'
|
||||
# Build the command
|
||||
command = ['ping', param, '1', '-w', '1', host]
|
||||
|
||||
try:
|
||||
# Faster timeout (1 second)
|
||||
subprocess.check_output(['ping', '-c', '1', '-W', '1', str(ip)], stderr=subprocess.STDOUT)
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
# Run the command and capture output
|
||||
output = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=2)
|
||||
# Return True if ping was successful
|
||||
return output.returncode == 0
|
||||
except subprocess.TimeoutExpired:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_hostname(ip):
|
||||
|
@ -166,7 +154,7 @@ def schedule_subnet_scans():
|
|||
# Start a thread for each subnet
|
||||
thread = threading.Thread(
|
||||
target=scan,
|
||||
args=(subnet.cidr,),
|
||||
args=(subnet,),
|
||||
daemon=True
|
||||
)
|
||||
thread.start()
|
||||
|
|
|
@ -1,14 +1,35 @@
|
|||
/* Custom styles for the app */
|
||||
:root {
|
||||
--background-color: #f5f8fa;
|
||||
--text-color: #333;
|
||||
--card-bg: #fff;
|
||||
--border-color: #e3e8ee;
|
||||
--sidebar-bg: #f0f2f5;
|
||||
--sidebar-hover-bg: #e0e5ee;
|
||||
--highlight-color: #3b82f6;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] {
|
||||
--background-color: #1a2234;
|
||||
--text-color: #e6e8eb;
|
||||
--card-bg: #24304d;
|
||||
--border-color: #374564;
|
||||
--sidebar-bg: #151a27;
|
||||
--sidebar-hover-bg: #1c2133;
|
||||
--highlight-color: #3f8cff;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background-color: #f5f7fb;
|
||||
color: #232e3c;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.markdown-body {
|
||||
padding: 1rem;
|
||||
background-color: #fff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.125);
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
|
@ -92,85 +113,160 @@ body {
|
|||
|
||||
/* Sidebar styles */
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
background-color: var(--sidebar-bg);
|
||||
color: var(--text-color);
|
||||
height: 100vh;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
background: #fff;
|
||||
box-shadow: 0 0 2rem 0 rgba(136, 152, 170, .15);
|
||||
width: 250px;
|
||||
z-index: 1000;
|
||||
overflow-y: auto;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
padding: 1.5rem;
|
||||
padding: 1.5rem 1rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 64px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.sidebar-brand-text {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
padding: 0.75rem 1.5rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar-heading {
|
||||
padding: 0.75rem 1rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: #8898aa;
|
||||
letter-spacing: 0.04em;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.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;
|
||||
padding: 0.5rem 1rem;
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
border-radius: 0.25rem;
|
||||
margin: 0.2rem 0.5rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-item:hover {
|
||||
color: #5e72e4;
|
||||
background: rgba(94, 114, 228, 0.1);
|
||||
text-decoration: none;
|
||||
background-color: var(--sidebar-hover-bg);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.sidebar-item.active {
|
||||
color: #5e72e4;
|
||||
background: rgba(94, 114, 228, 0.1);
|
||||
background-color: var(--highlight-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
.main-content {
|
||||
margin-left: 250px;
|
||||
padding: 1rem;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Header styles */
|
||||
.page-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 260px;
|
||||
.page-pretitle {
|
||||
color: #6c757d;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Responsive sidebar */
|
||||
@media (max-width: 992px) {
|
||||
/* Auth pages */
|
||||
body.auth-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
max-width: 450px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Port visualization */
|
||||
.port-map {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.port-map-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(10, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.port-item {
|
||||
padding: 4px;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.port-item:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Responsive tweaks */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
left: -260px;
|
||||
transition: left 0.3s ease;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.sidebar.show {
|
||||
left: 0;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.main-content.sidebar-open {
|
||||
margin-left: 250px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Theme switch */
|
||||
#theme-toggle {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-icon-light {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.theme-icon-dark {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .theme-icon-light {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .theme-icon-dark {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Notification area */
|
||||
|
|
|
@ -58,6 +58,24 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for DOM to be fully loaded
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Initialize theme toggle
|
||||
initThemeToggle();
|
||||
|
||||
// Initialize clipboard functionality
|
||||
initClipboard();
|
||||
|
||||
// Initialize port map tooltips
|
||||
initTooltips();
|
||||
|
||||
// Initialize mobile sidebar
|
||||
initMobileSidebar();
|
||||
|
||||
// Initialize notifications
|
||||
initNotifications();
|
||||
});
|
||||
});
|
||||
|
||||
function initTiptapEditor(element) {
|
||||
|
@ -134,9 +152,108 @@ function showNotification(message, type = 'info') {
|
|||
|
||||
notificationArea.appendChild(notification);
|
||||
|
||||
// Remove notification after 3 seconds
|
||||
// Auto-remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
notification.classList.remove('show');
|
||||
setTimeout(() => notification.remove(), 150);
|
||||
}, 3000);
|
||||
if (notification.parentNode) {
|
||||
notification.remove();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function initThemeToggle() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', function () {
|
||||
const currentTheme = document.documentElement.getAttribute('data-bs-theme') || 'light';
|
||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
|
||||
document.documentElement.setAttribute('data-bs-theme', newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
|
||||
console.log(`Theme switched to ${newTheme} mode`);
|
||||
});
|
||||
}
|
||||
|
||||
// Load saved theme or use OS preference
|
||||
const storedTheme = localStorage.getItem('theme');
|
||||
if (storedTheme) {
|
||||
document.documentElement.setAttribute('data-bs-theme', storedTheme);
|
||||
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.documentElement.setAttribute('data-bs-theme', 'dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
}
|
||||
}
|
||||
|
||||
function initClipboard() {
|
||||
// Add click handlers to any clipboard copy buttons
|
||||
document.querySelectorAll('.copy-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function () {
|
||||
const textToCopy = this.getAttribute('data-clipboard-text');
|
||||
if (textToCopy) {
|
||||
navigator.clipboard.writeText(textToCopy)
|
||||
.then(() => {
|
||||
showNotification('Copied to clipboard!', 'success');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to copy: ', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initTooltips() {
|
||||
const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||
if (tooltips.length > 0) {
|
||||
Array.from(tooltips).map(tooltipNode => new bootstrap.Tooltip(tooltipNode));
|
||||
}
|
||||
}
|
||||
|
||||
function initMobileSidebar() {
|
||||
// Sidebar toggle for mobile
|
||||
const sidebarToggler = document.querySelector('.sidebar-toggler');
|
||||
if (sidebarToggler) {
|
||||
sidebarToggler.addEventListener('click', function () {
|
||||
document.querySelector('.sidebar').classList.toggle('show');
|
||||
document.querySelector('.main-content').classList.toggle('sidebar-open');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function initNotifications() {
|
||||
// Add flash messages as notifications
|
||||
const flashMessages = document.querySelectorAll('.alert.flash-message');
|
||||
flashMessages.forEach(message => {
|
||||
setTimeout(() => {
|
||||
const bsAlert = new bootstrap.Alert(message);
|
||||
bsAlert.close();
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
// For random port suggestion
|
||||
async function suggestRandomPort(serverId) {
|
||||
try {
|
||||
const response = await fetch(`/api/servers/${serverId}/suggest_port`);
|
||||
if (!response.ok) throw new Error('Failed to get port suggestion');
|
||||
|
||||
const data = await response.json();
|
||||
if (data.port) {
|
||||
// Copy to clipboard
|
||||
navigator.clipboard.writeText(data.port.toString())
|
||||
.then(() => {
|
||||
showNotification(`Port ${data.port} copied to clipboard!`, 'success');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to copy: ', err);
|
||||
showNotification(`Suggested free port: ${data.port}`, 'info');
|
||||
});
|
||||
}
|
||||
return data.port;
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
showNotification('Failed to suggest port', 'danger');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
213
app/templates/dashboard/app_edit.html
Normal file
213
app/templates/dashboard/app_edit.html
Normal file
|
@ -0,0 +1,213 @@
|
|||
{% 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">
|
||||
Edit Application: {{ app.name }}
|
||||
</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_edit', app_id=app.id) }}">
|
||||
<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" value="{{ app.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 }}" {% if server.id==app.server_id %}selected{% endif %}>
|
||||
{{ 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">{{ app.documentation }}</textarea>
|
||||
<small class="form-hint">Supports Markdown formatting</small>
|
||||
</div>
|
||||
|
||||
<!-- Port management section -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Application Ports</label>
|
||||
<div id="ports-container">
|
||||
{% if app.ports %}
|
||||
{% for port in app.ports %}
|
||||
<div class="port-entry mb-2 row">
|
||||
<div class="col-4">
|
||||
<input type="number" class="form-control" name="port_numbers[]" value="{{ port.port_number }}"
|
||||
placeholder="Port number" min="1" max="65535">
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<select class="form-select" name="protocols[]">
|
||||
<option value="TCP" {% if port.protocol=='TCP' %}selected{% endif %}>TCP</option>
|
||||
<option value="UDP" {% if port.protocol=='UDP' %}selected{% endif %}>UDP</option>
|
||||
<option value="HTTP" {% if port.protocol=='HTTP' %}selected{% endif %}>HTTP</option>
|
||||
<option value="HTTPS" {% if port.protocol=='HTTPS' %}selected{% endif %}>HTTPS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<input type="text" class="form-control" name="port_descriptions[]" value="{{ port.description }}"
|
||||
placeholder="Description">
|
||||
</div>
|
||||
<div class="col-1">
|
||||
<button type="button" class="btn btn-outline-danger remove-port"><i class="ti ti-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="port-entry mb-2 row">
|
||||
<div class="col-4">
|
||||
<input type="number" class="form-control" name="port_numbers[]" placeholder="Port number" min="1"
|
||||
max="65535">
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<select class="form-select" name="protocols[]">
|
||||
<option value="TCP">TCP</option>
|
||||
<option value="UDP">UDP</option>
|
||||
<option value="HTTP">HTTP</option>
|
||||
<option value="HTTPS">HTTPS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<input type="text" class="form-control" name="port_descriptions[]" placeholder="Description">
|
||||
</div>
|
||||
<div class="col-1">
|
||||
<button type="button" class="btn btn-outline-danger remove-port"><i class="ti ti-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<button type="button" id="add-port" class="btn btn-sm btn-outline-primary">
|
||||
<i class="ti ti-plus me-1"></i> Add Port
|
||||
</button>
|
||||
<button type="button" id="suggest-port" class="btn btn-sm btn-outline-secondary ms-2">
|
||||
<i class="ti ti-bolt me-1"></i> Suggest Free Port
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between mt-4">
|
||||
<a href="{{ url_for('dashboard.server_view', server_id=app.server_id) }}" class="btn btn-outline-secondary">
|
||||
Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const portsContainer = document.getElementById('ports-container');
|
||||
const addPortButton = document.getElementById('add-port');
|
||||
const suggestPortButton = document.getElementById('suggest-port');
|
||||
|
||||
// Add new port field
|
||||
addPortButton.addEventListener('click', function () {
|
||||
const portEntry = document.createElement('div');
|
||||
portEntry.className = 'port-entry mb-2 row';
|
||||
|
||||
portEntry.innerHTML = `
|
||||
<div class="col-4">
|
||||
<input type="number" class="form-control" name="port_numbers[]" placeholder="Port number" min="1" max="65535">
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<select class="form-select" name="protocols[]">
|
||||
<option value="TCP">TCP</option>
|
||||
<option value="UDP">UDP</option>
|
||||
<option value="HTTP">HTTP</option>
|
||||
<option value="HTTPS">HTTPS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<input type="text" class="form-control" name="port_descriptions[]" placeholder="Description">
|
||||
</div>
|
||||
<div class="col-1">
|
||||
<button type="button" class="btn btn-outline-danger remove-port"><i class="ti ti-trash"></i></button>
|
||||
</div>
|
||||
`;
|
||||
portsContainer.appendChild(portEntry);
|
||||
|
||||
// Add event listener to the new remove button
|
||||
const removeButton = portEntry.querySelector('.remove-port');
|
||||
removeButton.addEventListener('click', function () {
|
||||
portEntry.remove();
|
||||
});
|
||||
});
|
||||
|
||||
// Add event listeners to initial remove buttons
|
||||
document.querySelectorAll('.remove-port').forEach(button => {
|
||||
button.addEventListener('click', function () {
|
||||
this.closest('.port-entry').remove();
|
||||
});
|
||||
});
|
||||
|
||||
// Suggest a free port
|
||||
suggestPortButton.addEventListener('click', async function () {
|
||||
try {
|
||||
const serverId = document.querySelector('select[name="server_id"]').value;
|
||||
if (!serverId) {
|
||||
alert('Please select a server first');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/servers/${serverId}/suggest_port`);
|
||||
if (!response.ok) throw new Error('Failed to get port suggestion');
|
||||
|
||||
const data = await response.json();
|
||||
if (data.port) {
|
||||
// Find the first empty port input or add a new one
|
||||
let portInput = document.querySelector('input[name="port_numbers[]"]:not([value])');
|
||||
if (!portInput) {
|
||||
addPortButton.click();
|
||||
portInput = document.querySelector('input[name="port_numbers[]"]:not([value])');
|
||||
}
|
||||
|
||||
portInput.value = data.port;
|
||||
|
||||
// Copy to clipboard
|
||||
navigator.clipboard.writeText(data.port.toString())
|
||||
.then(() => {
|
||||
showNotification('Port copied to clipboard!', 'success');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to copy: ', err);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
showNotification('Failed to suggest port', 'danger');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -46,12 +46,135 @@
|
|||
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>
|
||||
|
||||
<!-- Port management section -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Application Ports</label>
|
||||
<div id="ports-container">
|
||||
<div class="port-entry mb-2 row">
|
||||
<div class="col-4">
|
||||
<input type="number" class="form-control" name="port_numbers[]" placeholder="Port number" min="1"
|
||||
max="65535">
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<select class="form-select" name="protocols[]">
|
||||
<option value="TCP">TCP</option>
|
||||
<option value="UDP">UDP</option>
|
||||
<option value="HTTP">HTTP</option>
|
||||
<option value="HTTPS">HTTPS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<input type="text" class="form-control" name="port_descriptions[]" placeholder="Description">
|
||||
</div>
|
||||
<div class="col-1">
|
||||
<button type="button" class="btn btn-outline-danger remove-port"><i class="ti ti-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" id="add-port">
|
||||
<i class="ti ti-plus me-1"></i> Add Port
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm ms-2" id="suggest-port">
|
||||
<i class="ti ti-bulb me-1"></i> Suggest Free Port
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary">Save Application</button>
|
||||
<a href="{{ url_for('dashboard.dashboard_home') }}" class="btn btn-outline-secondary ms-2">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const portsContainer = document.getElementById('ports-container');
|
||||
const addPortButton = document.getElementById('add-port');
|
||||
const suggestPortButton = document.getElementById('suggest-port');
|
||||
|
||||
// Add a new port field
|
||||
addPortButton.addEventListener('click', function () {
|
||||
const portEntry = document.createElement('div');
|
||||
portEntry.className = 'port-entry mb-2 row';
|
||||
portEntry.innerHTML = `
|
||||
<div class="col-4">
|
||||
<input type="number" class="form-control" name="port_numbers[]" placeholder="Port number" min="1" max="65535">
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<select class="form-select" name="protocols[]">
|
||||
<option value="TCP">TCP</option>
|
||||
<option value="UDP">UDP</option>
|
||||
<option value="HTTP">HTTP</option>
|
||||
<option value="HTTPS">HTTPS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<input type="text" class="form-control" name="port_descriptions[]" placeholder="Description">
|
||||
</div>
|
||||
<div class="col-1">
|
||||
<button type="button" class="btn btn-outline-danger remove-port"><i class="ti ti-trash"></i></button>
|
||||
</div>
|
||||
`;
|
||||
portsContainer.appendChild(portEntry);
|
||||
|
||||
// Add event listener to the new remove button
|
||||
const removeButton = portEntry.querySelector('.remove-port');
|
||||
removeButton.addEventListener('click', function () {
|
||||
portEntry.remove();
|
||||
});
|
||||
});
|
||||
|
||||
// Add event listeners to initial remove buttons
|
||||
document.querySelectorAll('.remove-port').forEach(button => {
|
||||
button.addEventListener('click', function () {
|
||||
this.closest('.port-entry').remove();
|
||||
});
|
||||
});
|
||||
|
||||
// Suggest a free port
|
||||
suggestPortButton.addEventListener('click', async function () {
|
||||
try {
|
||||
const serverId = document.querySelector('select[name="server_id"]').value;
|
||||
if (!serverId) {
|
||||
alert('Please select a server first');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/servers/${serverId}/suggest_port`);
|
||||
if (!response.ok) throw new Error('Failed to get port suggestion');
|
||||
|
||||
const data = await response.json();
|
||||
if (data.port) {
|
||||
// Find the first empty port input or add a new one
|
||||
let portInput = document.querySelector('input[name="port_numbers[]"]:not([value])');
|
||||
if (!portInput) {
|
||||
addPortButton.click();
|
||||
portInput = document.querySelector('input[name="port_numbers[]"]:not([value])');
|
||||
}
|
||||
|
||||
portInput.value = data.port;
|
||||
|
||||
// Copy to clipboard
|
||||
navigator.clipboard.writeText(data.port.toString())
|
||||
.then(() => {
|
||||
showNotification('Port copied to clipboard!', 'success');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to copy: ', err);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
showNotification('Failed to suggest port', 'danger');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -5,133 +5,201 @@
|
|||
<div class="page-header d-print-none">
|
||||
<div class="row align-items-center">
|
||||
<div class="col">
|
||||
<div class="page-pretitle">
|
||||
Server Details
|
||||
</div>
|
||||
<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 class="btn-list">
|
||||
<a href="{{ url_for('dashboard.server_edit', server_id=server.id) }}" class="btn btn-primary">
|
||||
<i class="ti ti-edit me-1"></i> Edit
|
||||
</a>
|
||||
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#deleteServerModal">
|
||||
<i class="ti ti-trash me-1"></i> Delete
|
||||
</button>
|
||||
</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">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>
|
||||
<dl class="row">
|
||||
<dt class="col-5">IP Address:</dt>
|
||||
<dd class="col-7">{{ server.ip_address }}</dd>
|
||||
|
||||
<dt class="col-5">Subnet:</dt>
|
||||
<dd class="col-7">
|
||||
<a href="{{ url_for('ipam.subnet_view', subnet_id=server.subnet.id) }}">
|
||||
{{ server.subnet.cidr }}
|
||||
</a>
|
||||
</dd>
|
||||
|
||||
<dt class="col-5">Location:</dt>
|
||||
<dd class="col-7">{{ server.subnet.location }}</dd>
|
||||
|
||||
<dt class="col-5">Created:</dt>
|
||||
<dd class="col-7">{{ server.created_at.strftime('%Y-%m-%d') }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Port Usage Map -->
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Open Ports</h3>
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<h3 class="card-title">Port Usage</h3>
|
||||
<div class="ms-auto">
|
||||
<button id="get-random-port" class="btn btn-sm btn-outline-primary">
|
||||
<i class="ti ti-clipboard-copy me-1"></i> Get Free Port
|
||||
</button>
|
||||
</div>
|
||||
</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 class="port-map">
|
||||
<div class="port-map-grid">
|
||||
{% for i in range(1, 101) %}
|
||||
{% set port_num = 8000 + i - 1 %}
|
||||
{% set port_used = false %}
|
||||
{% set port_app = "" %}
|
||||
{% set port_color = "" %}
|
||||
{% set tooltip = "" %}
|
||||
|
||||
{% for app in server.apps %}
|
||||
{% for port in app.ports %}
|
||||
{% if port.port_number == port_num %}
|
||||
{% set port_used = true %}
|
||||
{% set port_app = app.name %}
|
||||
{% set port_color = "bg-" ~ ["primary", "success", "info", "warning", "danger"][(app.id % 5)] %}
|
||||
{% set tooltip = app.name ~ " - " ~ port.description %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
|
||||
<div class="port-item {{ port_color if port_used else 'bg-light' }}" data-port="{{ port_num }}"
|
||||
data-bs-toggle="tooltip" title="{{ tooltip if port_used else 'Free port: ' ~ port_num }}">
|
||||
{{ port_num }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 text-muted small">
|
||||
<div class="d-flex flex-wrap">
|
||||
<div class="me-3"><span class="badge bg-light">Port</span> Free</div>
|
||||
<div><span class="badge bg-primary">Port</span> Used</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-8">
|
||||
<!-- Applications -->
|
||||
<div class="card">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<h3 class="card-title">Applications</h3>
|
||||
<div class="ms-auto">
|
||||
<a href="{{ url_for('dashboard.app_new', server_id=server.id) }}" class="btn btn-sm btn-primary">
|
||||
<i class="ti ti-plus me-1"></i> Add Application
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if server.apps %}
|
||||
<div class="accordion" id="applicationAccordion">
|
||||
{% for app in server.apps %}
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="heading{{ app.id }}">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#collapse{{ app.id }}" aria-expanded="false" aria-controls="collapse{{ app.id }}">
|
||||
<span class="me-2">{{ app.name }}</span>
|
||||
{% if app.ports %}
|
||||
<div class="ms-auto d-flex">
|
||||
{% for port in app.ports %}
|
||||
<span class="badge bg-primary me-1">{{ port.port_number }}/{{ port.protocol }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</button>
|
||||
</h2>
|
||||
<div id="collapse{{ app.id }}" class="accordion-collapse collapse" aria-labelledby="heading{{ app.id }}"
|
||||
data-bs-parent="#applicationAccordion">
|
||||
<div class="accordion-body">
|
||||
<div class="d-flex justify-content-end mb-2">
|
||||
<a href="{{ url_for('dashboard.app_edit', app_id=app.id) }}"
|
||||
class="btn btn-sm btn-outline-primary me-2">
|
||||
<i class="ti ti-edit"></i> Edit
|
||||
</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger"
|
||||
onclick="confirmDeleteApp({{ app.id }}, '{{ app.name }}')">
|
||||
<i class="ti ti-trash"></i> Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Ports -->
|
||||
{% if app.ports %}
|
||||
<div class="mb-3">
|
||||
<h5>Ports</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Port</th>
|
||||
<th>Protocol</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for port in app.ports %}
|
||||
<tr>
|
||||
<td>{{ port.port_number }}</td>
|
||||
<td>{{ port.protocol }}</td>
|
||||
<td>{{ port.description }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Documentation -->
|
||||
{% if app.documentation %}
|
||||
<div class="mt-3">
|
||||
<h5>Documentation</h5>
|
||||
<div class="markdown-body">
|
||||
{{ app.documentation|markdown }}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-muted">No documentation available</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-3">
|
||||
No open ports detected.
|
||||
<div class="empty">
|
||||
<div class="empty-icon">
|
||||
<i class="ti ti-apps"></i>
|
||||
</div>
|
||||
<p class="empty-title">No applications found</p>
|
||||
<p class="empty-subtitle text-muted">
|
||||
This server doesn't have any applications yet.
|
||||
</p>
|
||||
<div class="empty-action">
|
||||
<a href="{{ url_for('dashboard.app_new', server_id=server.id) }}" class="btn btn-primary">
|
||||
<i class="ti ti-plus me-1"></i> Add Application
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
@ -139,4 +207,135 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Server Modal -->
|
||||
<div class="modal fade" id="deleteServerModal" tabindex="-1" aria-labelledby="deleteServerModalLabel"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="deleteServerModalLabel">Confirm Delete</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
Are you sure you want to delete server <strong>{{ server.hostname }}</strong>? This action cannot be undone.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<form action="{{ url_for('dashboard.server_delete', server_id=server.id) }}" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-danger">Delete Server</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete App Modal (created dynamically) -->
|
||||
<div class="modal fade" id="deleteAppModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Confirm Delete</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="deleteAppModalBody">
|
||||
Are you sure you want to delete this application?
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<form id="deleteAppForm" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-danger">Delete Application</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Initialize tooltips
|
||||
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||
tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||
return new bootstrap.Tooltip(tooltipTriggerEl);
|
||||
});
|
||||
|
||||
// Random port generator
|
||||
const getRandomPortBtn = document.getElementById('get-random-port');
|
||||
if (getRandomPortBtn) {
|
||||
getRandomPortBtn.addEventListener('click', async function () {
|
||||
try {
|
||||
const response = await fetch(`/api/servers/{{ server.id }}/suggest_port`);
|
||||
if (!response.ok) throw new Error('Failed to get port suggestion');
|
||||
|
||||
const data = await response.json();
|
||||
if (data.port) {
|
||||
// Copy to clipboard
|
||||
navigator.clipboard.writeText(data.port.toString())
|
||||
.then(() => {
|
||||
showNotification(`Port ${data.port} copied to clipboard!`, 'success');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to copy: ', err);
|
||||
showNotification(`Suggested free port: ${data.port}`, 'info');
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
showNotification('Failed to suggest port', 'danger');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Function to handle app deletion confirmation
|
||||
function confirmDeleteApp(appId, appName) {
|
||||
const modal = document.getElementById('deleteAppModal');
|
||||
const modalBody = document.getElementById('deleteAppModalBody');
|
||||
const deleteForm = document.getElementById('deleteAppForm');
|
||||
|
||||
modalBody.textContent = `Are you sure you want to delete application "${appName}"? This action cannot be undone.`;
|
||||
deleteForm.action = `/dashboard/apps/${appId}/delete`;
|
||||
|
||||
const bsModal = new bootstrap.Modal(modal);
|
||||
bsModal.show();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
<style>
|
||||
.port-map {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.port-map-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(10, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.port-item {
|
||||
padding: 4px;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.port-item:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.markdown-body {
|
||||
padding: 1rem;
|
||||
background-color: var(--tblr-bg-surface);
|
||||
border: 1px solid var(--tblr-border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
{% block content %}
|
||||
<div class="container text-center py-5">
|
||||
<div class="display-1 text-muted mb-3">404</div>
|
||||
<div class="display-1 text-muted mb-5">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.
|
||||
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
|
||||
<i class="ti ti-arrow-left me-2"></i>Go back to dashboard
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
{% 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>
|
||||
<div class="display-1 text-muted mb-5">500</div>
|
||||
<h1 class="h2 mb-3">Server Error</h1>
|
||||
<p class="h4 text-muted font-weight-normal mb-4">
|
||||
Something went wrong on our end. Please try again later.
|
||||
Oops, something went wrong on our end
|
||||
</p>
|
||||
<a href="{{ url_for('dashboard.dashboard_home') }}" class="btn btn-primary">
|
||||
<i class="ti ti-arrow-left me-2"></i> Return to dashboard
|
||||
<i class="ti ti-arrow-left me-2"></i>Go back to dashboard
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -5,55 +5,129 @@
|
|||
<div class="page-header d-print-none">
|
||||
<div class="row align-items-center">
|
||||
<div class="col">
|
||||
<div class="page-pretitle">
|
||||
Subnet Details
|
||||
</div>
|
||||
<h2 class="page-title">
|
||||
{{ subnet.cidr }}
|
||||
{{ subnet.cidr }} - {{ subnet.location }}
|
||||
</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 href="{{ url_for('ipam.subnet_edit', subnet_id=subnet.id) }}" class="btn btn-primary">
|
||||
<i class="ti ti-edit me-1"></i> Edit
|
||||
</a>
|
||||
<form method="POST" action="{{ url_for('ipam.subnet_scan', subnet_id=subnet.id) }}" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-warning">
|
||||
<i class="ti ti-search me-1"></i> Scan Now
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#deleteSubnetModal">
|
||||
<i class="ti ti-trash me-1"></i> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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 mt-3" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="row mt-3">
|
||||
<div class="col-md-8">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Registered Hosts</h3>
|
||||
<h3 class="card-title">Subnet Information</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if servers %}
|
||||
<table class="table table-vcenter">
|
||||
<tr>
|
||||
<td><strong>CIDR Notation</strong></td>
|
||||
<td>{{ subnet.cidr }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Location</strong></td>
|
||||
<td>{{ subnet.location }}</td>
|
||||
</tr>
|
||||
{% set network = get_ip_network(subnet.cidr) %}
|
||||
{% if network %}
|
||||
<tr>
|
||||
<td><strong>Network Address</strong></td>
|
||||
<td>{{ network.network_address }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Broadcast Address</strong></td>
|
||||
<td>{{ network.broadcast_address if network.prefixlen < 31 else 'N/A' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Netmask</strong></td>
|
||||
<td>{{ network.netmask }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Host Range</strong></td>
|
||||
<td>
|
||||
{% if network.prefixlen < 31 %} {{ network.network_address + 1 }} - {{ network.broadcast_address - 1 }}
|
||||
{% else %} {{ network.network_address }} - {{ network.broadcast_address }} {% endif %} </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Total Hosts</strong></td>
|
||||
<td>
|
||||
{% if network.prefixlen < 31 %} {{ network.num_addresses - 2 }} {% else %} {{ network.num_addresses }}
|
||||
{% endif %} </td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td><strong>Auto Scan</strong></td>
|
||||
<td>{{ 'Yes' if subnet.auto_scan else 'No' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Last Scanned</strong></td>
|
||||
<td>{{ subnet.last_scanned|default('Never', true) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Created</strong></td>
|
||||
<td>{{ subnet.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Servers in Subnet</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if subnet.servers %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter">
|
||||
<table class="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Hostname</th>
|
||||
<th>IP Address</th>
|
||||
<th>Created</th>
|
||||
<th class="w-1"></th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for server in servers %}
|
||||
{% for server in subnet.servers %}
|
||||
<tr>
|
||||
<td>{{ server.hostname }}</td>
|
||||
<td><a href="{{ url_for('dashboard.server_view', server_id=server.id) }}">{{ server.hostname }}</a>
|
||||
</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
|
||||
<i class="ti ti-eye"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -62,69 +136,51 @@
|
|||
</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 class="empty">
|
||||
<div class="empty-icon">
|
||||
<i class="ti ti-server"></i>
|
||||
</div>
|
||||
<p class="empty-title">No servers in this subnet</p>
|
||||
<p class="empty-subtitle text-muted">
|
||||
You can add a new server to this subnet from the dashboard
|
||||
</p>
|
||||
<div class="empty-action">
|
||||
<a href="{{ url_for('dashboard.server_new') }}" class="btn btn-primary">
|
||||
<i class="ti ti-plus me-2"></i> Add New Server
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteSubnetModal" tabindex="-1" aria-labelledby="deleteSubnetModalLabel"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="deleteSubnetModalLabel">Confirm Deletion</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</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 class="modal-body">
|
||||
<p>Are you sure you want to delete the subnet {{ subnet.cidr }}?</p>
|
||||
{% if subnet.servers %}
|
||||
<div class="alert alert-danger">
|
||||
<strong>Warning:</strong> This subnet has {{ subnet.servers|length }} servers assigned to it.
|
||||
You must delete or reassign these servers before deleting the subnet.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<form method="POST" action="{{ url_for('ipam.subnet_delete', subnet_id=subnet.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-danger" {{ 'disabled' if subnet.servers }}>Delete Subnet</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -16,6 +16,21 @@
|
|||
<!-- Custom CSS -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
|
||||
{% block styles %}{% endblock %}
|
||||
<script>
|
||||
// Check for saved theme preference or respect OS preference
|
||||
function initTheme() {
|
||||
const storedTheme = localStorage.getItem('theme');
|
||||
if (storedTheme) {
|
||||
document.documentElement.setAttribute('data-bs-theme', storedTheme);
|
||||
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.documentElement.setAttribute('data-bs-theme', 'dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
}
|
||||
}
|
||||
|
||||
// Run before page load to prevent flash
|
||||
initTheme();
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body class="{{ 'auth-page' if not current_user.is_authenticated else '' }}">
|
||||
|
@ -84,6 +99,12 @@
|
|||
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">Logout</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="ms-auto me-3">
|
||||
<button class="btn btn-icon" id="theme-toggle" aria-label="Toggle theme">
|
||||
<span class="ti ti-moon theme-icon-light"></span>
|
||||
<span class="ti ti-sun theme-icon-dark"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
|
Binary file not shown.
BIN
config/app-dev.db
Normal file
BIN
config/app-dev.db
Normal file
Binary file not shown.
|
@ -10,50 +10,56 @@ class Config:
|
|||
SESSION_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
REMEMBER_COOKIE_DURATION = timedelta(days=14)
|
||||
PERMANENT_SESSION_LIFETIME = timedelta(days=1)
|
||||
MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16 MB max upload
|
||||
|
||||
# Security headers
|
||||
SECURITY_HEADERS = {
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'SAMEORIGIN',
|
||||
'X-XSS-Protection': '1; mode=block',
|
||||
'Content-Security-Policy': "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net https://unpkg.com https://cdnjs.cloudflare.com 'unsafe-inline'; style-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com 'unsafe-inline'; font-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.gstatic.com data:; img-src 'self' data:;"
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def init_app(app):
|
||||
pass
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
"""Development 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')
|
||||
'sqlite:///' + os.path.join(basedir, 'app-dev.db')
|
||||
SESSION_COOKIE_SECURE = False
|
||||
|
||||
class TestingConfig(Config):
|
||||
"""Testing config."""
|
||||
TESTING = True
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL') or \
|
||||
'sqlite:///' + os.path.join(basedir, '..', 'instance', 'testing.db')
|
||||
'sqlite:///' + os.path.join(basedir, 'app-test.db')
|
||||
WTF_CSRF_ENABLED = False
|
||||
SESSION_COOKIE_SECURE = False
|
||||
|
||||
class ProductionConfig(Config):
|
||||
"""Production config."""
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
|
||||
'sqlite:///' + os.path.join(basedir, '..', 'instance', 'production.db')
|
||||
|
||||
'postgresql://user:password@localhost/production'
|
||||
|
||||
@classmethod
|
||||
def init_app(cls, app):
|
||||
Config.init_app(app)
|
||||
|
||||
# Production-specific logging
|
||||
# Log to stdout/stderr
|
||||
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 = RotatingFileHandler('logs/netdocs.log', maxBytes=10240, 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')
|
||||
app.logger.info('NetDocs startup')
|
||||
|
||||
config = {
|
||||
'development': DevelopmentConfig,
|
||||
|
|
Binary file not shown.
101
run.py
101
run.py
|
@ -3,6 +3,13 @@ import sys
|
|||
import importlib.util
|
||||
from flask import Flask, render_template
|
||||
from app import create_app
|
||||
from app.core.extensions import db
|
||||
from app.core.models import Server, Subnet, App, Port
|
||||
from app.core.auth import User # Import User from auth module
|
||||
from datetime import datetime
|
||||
import random
|
||||
import string
|
||||
import json
|
||||
|
||||
# Add the current directory to Python path
|
||||
current_dir = os.path.abspath(os.path.dirname(__file__))
|
||||
|
@ -60,12 +67,96 @@ def register_routes(app):
|
|||
except ImportError as e:
|
||||
print(f"Could not import IPAM blueprint: {e}")
|
||||
|
||||
# Create a development application instance
|
||||
print("Starting Flask app with SQLite database...")
|
||||
app = create_app('development')
|
||||
|
||||
@app.shell_context_processor
|
||||
def make_shell_context():
|
||||
return {
|
||||
'db': db,
|
||||
'User': User,
|
||||
'Server': Server,
|
||||
'Subnet': Subnet,
|
||||
'App': App,
|
||||
'Port': Port
|
||||
}
|
||||
|
||||
def init_db():
|
||||
"""Initialize database tables"""
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
def create_admin_user():
|
||||
"""Create an admin user if no users exist"""
|
||||
with app.app_context():
|
||||
if User.query.count() == 0:
|
||||
admin = User(
|
||||
username='admin',
|
||||
email='admin@example.com',
|
||||
is_admin=True
|
||||
)
|
||||
admin.set_password('admin')
|
||||
db.session.add(admin)
|
||||
db.session.commit()
|
||||
print("Created admin user: admin@example.com (password: admin)")
|
||||
|
||||
# Update seed_data to use consistent structures
|
||||
def seed_data():
|
||||
"""Add some sample data to the database"""
|
||||
with app.app_context():
|
||||
# Only seed if the database is empty
|
||||
if Subnet.query.count() == 0:
|
||||
# Create sample subnets
|
||||
subnet1 = Subnet(cidr='192.168.1.0/24', location='Office', active_hosts=json.dumps([]))
|
||||
subnet2 = Subnet(cidr='10.0.0.0/24', location='Datacenter', active_hosts=json.dumps([]))
|
||||
|
||||
db.session.add_all([subnet1, subnet2])
|
||||
db.session.commit()
|
||||
|
||||
# Create sample servers
|
||||
server1 = Server(hostname='web-server', ip_address='192.168.1.10', subnet=subnet1)
|
||||
server2 = Server(hostname='db-server', ip_address='192.168.1.11', subnet=subnet1)
|
||||
server3 = Server(hostname='app-server', ip_address='10.0.0.5', subnet=subnet2)
|
||||
|
||||
db.session.add_all([server1, server2, server3])
|
||||
db.session.commit()
|
||||
|
||||
# Create sample apps
|
||||
app1 = App(name='Website', server=server1, documentation='# Company Website\nRunning on Nginx/PHP')
|
||||
app2 = App(name='PostgreSQL', server=server2, documentation='# Database Server\nPostgreSQL 15')
|
||||
app3 = App(name='API Service', server=server3, documentation='# REST API\nNode.js service')
|
||||
|
||||
db.session.add_all([app1, app2, app3])
|
||||
db.session.commit()
|
||||
|
||||
# Create sample ports
|
||||
port1 = Port(app=app1, port_number=80, protocol='TCP', description='HTTP')
|
||||
port2 = Port(app=app1, port_number=443, protocol='TCP', description='HTTPS')
|
||||
port3 = Port(app=app2, port_number=5432, protocol='TCP', description='PostgreSQL')
|
||||
port4 = Port(app=app3, port_number=3000, protocol='TCP', description='Node.js API')
|
||||
|
||||
db.session.add_all([port1, port2, port3, port4])
|
||||
db.session.commit()
|
||||
|
||||
print("Sample data has been added to the database")
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Initialize database if needed
|
||||
if not os.path.exists('app.db') and 'sqlite' in app.config['SQLALCHEMY_DATABASE_URI']:
|
||||
print("Database not found, initializing...")
|
||||
try:
|
||||
init_db()
|
||||
create_admin_user()
|
||||
# Uncomment to add sample data
|
||||
# seed_data()
|
||||
except Exception as e:
|
||||
print(f"Error initializing database: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Run the application
|
||||
try:
|
||||
print("Starting Flask app with SQLite database...")
|
||||
app = create_app('development')
|
||||
app.run(debug=True, host='0.0.0.0', port=5000)
|
||||
app.run(debug=True, port=5000)
|
||||
except Exception as e:
|
||||
print(f"Error starting Flask app: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
Loading…
Add table
Add a link
Reference in a new issue