Initial Commit

This commit is contained in:
2025-05-27 17:55:08 +00:00
commit e5f50a815b
32 changed files with 1581 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
FROM mcr.microsoft.com/devcontainers/python:3.13
# Install system dependencies
RUN apt-get update && apt-get install -y \
sqlite3 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Set the working directory
WORKDIR /workspace
# Default command
CMD ["sleep", "infinity"]
+15
View File
@@ -0,0 +1,15 @@
{
"name": "Flask Dev Container",
"build": {
"dockerfile": "Dockerfile"
},
"settings": {},
"customizations": {
"vscode": {
"extensions": ["ms-python.python"]
}
},
"postCreateCommand": "pip install -r requirements.txt; curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64; chmod +x tailwindcss-linux-x64; mv tailwindcss-linux-x64 tailwindcss",
"forwardPorts": [5000],
"remoteUser": "vscode"
}
+4
View File
@@ -0,0 +1,4 @@
db/
__pycache__
tailwindcss
static/css/output.css
+45
View File
@@ -0,0 +1,45 @@
stages:
- buildandpush
- sonarqube_and_deploy
variables:
IMAGE_NAME: "$HARBOR_REGISTRY/internal/ipam"
before_script:
- echo "$HARBOR_PASSWORD" | docker login $HARBOR_REGISTRY -u "$HARBOR_USERNAME" --password-stdin
buildandpush:
stage: buildandpush
image: docker:latest
tags:
- docker
script:
- docker build -t $IMAGE_NAME:$IMAGE_TAG .
- docker tag $IMAGE_NAME:$IMAGE_TAG $IMAGE_NAME:latest
- docker push $IMAGE_NAME:$IMAGE_TAG
- docker push $IMAGE_NAME:latest
sonarqube:
stage: sonarqube_and_deploy
image:
name: sonarsource/sonar-scanner-cli:11
entrypoint: [""]
tags:
- docker
cache:
policy: pull-push
key: "sonar-cache-$CI_COMMIT_REF_SLUG"
paths:
- "${SONAR_USER_HOME}/cache"
before_script: []
script:
- sonar-scanner -Dsonar.host.url="${SONAR_HOST_URL}"
allow_failure: true
deploy:
stage: sonarqube_and_deploy
tags:
- k3s-lan-01
before_script: []
script:
- sudo kubectl replace -f deployment.yml --grace-period=60 --force
+12
View File
@@ -0,0 +1,12 @@
FROM python:3.13-slim
WORKDIR /app
COPY . /app
RUN pip install -r requirements.txt
RUN apt-get update && apt-get install -y sqlite3 curl
RUN rm -rf /var/lib/apt/lists/*
RUN curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 \
&& chmod +x tailwindcss-linux-x64 \
&& mv tailwindcss-linux-x64 tailwindcss \
&& ./tailwindcss -i ./static/css/input.css -o ./static/css/output.css --content "./templates/*.html,./static/js/*.js" --minify
EXPOSE 5000
CMD ["python", "app.py"]
+15
View File
@@ -0,0 +1,15 @@
from flask import Flask
from db import init_db, hash_password
from routes import register_routes
import os
os.chdir(os.path.dirname(os.path.abspath(__file__)))
app = Flask(__name__)
app.config['SECRET_KEY'] = '#{flask-secret}#'
register_routes(app)
if __name__ == '__main__':
init_db()
app.run(host='0.0.0.0', port=5000)
+96
View File
@@ -0,0 +1,96 @@
import os
import sqlite3
import hashlib
import base64
def hash_password(password, salt=None):
if salt is None:
salt = base64.b64encode(os.urandom(16)).decode('utf-8')
salted = (salt + password).encode('utf-8')
hashed = hashlib.sha256(salted).hexdigest()
return f'{salt}${hashed}'
def verify_password(password, hashed):
try:
salt, hash_val = hashed.split('$', 1)
except ValueError:
return False
return hash_password(password, salt) == hashed
def get_db_connection():
conn = sqlite3.connect('db/subnets.db')
conn.row_factory = sqlite3.Row
conn.execute('PRAGMA foreign_keys = ON;')
return conn
def init_db():
os.makedirs('db', exist_ok=True)
conn = sqlite3.connect('db/subnets.db')
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS User (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS AuditLog (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
action TEXT NOT NULL,
details TEXT,
subnet_id INTEGER,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES User(id),
FOREIGN KEY (subnet_id) REFERENCES Subnet(id)
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS Subnet (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
cidr TEXT NOT NULL,
site TEXT
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS IPAddress (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL,
hostname TEXT,
subnet_id INTEGER NOT NULL,
FOREIGN KEY (subnet_id) REFERENCES Subnet (id)
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS Device (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS DeviceIPAddress (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id INTEGER NOT NULL,
ip_id INTEGER NOT NULL,
FOREIGN KEY (device_id) REFERENCES Device (id),
FOREIGN KEY (ip_id) REFERENCES IPAddress (id)
)
''')
cursor.execute('SELECT COUNT(*) FROM User')
if cursor.fetchone()[0] == 0:
cursor.execute('''INSERT INTO User (name, email, password) VALUES (?, ?, ?)''',
('Jamie Banks', 'jamie@jdbnet.co.uk', hash_password('Drippy-Cavity-Jawline')))
conn.commit()
conn.close()
+87
View File
@@ -0,0 +1,87 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: ipam
namespace: ipam
spec:
replicas: 1
selector:
matchLabels:
app: ipam
template:
metadata:
labels:
app: ipam
spec:
containers:
- name: ipam
image: docker.jdbnet.co.uk/internal/ipam:latest
imagePullPolicy: Always
volumeMounts:
- mountPath: /app/db
name: ipam
ports:
- containerPort: 5000
name: "ipam"
livenessProbe:
failureThreshold: 3
httpGet:
path: /
port: 5000
scheme: HTTP
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 1
readinessProbe:
failureThreshold: 3
httpGet:
path: /
port: 5000
scheme: HTTP
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 1
startupProbe:
failureThreshold: 3
httpGet:
path: /
port: 5000
scheme: HTTP
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 1
volumes:
- name: ipam
persistentVolumeClaim:
claimName: ipam
---
apiVersion: v1
kind: Service
metadata:
name: ipam-ingress-service
namespace: ipam
spec:
selector:
app: ipam
ports:
- protocol: TCP
port: 80
targetPort: 5000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ipam-ingress
namespace: ipam
spec:
rules:
- host: ipam.jdb143.uk
http:
paths:
- pathType: Prefix
path: "/"
backend:
service:
name: ipam-ingress-service
port:
number: 80
+2
View File
@@ -0,0 +1,2 @@
Flask==2.3.2
Flask-WTF==1.1.1
+442
View File
@@ -0,0 +1,442 @@
from flask import render_template, request, redirect, url_for, send_from_directory, send_file, session
from db import init_db, hash_password, get_db_connection, verify_password
import sqlite3
from ipaddress import ip_network
from functools import wraps
import os
import csv
from io import StringIO, BytesIO
app = None
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'logged_in' not in session:
return redirect(url_for('login'))
return f(*args, **kwargs)
return decorated_function
def add_audit_log(user_id, action, details=None, subnet_id=None, conn=None):
close_conn = False
if conn is None:
conn = get_db_connection()
close_conn = True
cursor = conn.cursor()
cursor.execute('''INSERT INTO AuditLog (user_id, action, details, subnet_id) VALUES (?, ?, ?, ?)''',
(user_id, action, details, subnet_id))
if close_conn:
conn.commit()
conn.close()
def register_routes(app):
@app.route('/login', methods=['GET', 'POST'])
def login():
error = None
if request.method == 'POST':
email = request.form['email']
password = request.form['password']
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('SELECT id, password FROM User WHERE email = ?', (email,))
user = cursor.fetchone()
if user and verify_password(password, user[1]):
session['logged_in'] = True
session['user_id'] = user[0]
return redirect(url_for('index'))
else:
error = 'Invalid email or password.'
return render_with_user('login.html', error=error)
@app.route('/logout')
def logout():
session.clear()
return redirect(url_for('login'))
@app.route('/')
@login_required
def index():
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('SELECT id, name, cidr, site FROM Subnet')
subnets = cursor.fetchall()
sites_subnets = {}
for subnet in subnets:
site = subnet[3] or 'Unassigned'
if site not in sites_subnets:
sites_subnets[site] = []
sites_subnets[site].append({'id': subnet[0], 'name': subnet[1], 'cidr': subnet[2]})
return render_with_user('index.html', sites_subnets=sites_subnets)
@app.route('/devices')
@login_required
def devices():
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('SELECT id, name FROM Device')
devices = cursor.fetchall()
cursor.execute('SELECT id, name, cidr, site FROM Subnet')
subnets = cursor.fetchall()
cursor.execute('SELECT DeviceIPAddress.device_id, IPAddress.id, IPAddress.ip FROM DeviceIPAddress JOIN IPAddress ON DeviceIPAddress.ip_id = IPAddress.id')
device_ips = {}
for row in cursor.fetchall():
device_ips.setdefault(row[0], []).append((row[1], row[2]))
sites_devices = {}
for device in devices:
cursor.execute('''SELECT Subnet.site FROM DeviceIPAddress JOIN IPAddress ON DeviceIPAddress.ip_id = IPAddress.id JOIN Subnet ON IPAddress.subnet_id = Subnet.id WHERE DeviceIPAddress.device_id = ? LIMIT 1''', (device[0],))
site = cursor.fetchone()
site = site[0] if site else 'Unassigned'
if site not in sites_devices:
sites_devices[site] = []
sites_devices[site].append({'id': device[0], 'name': device[1]})
return render_with_user('devices.html', sites_devices=sites_devices, device_ips=device_ips)
@app.route('/add_device', methods=['GET', 'POST'])
@login_required
def add_device():
if request.method == 'POST':
name = request.form['device_name']
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('INSERT INTO Device (name) VALUES (?)', (name,))
conn.commit()
return redirect(url_for('devices'))
return render_with_user('add_device.html')
@app.route('/device/<int:device_id>')
@login_required
def device(device_id):
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('SELECT id, name, description FROM Device WHERE id = ?', (device_id,))
device = cursor.fetchone()
cursor.execute('SELECT id, name, cidr, site FROM Subnet')
subnets = [dict(id=row[0], name=row[1], cidr=row[2], site=row[3]) for row in cursor.fetchall()]
cursor.execute('''SELECT DeviceIPAddress.id as device_ip_id, IPAddress.ip FROM DeviceIPAddress JOIN IPAddress ON DeviceIPAddress.ip_id = IPAddress.id WHERE DeviceIPAddress.device_id = ?''', (device_id,))
device_ips = [{'device_ip_id': row[0], 'ip': row[1]} for row in cursor.fetchall()]
return render_with_user('device.html', device={'id': device[0], 'name': device[1], 'description': device[2]}, subnets=subnets, device_ips=device_ips)
@app.route('/device/<int:device_id>/add_ip', methods=['POST'])
@login_required
def device_add_ip(device_id):
subnet_id = request.form['subnet_id']
ip_id = request.form['ip_id']
user_id = session.get('user_id')
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('INSERT INTO DeviceIPAddress (device_id, ip_id) VALUES (?, ?)', (device_id, ip_id))
cursor.execute('SELECT name FROM Device WHERE id = ?', (device_id,))
device_name = cursor.fetchone()[0]
cursor.execute('UPDATE IPAddress SET hostname = ? WHERE id = ?', (device_name, ip_id))
cursor.execute('SELECT ip, subnet_id FROM IPAddress WHERE id = ?', (ip_id,))
ip, subnet_id_val = cursor.fetchone()
cursor.execute('SELECT name, cidr FROM Subnet WHERE id = ?', (subnet_id_val,))
subnet_name, subnet_cidr = cursor.fetchone()
details = f"Assigned IP {ip} ({subnet_name} {subnet_cidr}) to device {device_name}"
add_audit_log(user_id, 'device_add_ip', details, subnet_id_val, conn=conn)
conn.commit()
return redirect(url_for('device', device_id=device_id))
@app.route('/device/<int:device_id>/delete_ip', methods=['POST'])
@login_required
def device_delete_ip(device_id):
device_ip_id = request.form['device_ip_id']
user_id = session.get('user_id')
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('SELECT ip_id FROM DeviceIPAddress WHERE id = ?', (device_ip_id,))
ip_id = cursor.fetchone()[0]
cursor.execute('SELECT ip, subnet_id FROM IPAddress WHERE id = ?', (ip_id,))
ip, subnet_id_val = cursor.fetchone()
cursor.execute('SELECT name, cidr FROM Subnet WHERE id = ?', (subnet_id_val,))
subnet_name, subnet_cidr = cursor.fetchone()
cursor.execute('SELECT device_id FROM DeviceIPAddress WHERE id = ?', (device_ip_id,))
device_id_val = cursor.fetchone()[0]
cursor.execute('SELECT name FROM Device WHERE id = ?', (device_id_val,))
device_name = cursor.fetchone()[0]
details = f"Removed IP {ip} ({subnet_name} {subnet_cidr}) from device {device_name}"
add_audit_log(user_id, 'device_delete_ip', details, subnet_id_val, conn=conn)
cursor.execute('DELETE FROM DeviceIPAddress WHERE id = ?', (device_ip_id,))
cursor.execute('UPDATE IPAddress SET hostname = NULL WHERE id = ?', (ip_id,))
conn.commit()
return redirect(url_for('device', device_id=device_id))
@app.route('/delete_device', methods=['POST'])
@login_required
def delete_device():
device_id = request.form['device_id']
user_id = session.get('user_id')
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('SELECT name FROM Device WHERE id = ?', (device_id,))
device_name = cursor.fetchone()[0]
add_audit_log(user_id, 'delete_device', f"Deleted device {device_name}", conn=conn)
cursor.execute('DELETE FROM DeviceIPAddress WHERE device_id = ?', (device_id,))
cursor.execute('DELETE FROM Device WHERE id = ?', (device_id,))
conn.commit()
return redirect(url_for('devices'))
@app.route('/subnet/<int:subnet_id>')
@login_required
def subnet(subnet_id):
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('SELECT id, name, cidr FROM Subnet WHERE id = ?', (subnet_id,))
subnet = cursor.fetchone()
cursor.execute('SELECT * FROM IPAddress WHERE subnet_id = ?', (subnet_id,))
ip_addresses = cursor.fetchall()
cursor.execute('SELECT id, name, description FROM Device')
devices = cursor.fetchall()
device_name_map = {name.lower(): (id, description) for id, name, description in devices}
ip_addresses_with_device = []
for ip in ip_addresses:
hostname = ip[2]
device_id = None
device_description = None
if hostname:
match = device_name_map.get(hostname.lower())
if match:
device_id, device_description = match
ip_addresses_with_device.append((ip[0], ip[1], hostname, device_id, device_description))
return render_with_user('subnet.html', subnet={'id': subnet[0], 'name': subnet[1], 'cidr': subnet[2]}, ip_addresses=ip_addresses_with_device)
@app.route('/add_subnet', methods=['POST'])
@login_required
def add_subnet():
name = request.form['name']
cidr = request.form['cidr']
site = request.form['site']
user_id = session.get('user_id')
try:
network = ip_network(cidr, strict=False)
if network.prefixlen < 24:
return render_with_user('admin.html', subnets=[], error='Subnet must be /24 or smaller (e.g., /24, /25, ... /32)')
except Exception as e:
return render_with_user('admin.html', subnets=[], error='Invalid CIDR format.')
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('INSERT INTO Subnet (name, cidr, site) VALUES (?, ?, ?)', (name, cidr, site))
subnet_id = cursor.lastrowid
ip_rows = [(str(ip), subnet_id) for ip in network.hosts()]
cursor.executemany('INSERT INTO IPAddress (ip, subnet_id) VALUES (?, ?)', ip_rows)
add_audit_log(user_id, 'add_subnet', f"Added subnet {name} ({cidr})", subnet_id, conn=conn)
conn.commit()
return redirect(url_for('admin'))
@app.route('/delete_subnet', methods=['POST'])
@login_required
def delete_subnet():
subnet_id = request.form['subnet_id']
user_id = session.get('user_id')
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('SELECT name, cidr FROM Subnet WHERE id = ?', (subnet_id,))
subnet = cursor.fetchone()
add_audit_log(user_id, 'delete_subnet', f"Deleted subnet {subnet[0]} ({subnet[1]})", subnet_id, conn=conn)
cursor.execute('SELECT id FROM IPAddress WHERE subnet_id = ?', (subnet_id,))
ip_ids = [row[0] for row in cursor.fetchall()]
if ip_ids:
cursor.executemany('DELETE FROM DeviceIPAddress WHERE ip_id = ?', [(ip_id,) for ip_id in ip_ids])
cursor.executemany('UPDATE AuditLog SET subnet_id=NULL WHERE subnet_id = ?', [(subnet_id,) for _ in ip_ids])
cursor.execute('DELETE FROM IPAddress WHERE subnet_id = ?', (subnet_id,))
cursor.execute('DELETE FROM Subnet WHERE id = ?', (subnet_id,))
conn.commit()
return redirect(url_for('admin'))
@app.route('/admin', methods=['GET', 'POST'])
@login_required
def admin():
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('SELECT id, name, cidr FROM Subnet')
subnets = [dict(id=row[0], name=row[1], cidr=row[2]) for row in cursor.fetchall()]
return render_with_user('admin.html', subnets=subnets)
@app.route('/download_db')
@login_required
def download_db():
return send_file('db/subnets.db', as_attachment=True)
@app.route('/users', methods=['GET', 'POST'])
@login_required
def users():
with get_db_connection() as conn:
cursor = conn.cursor()
if request.method == 'POST':
action = request.form['action']
if action == 'add':
name = request.form['name']
email = request.form['email']
password = hash_password(request.form['password'])
cursor.execute('INSERT INTO User (name, email, password) VALUES (?, ?, ?)', (name, email, password))
elif action == 'edit':
user_id = request.form['user_id']
name = request.form['name']
email = request.form['email']
password = request.form['password']
if password:
password = hash_password(password)
cursor.execute('UPDATE User SET name=?, email=?, password=? WHERE id=?', (name, email, password, user_id))
else:
cursor.execute('UPDATE User SET name=?, email=? WHERE id=?', (name, email, user_id))
elif action == 'delete':
user_id = request.form['user_id']
cursor.execute('UPDATE User SET name=? WHERE id=?', ('Deleted User', user_id))
cursor.execute('UPDATE AuditLog SET user_id=NULL WHERE user_id=?', (user_id,))
cursor.execute('DELETE FROM User WHERE id=?', (user_id,))
conn.commit()
cursor.execute('SELECT id, name, email FROM User')
users = cursor.fetchall()
return render_with_user('users.html', users=users)
@app.route('/audit')
@login_required
def audit():
with get_db_connection() as conn:
cursor = conn.cursor()
user_id = request.args.get('user_id')
subnet_id = request.args.get('subnet_id')
action = request.args.get('action')
device_name = request.args.get('device_name')
query = '''SELECT AuditLog.id, COALESCE(User.name, 'Deleted User'), AuditLog.action, AuditLog.details, Subnet.name, AuditLog.timestamp FROM AuditLog LEFT JOIN User ON AuditLog.user_id = User.id LEFT JOIN Subnet ON AuditLog.subnet_id = Subnet.id WHERE 1=1'''
params = []
if user_id:
query += ' AND AuditLog.user_id = ?'
params.append(user_id)
if subnet_id:
query += ' AND AuditLog.subnet_id = ?'
params.append(subnet_id)
if action:
query += ' AND AuditLog.action = ?'
params.append(action)
if device_name:
query += ' AND AuditLog.details LIKE ?'
params.append(f'%{device_name}%')
query += ' ORDER BY AuditLog.timestamp DESC'
cursor.execute(query, params)
logs = cursor.fetchall()
cursor.execute('SELECT id, name FROM User')
users = cursor.fetchall()
cursor.execute('SELECT id, name FROM Subnet')
subnets = cursor.fetchall()
cursor.execute('SELECT DISTINCT action FROM AuditLog')
actions = [row[0] for row in cursor.fetchall()]
cursor.execute('SELECT name FROM Device ORDER BY name')
devices = cursor.fetchall()
return render_with_user('audit.html', logs=logs, users=users, subnets=subnets, actions=actions, devices=devices)
@app.route('/get_available_ips')
@login_required
def get_available_ips():
subnet_id = request.args.get('subnet_id')
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('''SELECT id, ip FROM IPAddress WHERE subnet_id = ? AND id NOT IN (SELECT ip_id FROM DeviceIPAddress)''', (subnet_id,))
available_ips = [{'id': row[0], 'ip': row[1]} for row in cursor.fetchall()]
return {'available_ips': available_ips}
@app.route('/rename_device', methods=['POST'])
@login_required
def rename_device():
device_id = request.form['device_id']
new_name = request.form['new_name']
user_id = session.get('user_id')
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('SELECT name FROM Device WHERE id = ?', (device_id,))
old_name = cursor.fetchone()[0]
cursor.execute('UPDATE Device SET name = ? WHERE id = ?', (new_name, device_id))
cursor.execute('UPDATE IPAddress SET hostname = ? WHERE hostname = ?', (new_name, old_name))
conn.commit()
add_audit_log(user_id, 'rename_device', f"Renamed device '{old_name}' to '{new_name}'", conn=conn)
return redirect(url_for('device', device_id=device_id))
@app.route('/update_device_description', methods=['POST'])
@login_required
def update_device_description():
device_id = request.form['device_id']
description = request.form['description']
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('UPDATE Device SET description = ? WHERE id = ?', (description, device_id))
conn.commit()
return redirect(url_for('device', device_id=device_id))
@app.route('/subnet/<int:subnet_id>/export_csv')
@login_required
def export_subnet_csv(subnet_id):
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('SELECT id, name, cidr FROM Subnet WHERE id = ?', (subnet_id,))
subnet = cursor.fetchone()
if not subnet:
return 'Subnet not found', 404
cursor.execute('SELECT * FROM IPAddress WHERE subnet_id = ?', (subnet_id,))
ip_addresses = cursor.fetchall()
cursor.execute('SELECT id, name, description FROM Device')
devices = cursor.fetchall()
device_name_map = {name.lower(): (id, description) for id, name, description in devices}
ip_addresses_with_device = []
for ip in ip_addresses:
hostname = ip[2]
device_id = None
device_description = None
if hostname:
match = device_name_map.get(hostname.lower())
if match:
device_id, device_description = match
ip_addresses_with_device.append((ip[0], ip[1], hostname, device_id, device_description))
output = StringIO()
writer = csv.writer(output)
writer.writerow(['IP Address', 'Hostname', 'Description'])
for ip in ip_addresses_with_device:
ip_addr = ip[1] or ''
hostname = ip[2] or ''
description = (ip[4] or '').split('\n')[0] if ip[4] else ''
writer.writerow([ip_addr, hostname, description])
csv_bytes = output.getvalue().encode('utf-8')
output_bytes = BytesIO(csv_bytes)
output_bytes.seek(0)
filename = f"{subnet[1]}_{subnet[2]}_subnet.csv".replace(' ', '_')
return send_file(
output_bytes,
mimetype='text/csv',
as_attachment=True,
download_name=filename
)
def get_current_user_name():
user_id = session.get('user_id')
if not user_id:
return ''
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('SELECT name FROM User WHERE id = ?', (user_id,))
row = cursor.fetchone()
return row[0] if row else ''
def render_with_user(*args, **kwargs):
if 'current_user_name' not in kwargs:
kwargs['current_user_name'] = get_current_user_name()
return render_template(*args, **kwargs)
app.add_url_rule('/login', 'login', login, methods=['GET', 'POST'])
app.add_url_rule('/logout', 'logout', logout)
app.add_url_rule('/', 'index', index)
app.add_url_rule('/devices', 'devices', devices)
app.add_url_rule('/add_device', 'add_device', add_device, methods=['GET', 'POST'])
app.add_url_rule('/device/<int:device_id>', 'device', device)
app.add_url_rule('/device/<int:device_id>/add_ip', 'device_add_ip', device_add_ip, methods=['POST'])
app.add_url_rule('/device/<int:device_id>/delete_ip', 'device_delete_ip', device_delete_ip, methods=['POST'])
app.add_url_rule('/delete_device', 'delete_device', delete_device, methods=['POST'])
app.add_url_rule('/subnet/<int:subnet_id>', 'subnet', subnet)
app.add_url_rule('/add_subnet', 'add_subnet', add_subnet, methods=['POST'])
app.add_url_rule('/delete_subnet', 'delete_subnet', delete_subnet, methods=['POST'])
app.add_url_rule('/admin', 'admin', admin, methods=['GET', 'POST'])
app.add_url_rule('/download_db', 'download_db', download_db)
app.add_url_rule('/users', 'users', users, methods=['GET', 'POST'])
app.add_url_rule('/audit', 'audit', audit)
app.add_url_rule('/get_available_ips', 'get_available_ips', get_available_ips)
app.add_url_rule('/rename_device', 'rename_device', rename_device, methods=['POST'])
app.add_url_rule('/update_device_description', 'update_device_description', update_device_description, methods=['POST'])
app.add_url_rule('/subnet/<int:subnet_id>/export_csv', 'export_subnet_csv', export_subnet_csv)
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
echo "Generating CSS..."
./tailwindcss -i ./static/css/input.css -o ./static/css/output.css --content "./templates/*.html,./static/js/*.js" --minify
echo "Starting app..."
python app.py
+2
View File
@@ -0,0 +1,2 @@
sonar.projectKey=internal_ipam.jdb143.uk_9d4cf316-69a9-4ae2-ac2f-29f2ba041432
sonar.qualitygate.wait=true
+16
View File
@@ -0,0 +1,16 @@
h2 {
cursor: pointer;
}
form:not(.mb-6), .mt-4 {
display: none;
}
.allocated-ips {
display: block;
margin-top: 1rem;
}
.button-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
justify-items: center;
}
+1
View File
@@ -0,0 +1 @@
@import "tailwindcss"
+15
View File
@@ -0,0 +1,15 @@
function validateSubnetForm() {
const cidrInput = document.getElementById('cidr-input');
const errorSpan = document.getElementById('cidr-error');
const cidrPattern = /^(?:\d{1,3}\.){3}\d{1,3}\/([0-9]|[1-2][0-9]|3[0-2])$/;
if (!cidrPattern.test(cidrInput.value.trim())) {
errorSpan.textContent = 'Please enter a valid CIDR (e.g., 192.168.1.0/24)';
errorSpan.classList.remove('hidden');
cidrInput.classList.add('border-red-500');
return false;
}
errorSpan.textContent = '';
errorSpan.classList.add('hidden');
cidrInput.classList.remove('border-red-500');
return true;
}
+61
View File
@@ -0,0 +1,61 @@
document.addEventListener('DOMContentLoaded', function() {
const siteSelect = document.getElementById('site-select');
const subnetSelect = document.getElementById('subnet-select');
const ipSelect = document.getElementById('ip-select');
const renameBtn = document.querySelector('.rename-btn');
const saveBtn = document.querySelector('.save-btn');
const cancelBtn = document.querySelector('.cancel-btn');
const nameInput = document.querySelector('input[name="new_name"]');
const h1 = document.querySelector('h1');
siteSelect.addEventListener('change', function() {
const selectedSite = this.value;
let firstSubnet = null;
Array.from(subnetSelect.options).forEach(option => {
if (!option.value) return;
if (option.getAttribute('data-site') === selectedSite) {
option.style.display = '';
if (!firstSubnet) firstSubnet = option.value;
} else {
option.style.display = 'none';
}
});
subnetSelect.value = firstSubnet || '';
const event = new Event('change', { bubbles: true });
subnetSelect.dispatchEvent(event);
});
subnetSelect.addEventListener('change', function() {
const subnetId = this.value;
if (!subnetId) {
ipSelect.innerHTML = '<option value="" disabled selected>Select IP</option>';
return;
}
fetch(`/get_available_ips?subnet_id=${subnetId}`)
.then(response => response.json())
.then(data => {
ipSelect.innerHTML = '<option value="" disabled selected>Select IP</option>';
data.available_ips.forEach(ip => {
const option = document.createElement('option');
option.value = ip.id;
option.textContent = ip.ip;
ipSelect.appendChild(option);
});
});
});
if (renameBtn && saveBtn && cancelBtn && nameInput && h1) {
renameBtn.addEventListener('click', function(e) {
e.preventDefault();
nameInput.classList.remove('hidden');
saveBtn.classList.remove('hidden');
cancelBtn.classList.remove('hidden');
h1.classList.add('hidden');
nameInput.focus();
});
cancelBtn.addEventListener('click', function(e) {
e.preventDefault();
nameInput.classList.add('hidden');
saveBtn.classList.add('hidden');
cancelBtn.classList.add('hidden');
h1.classList.remove('hidden');
});
}
});
+110
View File
@@ -0,0 +1,110 @@
document.addEventListener('DOMContentLoaded', function() {
// Expand/collapse site groups
document.querySelectorAll('.site-header').forEach(header => {
header.addEventListener('click', function(e) {
const deviceList = this.closest('.site-group').querySelector('.device-list');
const icon = this.querySelector('.expand-btn i');
if (deviceList.classList.contains('hidden')) {
deviceList.classList.remove('hidden');
icon.classList.remove('fa-chevron-down');
icon.classList.add('fa-chevron-up');
} else {
deviceList.classList.add('hidden');
icon.classList.remove('fa-chevron-up');
icon.classList.add('fa-chevron-down');
}
});
});
// Search functionality
const searchInput = document.getElementById('search');
searchInput.addEventListener('keypress', function(event) {
if (event.key === 'Enter') {
event.preventDefault();
const query = this.value.toLowerCase();
document.querySelectorAll('.site-group').forEach(siteGroup => {
let anyVisible = false;
siteGroup.querySelectorAll('.device-list li').forEach(li => {
const deviceName = li.querySelector('span').textContent.toLowerCase();
const ipSpans = li.querySelectorAll('span.inline-block');
let match = deviceName.includes(query);
if (!match) {
ipSpans.forEach(ipSpan => {
if (ipSpan.textContent.toLowerCase().includes(query)) {
match = true;
}
});
}
li.style.display = match ? '' : 'none';
const card = li.querySelector('a');
if (match) {
anyVisible = true;
siteGroup.querySelector('.device-list').classList.remove('hidden');
const icon = siteGroup.querySelector('.expand-btn i');
if (icon && icon.classList.contains('fa-chevron-down')) {
icon.classList.remove('fa-chevron-down');
icon.classList.add('fa-chevron-up');
}
if (card) {
card.style.transition = 'background-color 0.3s';
card.style.backgroundColor = '#2563eb';
card.style.color = '#fff';
setTimeout(() => {
card.style.backgroundColor = '';
card.style.color = '';
}, 2000);
}
} else {
if (card) {
card.style.backgroundColor = '';
card.style.color = '';
}
}
});
siteGroup.style.display = anyVisible ? '' : 'none';
});
}
});
// Scroll to Top Button
const scrollToTopButton = document.createElement('button');
scrollToTopButton.innerHTML = '<i class="fas fa-arrow-up"></i>';
scrollToTopButton.style.fontSize = '26px';
scrollToTopButton.className = 'fixed bottom-5 right-5 bg-gray-800 text-white p-3 rounded-full shadow-lg hidden';
scrollToTopButton.style.width = '60px';
scrollToTopButton.style.height = '60px';
scrollToTopButton.style.borderRadius = '50%';
document.body.appendChild(scrollToTopButton);
const style = document.createElement('style');
style.textContent = `
@keyframes bob {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-5px);
}
}
.bobbing {
animation: bob 1.5s infinite;
}
`;
document.head.appendChild(style);
scrollToTopButton.classList.add('bobbing');
window.addEventListener('scroll', () => {
if (window.scrollY > 200) {
scrollToTopButton.classList.remove('hidden');
} else {
scrollToTopButton.classList.add('hidden');
}
});
scrollToTopButton.addEventListener('click', () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
});
+7
View File
@@ -0,0 +1,7 @@
document.querySelectorAll('.export-csv-btn').forEach(btn => {
btn.addEventListener('click', function(e) {
e.stopPropagation();
const subnetId = this.getAttribute('data-subnet-id');
window.location.href = `/subnet/${subnetId}/export_csv`;
});
});
+12
View File
@@ -0,0 +1,12 @@
document.addEventListener('DOMContentLoaded', function() {
const navToggle = document.getElementById('nav-toggle');
const mobileNav = document.getElementById('mobile-nav');
navToggle.addEventListener('click', function() {
mobileNav.classList.toggle('hidden');
});
document.addEventListener('click', function(e) {
if (!mobileNav.contains(e.target) && !navToggle.contains(e.target)) {
mobileNav.classList.add('hidden');
}
});
});
+34
View File
@@ -0,0 +1,34 @@
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.site-header').forEach(header => {
header.addEventListener('click', function(e) {
if (e.target.closest('button')) return;
const subnetList = this.closest('.site-group').querySelector('.subnet-list');
const icon = this.querySelector('.expand-btn i');
if (subnetList.classList.contains('hidden')) {
subnetList.classList.remove('hidden');
icon.classList.remove('fa-chevron-down');
icon.classList.add('fa-chevron-up');
} else {
subnetList.classList.add('hidden');
icon.classList.remove('fa-chevron-up');
icon.classList.add('fa-chevron-down');
}
});
});
document.querySelectorAll('.expand-btn').forEach(btn => {
btn.addEventListener('click', function(e) {
e.stopPropagation();
const subnetList = this.closest('.site-group').querySelector('.subnet-list');
const icon = this.querySelector('i');
if (subnetList.classList.contains('hidden')) {
subnetList.classList.remove('hidden');
icon.classList.remove('fa-chevron-down');
icon.classList.add('fa-chevron-up');
} else {
subnetList.classList.add('hidden');
icon.classList.remove('fa-chevron-up');
icon.classList.add('fa-chevron-down');
}
});
});
});
+79
View File
@@ -0,0 +1,79 @@
document.addEventListener('DOMContentLoaded', () => {
const form = document.querySelector('form');
if (form) {
form.addEventListener('submit', (event) => {
event.preventDefault();
});
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.placeholder = 'Search by IP or Hostname';
searchInput.className = 'p-2 w-full rounded-lg bg-gray-800 text-gray-100 border border-gray-600 focus:outline-none focus:border-blue-400 mb-4 text-center';
form.insertAdjacentElement('beforebegin', searchInput);
searchInput.addEventListener('keypress', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
const searchTerm = searchInput.value.toLowerCase();
const rows = document.querySelectorAll('tbody tr');
rows.forEach(row => {
const ipCell = row.querySelector('td:nth-child(1)').textContent.toLowerCase();
const hostnameCell = row.querySelector('td:nth-child(2)').textContent.toLowerCase();
if (ipCell.includes(searchTerm) || hostnameCell.includes(searchTerm)) {
row.style.backgroundColor = 'rgba(59, 130, 246, 0.5)';
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
setTimeout(() => {
row.style.backgroundColor = '';
}, 3000);
} else {
row.style.backgroundColor = '';
}
});
}
});
}
// Scroll to Top Button
const scrollToTopButton = document.createElement('button');
scrollToTopButton.innerHTML = '<i class="fas fa-arrow-up"></i>';
scrollToTopButton.style.fontSize = '26px';
scrollToTopButton.className = 'fixed bottom-5 right-5 bg-gray-800 text-white p-3 rounded-full shadow-lg hidden';
scrollToTopButton.style.width = '60px';
scrollToTopButton.style.height = '60px';
scrollToTopButton.style.borderRadius = '50%';
document.body.appendChild(scrollToTopButton);
const style = document.createElement('style');
style.textContent = `
@keyframes bob {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-5px);
}
}
.bobbing {
animation: bob 1.5s infinite;
}
`;
document.head.appendChild(style);
scrollToTopButton.classList.add('bobbing');
window.addEventListener('scroll', () => {
if (window.scrollY > 200) {
scrollToTopButton.classList.remove('hidden');
} else {
scrollToTopButton.classList.add('hidden');
}
});
scrollToTopButton.addEventListener('click', () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
});
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+26
View File
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Add Device</title>
<link rel="icon" type="image/png" href="/static/logo.png">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-900 text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-md pt-20">
<div class="flex items-center mb-6 relative">
<a href="javascript:window.history.back()" class="absolute left-0 bg-gradient-to-r from-gray-500 to-gray-600 hover:from-gray-600 hover:to-gray-500 text-white flex items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
<h1 class="text-3xl font-bold text-center w-full">Add Device</h1>
</div>
<form action="/add_device" method="POST" class="flex flex-col space-y-4">
<input type="text" name="device_name" placeholder="Device Name" class="border p-3 rounded-lg bg-gray-800 text-gray-100 border-gray-600" required>
<button type="submit" class="bg-gradient-to-r from-gray-500 to-gray-600 hover:from-gray-600 hover:to-gray-500 text-white px-4 py-2 rounded-lg">Add Device</button>
</form>
</div>
</div>
</body>
</html>
+56
View File
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Panel</title>
<link rel="icon" type="image/png" href="/static/logo.png">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-900 text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-md pt-20">
<h1 class="text-3xl font-bold mb-6 text-center">Admin Panel</h1>
<hr class="border-t-2 border-gray-600 rounded-lg mb-4">
<h1 class="text-2xl font-bold mb-6 text-center">Add Subnet</h1>
{% if error %}
<div class="text-red-500 text-center mb-4">{{ error }}</div>
{% endif %}
<form action="/add_subnet" method="POST" class="mb-6" onsubmit="return validateSubnetForm();">
<div class="flex flex-col space-y-4">
<input type="text" name="name" placeholder="Subnet Name" class="border p-3 rounded-lg bg-gray-800 text-gray-100 border-gray-600" required>
<input type="text" name="cidr" id="cidr-input" placeholder="CIDR (e.g., 192.168.1.0/24)" class="border p-3 rounded-lg bg-gray-800 text-gray-100 border-gray-600" required>
<input type="text" name="site" placeholder="Site/Location" class="border p-3 rounded-lg bg-gray-800 text-gray-100 border-gray-600" required>
<button type="submit" class="bg-gradient-to-r from-gray-500 to-gray-700 hover:from-gray-600 hover:to-gray-800 text-white px-4 py-2 rounded-lg">Add Subnet</button>
<span id="cidr-error" class="text-red-500 text-sm hidden"></span>
</div>
</form>
<hr class="border-t-2 border-gray-600 rounded-lg mb-4">
<h1 class="text-2xl font-bold mb-6 text-center">Delete Subnet</h1>
<form action="/delete_subnet" method="POST" class="mb-6 flex items-center space-x-4 justify-center" onsubmit="return confirm('Are you sure you want to delete this subnet and all its IPs? This action is irreversible.');">
<select name="subnet_id" class="border p-3 rounded-lg bg-gray-800 text-gray-100 border-gray-600" required>
<option value="" disabled selected>Select Subnet</option>
{% for subnet in subnets %}
<option value="{{ subnet.id }}">{{ subnet.name }} ({{ subnet.cidr }})</option>
{% endfor %}
</select>
<button type="submit" class="text-red-500 hover:text-red-700 bg-gray-900 rounded-full p-3" title="Delete Subnet">
<i class="fas fa-trash fa-lg"></i>
</button>
</form>
<hr class="border-t-2 border-gray-600 rounded-lg mb-4">
<h1 class="text-2xl font-bold mb-6 text-center">Database Management</h1>
<div class="mb-6 flex justify-center">
<a href="/download_db" class="bg-gradient-to-r from-gray-500 to-gray-700 hover:from-gray-600 hover:to-gray-800 text-white px-4 py-2 rounded-lg">Download Database</a>
</div>
<form action="/admin" method="POST" enctype="multipart/form-data" class="flex flex-col space-y-4">
<input type="file" name="file" class="border p-3 rounded-lg bg-gray-800 text-gray-100 border-gray-600" required>
<button type="submit" class="bg-gradient-to-r from-gray-500 to-gray-700 hover:from-gray-600 hover:to-gray-800 text-white px-4 py-2 rounded-lg">Upload Database</button>
</form>
</div>
</div>
<script src="/static/js/add_subnet.js"></script>
</body>
</html>
+79
View File
@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Audit Log</title>
<link rel="icon" type="image/png" href="/static/logo.png">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-900 text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-6xl pt-20">
<h1 class="text-3xl font-bold mb-6 text-center">Audit Log</h1>
<form method="GET" class="flex flex-wrap gap-4 mb-6 justify-center">
<select name="user_id" class="border p-2 rounded-lg bg-gray-800 text-gray-100 border-gray-600">
<option value="">All Users</option>
{% for user in users %}
<option value="{{ user[0] }}" {% if request.args.get('user_id') == user[0]|string %}selected{% endif %}>{{ user[1] }}</option>
{% endfor %}
</select>
<select name="subnet_id" class="border p-2 rounded-lg bg-gray-800 text-gray-100 border-gray-600">
<option value="">All Subnets</option>
{% for subnet in subnets %}
<option value="{{ subnet[0] }}" {% if request.args.get('subnet_id') == subnet[0]|string %}selected{% endif %}>{{ subnet[1] }}</option>
{% endfor %}
</select>
<select name="action" class="border p-2 rounded-lg bg-gray-800 text-gray-100 border-gray-600">
<option value="">All Actions</option>
{% for a in actions %}
<option value="{{ a }}" {% if request.args.get('action') == a %}selected{% endif %}>{{ a }}</option>
{% endfor %}
</select>
<select name="device_name" class="border p-2 rounded-lg bg-gray-800 text-gray-100 border-gray-600">
<option value="">All Devices</option>
{% for device in devices %}
<option value="{{ device[0] }}" {% if request.args.get('device_name') == device[0] %}selected{% endif %}>{{ device[0] }}</option>
{% endfor %}
</select>
<button type="submit" class="bg-gradient-to-r from-gray-500 to-gray-600 hover:from-gray-600 hover:to-gray-500 text-white px-4 py-2 rounded-lg">Filter</button>
</form>
<table class="w-full table-auto bg-gray-800 rounded-lg overflow-hidden">
<thead>
<tr class="bg-gray-700">
<th class="px-4 py-2 text-center">User</th>
<th class="px-4 py-2 text-center">Action</th>
<th class="px-4 py-2 text-center">Details</th>
<th class="px-4 py-2 text-center">Subnet</th>
<th class="px-4 py-2 text-center">Timestamp</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr class="border-b border-gray-700">
<td class="px-4 py-2 text-center">{{ log[1] or 'Unknown' }}</td>
<td class="px-4 py-2 text-center">{{ log[2] }}</td>
<td class="px-4 py-2 text-center">{{ log[3] }}</td>
<td class="px-4 py-2 text-center">{{ log[4] or 'N/A' }}</td>
<td class="px-4 py-2 text-center" data-utc="{{ log[5] }}">{{ log[5] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('td[data-utc]').forEach(function(td) {
const utc = td.getAttribute('data-utc');
if (utc) {
const date = new Date(utc + 'Z');
td.textContent = date.toLocaleString();
}
});
});
</script>
</body>
</html>
+79
View File
@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ device.name }} - Device Details</title>
<link rel="icon" type="image/png" href="/static/logo.png">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-900 text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 w-auto min-w-[20rem] max-w-2xl pt-20">
<div class="flex items-center mb-8 relative justify-between gap-4">
<a href="javascript:window.history.back()" class="bg-gradient-to-r from-gray-500 to-gray-600 hover:from-gray-600 hover:to-gray-500 text-white flex items-center justify-center rounded-full w-11 h-11 shrink-0"><i class="fas fa-arrow-left"></i></a>
<h1 class="text-3xl font-bold text-center flex-1 min-w-0 truncate">{{ device.name }}</h1>
<div class="flex items-center shrink-0">
<form action="/rename_device" method="POST" class="inline">
<input type="hidden" name="device_id" value="{{ device.id }}">
<input type="text" name="new_name" value="{{ device.name }}" class="hidden border p-1 rounded bg-gray-800 text-gray-100 border-gray-600 w-32 mr-2" style="vertical-align: middle;" required>
<button type="button" class="text-blue-400 hover:text-blue-600 ml-2 rename-btn" title="Rename Device"><i class="fas fa-pencil-alt"></i></button>
<button type="submit" class="text-green-400 hover:text-green-600 ml-2 save-btn hidden" title="Save Name"><i class="fas fa-check"></i></button>
<button type="button" class="text-gray-400 hover:text-gray-600 ml-2 cancel-btn hidden" title="Cancel"><i class="fas fa-times"></i></button>
</form>
<form action="/delete_device" method="POST" onsubmit="return confirm('Are you sure you want to delete this device?');">
<input type="hidden" name="device_id" value="{{ device.id }}">
<button type="submit" class="ml-4 text-red-500 hover:text-red-700" title="Delete Device">
<i class="fas fa-trash fa-lg"></i>
</button>
</form>
</div>
</div>
<form action="/device/{{ device.id }}/add_ip" method="POST" class="mb-6">
<div class="flex flex-col space-y-4">
<select name="site" id="site-select" class="border p-3 rounded-lg bg-gray-800 text-gray-100 border-gray-600 w-full" required>
<option value="" disabled selected>Select Site...</option>
{% set sites = subnets | map(attribute='site') | unique | list %}
{% for site in sites %}
<option value="{{ site }}">{{ site }}</option>
{% endfor %}
</select>
<select name="subnet_id" id="subnet-select" class="border p-3 rounded-lg bg-gray-800 text-gray-100 border-gray-600 w-full" required>
<option value="" disabled selected>Select Subnet...</option>
{% for subnet in subnets %}
<option value="{{ subnet.id }}" data-site="{{ subnet.site }}">{{ subnet.name }} ({{ subnet.cidr }})</option>
{% endfor %}
</select>
<select name="ip_id" id="ip-select" class="border p-3 rounded-lg bg-gray-800 text-gray-100 border-gray-600 w-full" required>
<option value="" disabled selected>Select IP...</option>
</select>
<button type="submit" class="bg-gradient-to-r from-gray-500 to-gray-600 hover:from-gray-600 hover:to-gray-500 text-white px-4 py-2 rounded-lg w-full">Add IP</button>
</div>
</form>
<div class="allocated-ips">
<h3 class="text-lg font-bold mb-2">Allocated IPs:</h3>
<ul class="space-y-2">
{% for ip in device_ips %}
<li class="flex justify-between items-center bg-gray-700 p-2 rounded-lg">
<span class="allocated-ip">{{ ip.ip }}</span>
<form action="/device/{{ device.id }}/delete_ip" method="POST" class="inline">
<input type="hidden" name="device_ip_id" value="{{ ip.device_ip_id }}">
<button type="submit" class="bg-gradient-to-r from-red-500 to-red-700 hover:from-red-600 hover:to-red-800 text-white px-2 py-1 rounded-lg">Delete</button>
</form>
</li>
{% endfor %}
</ul>
</div>
<form action="/update_device_description" method="POST" class="mb-6 mt-4">
<input type="hidden" name="device_id" value="{{ device.id }}">
<label for="description" class="block mb-2 text-lg font-bold">Description</label>
<textarea id="description" name="description" rows="3" class="border p-2 rounded-lg bg-gray-800 text-gray-100 border-gray-600 w-full resize-y" placeholder="Enter device description...">{{ device.description or '' }}</textarea>
<button type="submit" class="bg-gradient-to-r from-gray-500 to-gray-600 hover:from-gray-600 hover:to-gray-500 text-white px-4 py-2 rounded-lg w-full mt-2">Save Description</button>
</form>
</div>
</div>
<script src="/static/js/device.js"></script>
</body>
</html>
+56
View File
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Device Manager</title>
<link rel="icon" type="image/png" href="/static/logo.png">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
<link href="/static/css/devices.css" rel="stylesheet">
</head>
<body class="bg-gray-900 text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-4xl pt-20">
<h1 class="text-3xl font-bold mb-6 text-center">Device Manager</h1>
<a href="/add_device" class="block mb-6 text-center bg-gradient-to-r from-gray-500 to-gray-600 hover:from-gray-600 hover:to-gray-500 text-white px-4 py-2 rounded-lg">Add New Device</a>
<div class="mb-6">
<input type="text" id="search" placeholder="Search devices or IPs..." class="border p-3 rounded-lg bg-gray-800 text-gray-100 border-gray-600 w-full">
</div>
<div id="site-list" class="space-y-6">
{% for site, devices in sites_devices.items() %}
<div class="site-group bg-gray-800 rounded-lg shadow-md">
<div class="flex flex-row items-center justify-between p-4 cursor-pointer site-header">
<h2 class="text-xl font-bold mb-0 text-blue-300">{{ site }}</h2>
<button type="button" class="expand-btn text-gray-400 hover:text-gray-200 ml-2 flex items-center" aria-label="Expand site">
<i class="fas fa-chevron-down"></i>
</button>
</div>
<ul class="device-list hidden px-6 pb-4">
{% for device in devices %}
<li class="my-2">
<a href="/device/{{ device.id }}" class="flex items-center justify-between bg-gray-900 hover:bg-gray-700 text-gray-200 hover:text-white font-semibold rounded-lg px-4 py-2 shadow transition-colors duration-150">
<span><i class="fas fa-server mr-2"></i>{{ device.name }}</span>
{% set ips = device_ips.get(device.id, []) %}
<span class="flex flex-row flex-wrap justify-end items-center ml-4 text-xs text-blue-300 font-normal align-middle">
{% if ips|length > 0 %}
{% for ip in ips %}
<span class="inline-block bg-gray-800 text-blue-200 rounded px-2 py-1 ml-1 font-mono">{{ ip[1] }}</span>
{% endfor %}
{% else %}
<span class="text-gray-400">No IPs</span>
{% endif %}
</span>
</a>
</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</div>
</div>
</div>
<script src="/static/js/devices.js"></script>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
<header class="bg-gray-800 shadow-md py-3 px-6 flex items-center justify-between relative">
<a href="/" class="flex items-center space-x-3">
<img src="/static/logo.png" alt="Logo" class="h-8 rounded">
<span class="text-2xl font-bold text-white">JDB-NET IPAM</span>
</a>
<div class="hidden lg:block absolute left-1/2 transform -translate-x-1/2 text-white text-lg font-medium whitespace-nowrap">
{% if current_user_name %}Hello, {{ current_user_name }}{% endif %}
</div>
<nav class="hidden md:flex items-center space-x-6" id="main-nav">
<a href="/" class="text-gray-200 hover:text-blue-400 font-medium">Home</a>
<a href="/devices" class="text-gray-200 hover:text-blue-400 font-medium">Devices</a>
<a href="/admin" class="text-gray-200 hover:text-blue-400 font-medium">Admin</a>
<a href="/audit" class="text-gray-200 hover:text-blue-400 font-medium">Audit Log</a>
{% if current_user_name %}
<a href="/logout" class="text-gray-200 hover:text-blue-400 font-medium">Logout</a>
{% endif %}
</nav>
<button class="md:hidden flex items-center text-gray-200 focus:outline-none" id="nav-toggle" aria-label="Open navigation menu">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
<div class="md:hidden absolute top-16 right-6 bg-gray-800 rounded-lg shadow-lg z-50 w-48 hidden flex-col py-2" id="mobile-nav">
<a href="/" class="block px-6 py-2 text-gray-200 hover:text-blue-400 font-medium">Home</a>
<a href="/devices" class="block px-6 py-2 text-gray-200 hover:text-blue-400 font-medium">Devices</a>
<a href="/admin" class="block px-6 py-2 text-gray-200 hover:text-blue-400 font-medium">Admin</a>
<a href="/audit" class="block px-6 py-2 text-gray-200 hover:text-blue-400 font-medium">Audit Log</a>
{% if current_user_name %}
<a href="/logout" class="block px-6 py-2 text-gray-200 hover:text-blue-400 font-medium">Logout</a>
{% endif %}
</div>
<script src="/static/js/header.js"></script>
</header>
+46
View File
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JDB-NET IPAM</title>
<link rel="icon" type="image/png" href="/static/logo.png">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-900 text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-md pt-20">
<h1 class="text-3xl font-bold mb-6 text-center">JDB-NET IPAM</h1>
<ul class="space-y-4">
{% for site, subnets in sites_subnets.items() %}
<li class="site-group bg-gray-800 rounded-xl shadow-lg">
<div class="flex flex-row items-center justify-between p-4 cursor-pointer site-header">
<h2 class="text-xl font-bold mb-0 text-blue-300">{{ site }}</h2>
<button type="button" class="expand-btn text-gray-400 hover:text-gray-200 ml-2 flex items-center" aria-label="Expand site">
<i class="fas fa-chevron-down"></i>
</button>
</div>
<ul class="subnet-list hidden space-y-4 px-2 pb-4">
{% for subnet in subnets %}
<li class="p-4 bg-gray-900 rounded-lg shadow-md flex items-center justify-between">
<div>
<a href="/subnet/{{ subnet.id }}" class="text-blue-400 hover:text-blue-300 text-lg font-medium">{{ subnet.name }}</a>
<p class="text-sm text-gray-400">{{ subnet.cidr }}</p>
</div>
<button type="button" class="export-csv-btn ml-2 bg-gradient-to-r from-gray-500 to-gray-600 hover:from-gray-600 hover:to-gray-500 text-white flex items-center justify-center rounded-full w-9 h-9" title="Export as CSV" data-subnet-id="{{ subnet.id }}">
<i class="fas fa-file-csv"></i>
</button>
</li>
{% endfor %}
</ul>
</li>
{% endfor %}
</ul>
<script src="/static/js/sitelist.js"></script>
<script src="/static/js/export_csv.js"></script>
</div>
</div>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JDB-NET IPAM</title>
<link rel="icon" type="image/png" href="/static/logo.png">
<link href="/static/css/output.css" rel="stylesheet">
</head>
<body class="bg-gray-900 text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-md pt-20">
<h1 class="text-3xl font-bold mb-6 text-center">JDB-NET IPAM</h1>
<form action="/login" method="POST" class="flex flex-col space-y-4">
<input type="email" name="email" placeholder="Email Address" class="border p-3 rounded-lg bg-gray-800 text-gray-100 border-gray-600" required>
<input type="password" name="password" placeholder="Password" class="border p-3 rounded-lg bg-gray-800 text-gray-100 border-gray-600" required>
<button type="submit" class="bg-gradient-to-r from-gray-500 to-gray-600 hover:from-gray-600 hover:to-gray-500 text-white px-4 py-2 rounded-lg">Login</button>
</form>
{% if error %}
<p class="text-red-500 text-center mt-4">{{ error }}</p>
{% endif %}
</div>
</div>
</body>
</html>
+57
View File
@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ subnet.name }} - Subnet Details</title>
<link rel="icon" type="image/png" href="/static/logo.png">
<link href="/static/css/output.css" rel="stylesheet">
<script src="/static/js/subnet.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-900 text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-3/4 pt-20">
<div class="flex items-center mb-6 relative">
<a href="javascript:window.history.back()" class="absolute left-0 bg-gradient-to-r from-gray-500 to-gray-600 hover:from-gray-600 hover:to-gray-500 text-white flex items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
<h1 class="text-3xl font-bold text-center w-full">{{ subnet.name }} ({{ subnet.cidr }})</h1>
<button type="button" id="export-csv" class="absolute right-0 bg-gradient-to-r from-gray-500 to-gray-600 hover:from-gray-600 hover:to-gray-500 text-white flex items-center justify-center rounded-full w-11 h-11 export-csv-btn" title="Export as CSV" data-subnet-id="{{ subnet.id }}">
<i class="fas fa-file-csv fa-lg"></i>
</button>
</div>
<form action="" method="POST">
<table class="table-auto w-full mb-6">
<thead>
<tr>
<th class="text-center text-gray-400">IP Address</th>
<th class="text-center text-gray-400">Hostname</th>
<th class="text-center text-gray-400">Description</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-700">
{% for ip in ip_addresses %}
<tr>
<td class="text-gray-300 font-bold text-center">{{ ip[1] }}</td>
<td class="text-white text-center">
{% if ip[2] and ip[3] %}
<a href="/device/{{ ip[3] }}" class="hover:text-blue-300">{{ ip[2] }}</a>
{% elif ip[2] %}
{{ ip[2] }}
{% else %}
{{ '' }}
{% endif %}
</td>
<td class="text-white text-left align-top">
<textarea readonly rows="1" class="bg-gray-800 text-gray-100 border border-gray-600 rounded w-full resize-y cursor-pointer p-2">{{ ip[4].split('\n')[0] if ip[4] else '' }}</textarea>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</form>
</div>
</div>
<script src="/static/js/export_csv.js"></script>
</body>
</html>
+48
View File
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>User Management</title>
<link rel="icon" type="image/png" href="/static/logo.png">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-900 text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-6xl pt-20">
<h1 class="text-3xl font-bold mb-6 text-center">User Management</h1>
<form action="/users" method="POST" class="mb-8 bg-gray-800 p-6 rounded-lg shadow-md">
<input type="hidden" name="action" value="add">
<div class="flex flex-col space-y-4">
<input type="text" name="name" placeholder="Name" class="border p-3 rounded-lg bg-gray-900 text-gray-100 border-gray-600" required>
<input type="email" name="email" placeholder="Email Address" class="border p-3 rounded-lg bg-gray-900 text-gray-100 border-gray-600" required>
<input type="password" name="password" placeholder="Password" class="border p-3 rounded-lg bg-gray-900 text-gray-100 border-gray-600" required>
<button type="submit" class="bg-gradient-to-r from-gray-500 to-gray-600 hover:from-gray-600 hover:to-gray-500 text-white px-4 py-2 rounded-lg">Add User</button>
</div>
</form>
<h2 class="text-xl font-bold mb-4">Existing Users</h2>
<ul class="space-y-4">
{% for user in users %}
<li class="bg-gray-800 p-4 rounded-lg flex justify-between items-center">
<form action="/users" method="POST" class="flex flex-row items-center space-x-2">
<input type="hidden" name="action" value="edit">
<input type="hidden" name="user_id" value="{{ user[0] }}">
<input type="text" name="name" value="{{ user[1] }}" class="border p-2 rounded-lg bg-gray-900 text-gray-100 border-gray-600 w-52">
<input type="email" name="email" value="{{ user[2] }}" class="border p-2 rounded-lg bg-gray-900 text-gray-100 border-gray-600 w-80">
<input type="password" name="password" placeholder="New Password (leave blank to keep)" class="border p-2 rounded-lg bg-gray-900 text-gray-100 border-gray-600 w-80">
<button type="submit" class="bg-gradient-to-r from-gray-500 to-gray-600 hover:from-gray-600 hover:to-gray-500 text-white px-3 py-1 rounded-lg">Save</button>
</form>
<form action="/users" method="POST" onsubmit="return confirm('Are you sure you want to delete this user?');">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="user_id" value="{{ user[0] }}">
<button type="submit" class="text-red-500 hover:text-red-700 ml-2" title="Delete User"><i class="fas fa-trash"></i></button>
</form>
</li>
{% endfor %}
</ul>
</div>
</div>
</body>
</html>