Compare commits
47 Commits
1206c90eeb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 89c2d4fba3 | |||
| d011550f3a | |||
| 7d6dfec4c9 | |||
| e53e5004e1 | |||
| ae4cd12f97 | |||
| 0a254c9d20 | |||
| b25ebfe9bb | |||
| b206033c7d | |||
| 1344970c05 | |||
| de13c8b2f9 | |||
| 01ecccc928 | |||
| 247ef50e49 | |||
| 0b4e9943a2 | |||
| 4f53141602 | |||
| 846f5475db | |||
| 15fad1b10c | |||
| 90773b6198 | |||
| ff5b44676b | |||
| 42a8a4eb00 | |||
| c6b8b547b9 | |||
| 3c11ffdc19 | |||
| f3364c6ef6 | |||
| 196a1f31d3 | |||
| bb121ccbc6 | |||
| 32ad2fd115 | |||
| 00a91eb556 | |||
| 0e1968fd5e | |||
| 70573bc0b4 | |||
| dc782e3a76 | |||
| 2ff020e1a8 | |||
| da17b9cb38 | |||
| 590f4142e6 | |||
| 063057e0eb | |||
| f027d9105d | |||
| 2e511ca428 | |||
| af7e24a948 | |||
| bfd6d8af57 | |||
| 82e534f4d3 | |||
| 1482643261 | |||
| 9d4b21b5ae | |||
| 4327cdd858 | |||
| 0754f332c9 | |||
| eb5d9bc3f9 | |||
| 1a51ded5fc | |||
| 173c8c2c99 | |||
| 519aabc0a6 | |||
| cd08abdc43 |
37
.env.template
Normal file
37
.env.template
Normal file
@@ -0,0 +1,37 @@
|
||||
# Flask
|
||||
FLASK_SECRET_KEY=your-secret-key
|
||||
|
||||
# Database config (shared by all)
|
||||
DB_HOST=db
|
||||
DB_PORT=3306
|
||||
DB_NAME=radius
|
||||
DB_USER=radiususer
|
||||
DB_PASSWORD=radiuspass
|
||||
|
||||
# Only used by the MariaDB container
|
||||
MARIADB_ROOT_PASSWORD=rootpassword
|
||||
|
||||
# MAC Lookup API
|
||||
OUI_API_KEY= # only required if you want to increase the OUI limits
|
||||
OUI_API_URL=https://api.maclookup.app/v2/macs/{}
|
||||
|
||||
# Rate Limits
|
||||
OUI_API_LIMIT_PER_SEC=2
|
||||
OUI_API_DAILY_LIMIT=10000
|
||||
|
||||
# Logging
|
||||
LOG_TO_FILE=true
|
||||
LOG_FILE_PATH=/app/logs/app.log
|
||||
|
||||
# Timezone
|
||||
APP_TIMEZONE=America/Toronto
|
||||
|
||||
# RADIUS config
|
||||
RADIUS_SECRET=changeme
|
||||
RADIUS_PORT=1812
|
||||
|
||||
# Fallback VLAN when MAC not found
|
||||
DEFAULT_VLAN=505
|
||||
|
||||
# Assign MAC to this VLAN to deny them access (prevent fallback)
|
||||
DENIED_VLAN=999
|
||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
.env
|
||||
*.pyc
|
||||
__pycache__/
|
||||
*.log
|
||||
/app/logs/
|
||||
instance/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
docker-compose.yml
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"python.analysis.typeCheckingMode": "basic"
|
||||
}
|
||||
50
README.md
50
README.md
@@ -1 +1,49 @@
|
||||
This is a project that allows me to simplify freeradius user management with mac address authentication as it's primary focus.
|
||||
🛡️ RadMac — Web Manager and radius server for MAC-based authentication / VLAN Assignment
|
||||
RadMac is a lightweight Flask web UI for managing MAC address-based access control and VLAN assignment, backed by a MariaDB/MySQL database. It incorporate a lightweight radius server.
|
||||
|
||||
✨ Some Features
|
||||
|
||||
🔐 MAC-based User Management
|
||||
Add/edit/delete MAC entries with descriptions and VLAN IDs.
|
||||
|
||||
🧠 MAC Vendor Lookup
|
||||
Auto-lookup vendors using maclookup.app with rate-limited API integration and local caching.
|
||||
|
||||
📊 Auth Log Viewer
|
||||
Filter Access-Accept / Reject / Fallback events with timestamps, MAC, vendor, and description.
|
||||
|
||||
🧹 Database Maintenance Tools
|
||||
- View row counts for all tables
|
||||
- Clear auth logs
|
||||
- Backup the full database as a .sql file
|
||||
- Restore from uploaded .sql files
|
||||
|
||||
🌗 Dark & Light Theme
|
||||
Toggle between light and dark modes, with theme persistence.
|
||||
|
||||
🔁 Session-Friendly UX
|
||||
Preserves scroll position, sticky headers, toast notifications.
|
||||
|
||||
📦 Setup (Docker Compose)
|
||||
The project includes a ready-to-use docker-compose.yml.
|
||||
|
||||
1. Clone the repository
|
||||
bash
|
||||
Copy
|
||||
Edit
|
||||
git clone https://github.com/Simon-CR/RadMac.git
|
||||
cd RadMac
|
||||
|
||||
2. Create environment file
|
||||
Copy .env.template to .env and edit:
|
||||
|
||||
- Fill in your MySQL credentials and other optional settings like OUI_API_KEY.
|
||||
|
||||
3. Run the stack
|
||||
|
||||
docker-compose up --build
|
||||
|
||||
The web UI will be available at: http://localhost:8080
|
||||
|
||||
📄 License
|
||||
MIT — do whatever you want, no guarantees.
|
||||
BIN
db-data/mysql/innodb_index_stats.frm → app/.DS_Store
vendored
BIN
db-data/mysql/innodb_index_stats.frm → app/.DS_Store
vendored
Binary file not shown.
@@ -1,13 +1,32 @@
|
||||
FROM python:3.9-slim
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
# Optional: Set timezone via build argument (can override in docker-compose)
|
||||
ARG TIMEZONE=UTC
|
||||
ENV TZ=$TIMEZONE
|
||||
|
||||
# Install tzdata and optional tools
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends tzdata iputils-ping telnet mariadb-client && \
|
||||
ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && \
|
||||
echo $TZ > /etc/timezone && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create logs directory (used if LOG_TO_FILE=true)
|
||||
RUN mkdir -p /app/logs
|
||||
|
||||
# Copy dependencies and install
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
RUN apt-get update && apt-get install -y iputils-ping telnet # Add these lines
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
CMD ["python", "app.py"]
|
||||
# Expose internal port (Gunicorn default)
|
||||
EXPOSE 8080
|
||||
|
||||
# Run the app via Gunicorn
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "wsgi:app", "--timeout", "120", "--workers", "2"]
|
||||
|
||||
671
app/app.py
671
app/app.py
@@ -1,647 +1,46 @@
|
||||
from flask import Flask, render_template, request, redirect, url_for, jsonify
|
||||
import mysql.connector
|
||||
import json
|
||||
from flask import Flask, redirect, url_for, render_template
|
||||
from views.index_views import index
|
||||
from views.user_views import user
|
||||
from views.group_views import group
|
||||
from views.stats_views import stats
|
||||
from views.maintenance_views import maintenance
|
||||
from config import app_config
|
||||
|
||||
|
||||
import logging, os
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(app_config)
|
||||
|
||||
DB_CONFIG = {
|
||||
'host': '192.168.60.150',
|
||||
'user': 'user_92z0Kj',
|
||||
'password': '5B3UXZV8vyrB',
|
||||
'database': 'radius_NIaIuT'
|
||||
}
|
||||
if app.config.get('LOG_TO_FILE'):
|
||||
log_file = app.config.get('LOG_FILE_PATH', '/app/logs/app.log')
|
||||
os.makedirs(os.path.dirname(log_file), exist_ok=True)
|
||||
handler = RotatingFileHandler(log_file, maxBytes=1_000_000, backupCount=3)
|
||||
handler.setLevel(logging.INFO)
|
||||
app.logger.addHandler(handler)
|
||||
|
||||
def get_db():
|
||||
try:
|
||||
db = mysql.connector.connect(**DB_CONFIG)
|
||||
return db
|
||||
except mysql.connector.Error as err:
|
||||
print(f"Database Connection Error: {err}")
|
||||
return None
|
||||
app.logger.setLevel(logging.INFO)
|
||||
|
||||
@app.route('/', methods=['GET', 'POST'])
|
||||
def index():
|
||||
sql_results = None
|
||||
sql_error = None
|
||||
total_users = 0
|
||||
total_groups = 0
|
||||
|
||||
db = get_db()
|
||||
if db:
|
||||
cursor = db.cursor(dictionary=True)
|
||||
try:
|
||||
# Count total users
|
||||
cursor.execute("SELECT COUNT(DISTINCT username) as total FROM radcheck;")
|
||||
total_users = cursor.fetchone()['total']
|
||||
|
||||
# Count total groups
|
||||
cursor.execute("SELECT COUNT(DISTINCT groupname) as total FROM radgroupreply;")
|
||||
total_groups = cursor.fetchone()['total']
|
||||
|
||||
except mysql.connector.Error as err:
|
||||
print(f"Error fetching counts: {err}")
|
||||
|
||||
cursor.close()
|
||||
db.close()
|
||||
|
||||
return render_template('index.html', total_users=total_users, total_groups=total_groups, sql_results=sql_results, sql_error=sql_error)
|
||||
|
||||
@app.route('/sql', methods=['POST'])
|
||||
def sql():
|
||||
sql_results = None
|
||||
sql_error = None
|
||||
sql_query = request.form['query']
|
||||
|
||||
db = get_db()
|
||||
if db:
|
||||
try:
|
||||
cursor = db.cursor(dictionary=True)
|
||||
cursor.execute(sql_query)
|
||||
sql_results = cursor.fetchall()
|
||||
cursor.close()
|
||||
db.close()
|
||||
except mysql.connector.Error as err:
|
||||
sql_error = str(err)
|
||||
except Exception as e:
|
||||
sql_error = str(e)
|
||||
|
||||
total_users = 0
|
||||
total_groups = 0
|
||||
|
||||
db = get_db()
|
||||
if db:
|
||||
cursor = db.cursor(dictionary=True)
|
||||
try:
|
||||
# Count total users
|
||||
cursor.execute("SELECT COUNT(DISTINCT username) as total FROM radcheck;")
|
||||
total_users = cursor.fetchone()['total']
|
||||
|
||||
# Count total groups
|
||||
cursor.execute("SELECT COUNT(DISTINCT groupname) as total FROM radgroupreply;")
|
||||
total_groups = cursor.fetchone()['total']
|
||||
|
||||
except mysql.connector.Error as err:
|
||||
print(f"Error fetching counts: {err}")
|
||||
|
||||
cursor.close()
|
||||
db.close()
|
||||
|
||||
return render_template('index.html', total_users=total_users, total_groups=total_groups, sql_results=sql_results, sql_error=sql_error)
|
||||
# Route setup
|
||||
app.register_blueprint(index)
|
||||
app.register_blueprint(user, url_prefix='/user')
|
||||
app.register_blueprint(group, url_prefix='/group')
|
||||
app.register_blueprint(stats, url_prefix='/stats')
|
||||
app.register_blueprint(maintenance, url_prefix='/maintenance')
|
||||
|
||||
@app.route('/user_list')
|
||||
def user_list():
|
||||
"""Displays the user list with VLAN IDs and descriptions from rad_description."""
|
||||
db = get_db()
|
||||
if db is None:
|
||||
return "Database connection failed", 500
|
||||
|
||||
cursor = db.cursor(dictionary=True)
|
||||
try:
|
||||
# Fetch users, their group assignments, and descriptions from rad_description
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
r.username AS mac_address,
|
||||
rd.description AS description,
|
||||
ug.groupname AS vlan_id
|
||||
FROM radcheck r
|
||||
LEFT JOIN radusergroup ug ON r.username = ug.username
|
||||
LEFT JOIN rad_description rd ON r.username = rd.username
|
||||
""") #removed WHERE clause
|
||||
results = cursor.fetchall()
|
||||
print("Results:", results) #added print statement
|
||||
|
||||
# Fetch all group names for the dropdown
|
||||
cursor.execute("SELECT groupname FROM radgroupcheck")
|
||||
groups = cursor.fetchall()
|
||||
groups = [{'groupname': row['groupname']} for row in groups]
|
||||
print("Groups:", groups) #added print statement
|
||||
|
||||
cursor.close()
|
||||
db.close()
|
||||
return render_template('user_list_inline_edit.html', results=results, groups=groups)
|
||||
except mysql.connector.Error as e:
|
||||
print(f"Database error: {e}")
|
||||
cursor.close()
|
||||
db.close()
|
||||
return "Database error", 500
|
||||
|
||||
@app.route('/update_user', methods=['POST'])
|
||||
def update_user():
|
||||
mac_address = request.form['mac_address']
|
||||
description = request.form['description']
|
||||
vlan_id = request.form['vlan_id']
|
||||
|
||||
db = get_db()
|
||||
if db:
|
||||
cursor = db.cursor()
|
||||
try:
|
||||
db.autocommit = False
|
||||
|
||||
# Update rad_description table
|
||||
cursor.execute("""
|
||||
UPDATE rad_description
|
||||
SET description = %s
|
||||
WHERE username = %s
|
||||
""", (description, mac_address))
|
||||
|
||||
# Update radgroupreply table for VLAN ID
|
||||
cursor.execute("""
|
||||
UPDATE radgroupreply rgr
|
||||
SET value = %s
|
||||
WHERE rgr.groupname = (SELECT groupname FROM radusergroup rug WHERE rug.username = %s LIMIT 1)
|
||||
AND rgr.attribute = 'Tunnel-Private-Group-Id'
|
||||
""", (vlan_id, mac_address))
|
||||
|
||||
db.commit()
|
||||
db.autocommit = True
|
||||
cursor.close()
|
||||
return "success"
|
||||
except mysql.connector.Error as err:
|
||||
db.rollback()
|
||||
db.autocommit = True
|
||||
cursor.close()
|
||||
return str(err)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
db.autocommit = True
|
||||
cursor.close()
|
||||
return str(e)
|
||||
finally:
|
||||
db.close()
|
||||
return "Database Connection Failed"
|
||||
|
||||
@app.route('/delete_user/<mac_address>')
|
||||
def delete_user(mac_address):
|
||||
db = get_db()
|
||||
if db:
|
||||
cursor = db.cursor()
|
||||
try:
|
||||
db.autocommit = False #Start transaction
|
||||
|
||||
# Delete from rad_description
|
||||
cursor.execute("DELETE FROM rad_description WHERE username = %s", (mac_address,))
|
||||
|
||||
# Delete from radcheck
|
||||
cursor.execute("DELETE FROM radcheck WHERE username = %s", (mac_address,))
|
||||
|
||||
#Delete from radusergroup
|
||||
cursor.execute("DELETE FROM radusergroup WHERE username = %s", (mac_address,))
|
||||
|
||||
db.commit() #Commit transaction
|
||||
db.autocommit = True
|
||||
cursor.close()
|
||||
db.close()
|
||||
return redirect(url_for('user_list'))
|
||||
except mysql.connector.Error as err:
|
||||
print(f"Database Error: {err}")
|
||||
db.rollback() #Roll back transaction on error
|
||||
db.autocommit = True
|
||||
cursor.close()
|
||||
db.close()
|
||||
return redirect(url_for('user_list'))
|
||||
return "Database Connection Failed"
|
||||
|
||||
# @app.route('/edit_user/<mac_address>', methods=['GET', 'POST'])
|
||||
# def edit_user(mac_address):
|
||||
# db = get_db()
|
||||
# if db:
|
||||
# cursor = db.cursor(dictionary=True)
|
||||
|
||||
# if request.method == 'POST':
|
||||
# description = request.form['description']
|
||||
# vlan_id = request.form['vlan_id']
|
||||
|
||||
# cursor.execute("""
|
||||
# UPDATE radcheck
|
||||
# SET value = %s
|
||||
# WHERE username = %s AND attribute = 'User-Description'
|
||||
# """, (description, mac_address))
|
||||
|
||||
# cursor.execute("""
|
||||
# UPDATE radgroupreply rgr
|
||||
# SET value = %s
|
||||
# WHERE rgr.groupname = (SELECT groupname FROM radusergroup rug WHERE rug.username = %s LIMIT 1)
|
||||
# AND rgr.attribute = 'Tunnel-Private-Group-Id'
|
||||
# """, (vlan_id, mac_address))
|
||||
|
||||
# db.commit()
|
||||
# cursor.close()
|
||||
# db.close()
|
||||
# return redirect(url_for('user_list'))
|
||||
|
||||
# else:
|
||||
# cursor.execute("""
|
||||
# SELECT
|
||||
# rc.username AS mac_address,
|
||||
# IFNULL((SELECT value FROM radgroupreply rgr
|
||||
# WHERE rgr.groupname = (SELECT groupname FROM radusergroup rug WHERE rug.username = rc.username LIMIT 1)
|
||||
# AND rgr.attribute = 'Tunnel-Private-Group-Id' LIMIT 1), 'N/A') AS vlan_id,
|
||||
# IFNULL((SELECT value FROM radcheck rch
|
||||
# WHERE rch.username = rc.username AND rch.attribute = 'User-Description' LIMIT 1), 'N/A') AS description
|
||||
# FROM radcheck rc
|
||||
# WHERE rc.username = %s
|
||||
# GROUP BY rc.username;
|
||||
# """, (mac_address,))
|
||||
# user = cursor.fetchone()
|
||||
# cursor.close()
|
||||
# db.close()
|
||||
# return render_template('edit_user.html', user=user)
|
||||
# return "Database Connection Failed"
|
||||
def legacy_user_list():
|
||||
return redirect(url_for('user.user_list'))
|
||||
|
||||
@app.route('/groups')
|
||||
def groups():
|
||||
db = get_db()
|
||||
if db:
|
||||
cursor = db.cursor()
|
||||
try:
|
||||
# Fetch group names from radgroupcheck
|
||||
cursor.execute("SELECT DISTINCT groupname FROM radgroupcheck")
|
||||
group_names = [row[0] for row in cursor.fetchall()]
|
||||
def legacy_group_list():
|
||||
return redirect(url_for('group.group_list'))
|
||||
|
||||
grouped_results = {}
|
||||
for groupname in group_names:
|
||||
# Fetch attributes for each group from radgroupreply
|
||||
cursor.execute("SELECT id, attribute, op, value FROM radgroupreply WHERE groupname = %s", (groupname,))
|
||||
attributes = cursor.fetchall()
|
||||
grouped_results[groupname] = [{'id': row[0], 'attribute': row[1], 'op': row[2], 'value': row[3]} for row in attributes]
|
||||
@app.route('/')
|
||||
def index_redirect():
|
||||
return render_template('index.html')
|
||||
|
||||
cursor.close()
|
||||
db.close()
|
||||
return render_template('group_list_nested.html', grouped_results=grouped_results)
|
||||
except mysql.connector.Error as err:
|
||||
print(f"Database Error: {err}")
|
||||
cursor.close()
|
||||
db.close()
|
||||
return render_template('group_list_nested.html', grouped_results={})
|
||||
return "Database Connection Failed"
|
||||
|
||||
@app.route('/edit_groupname/<old_groupname>', methods=['GET', 'POST'])
|
||||
def edit_groupname(old_groupname):
|
||||
db = get_db()
|
||||
if db:
|
||||
cursor = db.cursor(dictionary=True)
|
||||
|
||||
if request.method == 'POST':
|
||||
new_groupname = request.form['groupname']
|
||||
try:
|
||||
db.autocommit = False
|
||||
cursor.execute("""
|
||||
UPDATE radgroupreply
|
||||
SET groupname = %s
|
||||
WHERE groupname = %s
|
||||
""", (new_groupname, old_groupname))
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE radusergroup
|
||||
SET groupname = %s
|
||||
WHERE groupname = %s
|
||||
""", (new_groupname, old_groupname))
|
||||
|
||||
db.commit()
|
||||
db.autocommit = True
|
||||
cursor.close()
|
||||
db.close()
|
||||
return redirect(url_for('groups'))
|
||||
except mysql.connector.Error as err:
|
||||
db.rollback()
|
||||
db.autocommit = True
|
||||
cursor.close()
|
||||
db.close()
|
||||
return f"Database Error: {err}"
|
||||
else:
|
||||
return render_template('edit_groupname.html', old_groupname=old_groupname)
|
||||
return "Database Connection Failed"
|
||||
|
||||
@app.route('/update_attribute', methods=['POST'])
|
||||
def update_attribute():
|
||||
group_id = request.form['group_id']
|
||||
attribute = request.form['attribute']
|
||||
op = request.form['op']
|
||||
value = request.form['value']
|
||||
|
||||
db = get_db()
|
||||
if db:
|
||||
cursor = db.cursor()
|
||||
try:
|
||||
db.autocommit = False
|
||||
cursor.execute("""
|
||||
UPDATE radgroupreply
|
||||
SET attribute = %s, op = %s, value = %s
|
||||
WHERE id = %s
|
||||
""", (attribute, op, value, group_id))
|
||||
db.commit()
|
||||
db.autocommit = True
|
||||
cursor.close()
|
||||
return "success"
|
||||
except mysql.connector.Error as err:
|
||||
db.rollback()
|
||||
db.autocommit = True
|
||||
cursor.close()
|
||||
return str(err)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
db.autocommit = True
|
||||
cursor.close()
|
||||
return str(e)
|
||||
finally:
|
||||
db.close()
|
||||
return "Database Connection Failed"
|
||||
|
||||
@app.route('/add_attribute', methods=['POST'])
|
||||
def add_attribute():
|
||||
groupname = request.form['groupname']
|
||||
attribute = request.form['attribute']
|
||||
op = request.form['op']
|
||||
value = request.form['value']
|
||||
|
||||
db = get_db()
|
||||
if db:
|
||||
cursor = db.cursor()
|
||||
try:
|
||||
cursor.execute("""
|
||||
INSERT INTO radgroupreply (groupname, attribute, op, value)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
""", (groupname, attribute, op, value))
|
||||
db.commit()
|
||||
cursor.close()
|
||||
db.close()
|
||||
return "success"
|
||||
except mysql.connector.Error as err:
|
||||
print(f"Database Error: {err}")
|
||||
db.rollback()
|
||||
cursor.close()
|
||||
db.close()
|
||||
return str(err)
|
||||
return "Database Connection Failed"
|
||||
|
||||
@app.route('/edit_attribute/<group_id>', methods=['GET', 'POST'])
|
||||
def edit_attribute(group_id):
|
||||
db = get_db()
|
||||
if db:
|
||||
cursor = db.cursor(dictionary=True)
|
||||
|
||||
if request.method == 'POST':
|
||||
attribute = request.form['attribute']
|
||||
op = request.form['op']
|
||||
value = request.form['value']
|
||||
|
||||
try:
|
||||
db.autocommit = False
|
||||
cursor.execute("""
|
||||
UPDATE radgroupreply
|
||||
SET attribute = %s, op = %s, value = %s
|
||||
WHERE id = %s
|
||||
""", (attribute, op, value, group_id))
|
||||
db.commit()
|
||||
db.autocommit = True
|
||||
cursor.close()
|
||||
db.close()
|
||||
return redirect(url_for('groups'))
|
||||
except mysql.connector.Error as err:
|
||||
db.rollback()
|
||||
db.autocommit = True
|
||||
cursor.close()
|
||||
db.close()
|
||||
return f"Database Error: {err}"
|
||||
|
||||
else:
|
||||
cursor.execute("SELECT * FROM radgroupreply WHERE id = %s", (group_id,))
|
||||
attribute_data = cursor.fetchone()
|
||||
cursor.close()
|
||||
db.close()
|
||||
return render_template('edit_attribute.html', attribute_data=attribute_data)
|
||||
return "Database Connection Failed"
|
||||
|
||||
@app.route('/add_group', methods=['POST'])
|
||||
def add_group():
|
||||
groupname = request.form['groupname']
|
||||
|
||||
db = get_db()
|
||||
if db:
|
||||
cursor = db.cursor()
|
||||
try:
|
||||
cursor.execute("INSERT INTO radgroupreply (groupname, attribute, op, value) VALUES (%s, '', '', '')", (groupname,))
|
||||
cursor.execute("INSERT INTO radusergroup (groupname, username) VALUES (%s, '')", (groupname,))
|
||||
# Add default values for radgroupcheck
|
||||
cursor.execute("INSERT INTO radgroupcheck (groupname, attribute, op, value) VALUES (%s, 'Auth-Type', ':=', 'Accept')", (groupname,))
|
||||
db.commit()
|
||||
cursor.close()
|
||||
db.close()
|
||||
return "success"
|
||||
except mysql.connector.Error as err:
|
||||
print(f"Database Error: {err}")
|
||||
db.rollback()
|
||||
cursor.close()
|
||||
db.close()
|
||||
return str(err)
|
||||
return "Database Connection Failed"
|
||||
|
||||
@app.route('/delete_group_rows/<groupname>')
|
||||
def delete_group_rows(groupname):
|
||||
db = get_db()
|
||||
if db:
|
||||
cursor = db.cursor()
|
||||
try:
|
||||
cursor.execute("DELETE FROM radgroupreply WHERE groupname = %s", (groupname,))
|
||||
cursor.execute("DELETE FROM radusergroup WHERE groupname = %s", (groupname,))
|
||||
cursor.execute("DELETE FROM radgroupcheck WHERE groupname = %s", (groupname,))
|
||||
db.commit()
|
||||
cursor.close()
|
||||
db.close()
|
||||
return redirect(url_for('groups'))
|
||||
except mysql.connector.Error as err:
|
||||
print(f"Database Error: {err}")
|
||||
db.rollback()
|
||||
cursor.close()
|
||||
db.close()
|
||||
return redirect(url_for('groups'))
|
||||
return "Database Connection Failed"
|
||||
|
||||
@app.route('/delete_group/<int:group_id>')
|
||||
def delete_group(group_id):
|
||||
db = get_db()
|
||||
if db:
|
||||
cursor = db.cursor()
|
||||
try:
|
||||
cursor.execute("DELETE FROM radgroupreply WHERE id = %s", (group_id,))
|
||||
cursor.execute("DELETE FROM radgroupcheck WHERE id = %s", (group_id,)) # Delete from radgroupcheck
|
||||
db.commit()
|
||||
cursor.close()
|
||||
db.close()
|
||||
return redirect(url_for('groups'))
|
||||
except mysql.connector.Error as err:
|
||||
print(f"Database Error: {err}")
|
||||
db.rollback()
|
||||
cursor.close()
|
||||
db.close()
|
||||
return redirect(url_for('groups'))
|
||||
return "Database Connection Failed"
|
||||
|
||||
@app.route('/duplicate_group', methods=['POST'])
|
||||
def duplicate_group():
|
||||
groupname = request.form['groupname']
|
||||
db = get_db()
|
||||
if db:
|
||||
cursor = db.cursor()
|
||||
try:
|
||||
cursor.execute("SELECT attribute, op, value FROM radgroupreply WHERE groupname = %s", (groupname,))
|
||||
attributes = [{'attribute': row[0], 'op': row[1], 'value': row[2]} for row in cursor.fetchall()]
|
||||
cursor.close()
|
||||
db.close()
|
||||
return jsonify(attributes)
|
||||
except mysql.connector.Error as err:
|
||||
print(f"Database Error: {err}")
|
||||
cursor.close()
|
||||
db.close()
|
||||
return jsonify([])
|
||||
return jsonify([])
|
||||
|
||||
@app.route('/save_duplicated_group', methods=['POST'])
|
||||
def save_duplicated_group():
|
||||
data = json.loads(request.data)
|
||||
groupname = data['groupname']
|
||||
attributes = data['attributes']
|
||||
db = get_db()
|
||||
if db:
|
||||
cursor = db.cursor()
|
||||
try:
|
||||
cursor.execute("INSERT INTO radgroupcheck (groupname, attribute, op, value) VALUES (%s, 'Auth-Type', ':=', 'Accept')", (groupname,))
|
||||
cursor.execute("INSERT INTO radusergroup (groupname, username) VALUES (%s, '')", (groupname,))
|
||||
for attribute in attributes:
|
||||
cursor.execute("INSERT INTO radgroupreply (groupname, attribute, op, value) VALUES (%s, %s, %s, %s)", (groupname, attribute['attribute'], attribute['op'], attribute['value']))
|
||||
db.commit()
|
||||
cursor.close()
|
||||
db.close()
|
||||
return "success"
|
||||
except mysql.connector.Error as err:
|
||||
print(f"Database Error: {err}")
|
||||
db.rollback()
|
||||
cursor.close()
|
||||
db.close()
|
||||
return str(err)
|
||||
return "Database Connection Failed"
|
||||
|
||||
@app.route('/add_user', methods=['POST'])
|
||||
def add_user():
|
||||
"""Adds a new user to the database."""
|
||||
try:
|
||||
data = request.get_json() # Get the JSON data from the request
|
||||
mac_address = data.get('mac_address')
|
||||
description = data.get('description')
|
||||
vlan_id = data.get('vlan_id')
|
||||
|
||||
if not mac_address:
|
||||
return jsonify({'success': False, 'message': 'MAC Address is required'}), 400
|
||||
|
||||
db = get_db()
|
||||
if db is None:
|
||||
return jsonify({'success': False, 'message': 'Database connection failed'}), 500
|
||||
|
||||
cursor = db.cursor()
|
||||
try:
|
||||
db.autocommit = False #Start Transaction
|
||||
|
||||
# Check if user already exists
|
||||
cursor.execute("SELECT username FROM radcheck WHERE username = %s", (mac_address,))
|
||||
if cursor.fetchone():
|
||||
cursor.close()
|
||||
db.close()
|
||||
return jsonify({'success': False, 'message': 'User with this MAC Address already exists'}), 400
|
||||
|
||||
# Insert into radcheck, setting password to MAC address
|
||||
cursor.execute("""
|
||||
INSERT INTO radcheck (username, attribute, op, value)
|
||||
VALUES (%s, 'Cleartext-Password', ':=', %s)
|
||||
""", (mac_address, mac_address)) # Use mac_address for both username and password
|
||||
|
||||
# Insert description into rad_description
|
||||
cursor.execute("""
|
||||
INSERT INTO rad_description (username, description)
|
||||
VALUES (%s, %s)
|
||||
""", (mac_address, description))
|
||||
|
||||
# Insert into radusergroup with the selected group
|
||||
cursor.execute("""
|
||||
INSERT INTO radusergroup (username, groupname)
|
||||
VALUES (%s, %s)
|
||||
""", (mac_address, vlan_id)) # Use vlan_id
|
||||
|
||||
db.commit() #Commit transaction
|
||||
db.autocommit = True
|
||||
cursor.close()
|
||||
db.close()
|
||||
return jsonify({'success': True, 'message': 'User added successfully'})
|
||||
|
||||
except mysql.connector.Error as err:
|
||||
print(f"Database Error: {err}")
|
||||
db.rollback() #Rollback transaction on error.
|
||||
db.autocommit = True
|
||||
cursor.close()
|
||||
db.close()
|
||||
return jsonify({'success': False, 'message': f"Database error: {err}"}), 500
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error adding user: {e}")
|
||||
db.rollback() #Rollback transaction on error.
|
||||
db.autocommit = True
|
||||
cursor.close()
|
||||
db.close()
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': 'Unknown error'}), 500
|
||||
|
||||
@app.route('/duplicate_user', methods=['POST'])
|
||||
def duplicate_user():
|
||||
"""
|
||||
Retrieves user data (MAC address, description, VLAN ID) from the database
|
||||
based on the provided MAC address. This data is intended to be used to
|
||||
pre-populate a "duplicate user" form in the frontend.
|
||||
"""
|
||||
mac_address = request.form['mac_address'] # Get the MAC address from the POST request.
|
||||
|
||||
db = get_db() # Get a database connection.
|
||||
if db:
|
||||
cursor = db.cursor(dictionary=True) # Create a cursor that returns results as dictionaries.
|
||||
try:
|
||||
# Construct the SQL query. This query retrieves the MAC address,
|
||||
# description, and VLAN ID for the specified user.
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
rc.username AS mac_address,
|
||||
IFNULL((SELECT value FROM radgroupreply rgr
|
||||
WHERE rgr.groupname = (SELECT groupname FROM radusergroup rug WHERE rug.username = rc.username LIMIT 1)
|
||||
AND rgr.attribute = 'Tunnel-Private-Group-Id' LIMIT 1), 'N/A') AS vlan_id,
|
||||
IFNULL((SELECT value FROM radcheck rch
|
||||
WHERE rch.username = rc.username AND rch.attribute = 'User-Description' LIMIT 1), 'N/A') AS description
|
||||
FROM radcheck rc
|
||||
WHERE rc.username = %s /* %s is a placeholder for the MAC address */
|
||||
GROUP BY rc.username;
|
||||
""", (mac_address,)) # Execute the query with the MAC address as a parameter.
|
||||
|
||||
user_data = cursor.fetchone() # Fetch the first (and should be only) result.
|
||||
cursor.close() # Close the cursor.
|
||||
db.close() # Close the database connection.
|
||||
|
||||
if user_data:
|
||||
# If user data was found, return it as a JSON response.
|
||||
return jsonify(user_data)
|
||||
else:
|
||||
# If no user data was found (e.g., invalid MAC address), return an empty JSON object.
|
||||
return jsonify({})
|
||||
|
||||
except mysql.connector.Error as err:
|
||||
# Handle database errors. Log the error and return an error message.
|
||||
print(f"Database Error: {err}")
|
||||
cursor.close()
|
||||
db.close()
|
||||
return jsonify({}) # Return an empty JSON object on error, to avoid crashing.
|
||||
|
||||
else:
|
||||
# Handle the case where the database connection could not be established.
|
||||
return jsonify({}) # Return empty JSON object
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True, host='0.0.0.0', port=8080)
|
||||
@app.route('/maintenance')
|
||||
def maintenance():
|
||||
return redirect(url_for('maintenance.maintenance'))
|
||||
38
app/config.py
Normal file
38
app/config.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import os
|
||||
|
||||
class Config:
|
||||
DEBUG = False
|
||||
TESTING = False
|
||||
SECRET_KEY = os.getenv('FLASK_SECRET_KEY', 'insecure-default-key')
|
||||
|
||||
# Database connection info
|
||||
DB_HOST = os.getenv('DB_HOST', 'localhost')
|
||||
DB_PORT = int(os.getenv('DB_PORT', '3306'))
|
||||
DB_USER = os.getenv('DB_USER', 'radiususer')
|
||||
DB_PASSWORD = os.getenv('DB_PASSWORD', 'radiuspass')
|
||||
DB_NAME = os.getenv('DB_NAME', 'radius')
|
||||
|
||||
# Logging
|
||||
LOG_TO_FILE = os.getenv('LOG_TO_FILE', 'false').lower() == 'true'
|
||||
LOG_FILE_PATH = os.getenv('LOG_FILE_PATH', '/app/logs/app.log')
|
||||
|
||||
# MAC Lookup API
|
||||
OUI_API_URL = os.getenv('OUI_API_URL', 'https://api.maclookup.app/v2/macs/{}')
|
||||
OUI_API_KEY = os.getenv('OUI_API_KEY', '')
|
||||
OUI_API_LIMIT_PER_SEC = int(os.getenv('OUI_API_LIMIT_PER_SEC', '2'))
|
||||
OUI_API_DAILY_LIMIT = int(os.getenv('OUI_API_DAILY_LIMIT', '10000'))
|
||||
|
||||
# Timezone
|
||||
APP_TIMEZONE = os.getenv('APP_TIMEZONE', 'UTC')
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
DEBUG = True
|
||||
|
||||
class ProductionConfig(Config):
|
||||
DEBUG = False
|
||||
|
||||
# Runtime selection
|
||||
if os.getenv('FLASK_ENV') == 'production':
|
||||
app_config = ProductionConfig
|
||||
else:
|
||||
app_config = DevelopmentConfig
|
||||
20
app/database.py
Normal file
20
app/database.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import mysql.connector
|
||||
from flask import current_app, g
|
||||
|
||||
def get_db():
|
||||
if 'db' not in g:
|
||||
g.db = mysql.connector.connect(
|
||||
host=current_app.config['DB_HOST'],
|
||||
port=current_app.config['DB_PORT'],
|
||||
user=current_app.config['DB_USER'],
|
||||
password=current_app.config['DB_PASSWORD'],
|
||||
database=current_app.config['DB_NAME']
|
||||
)
|
||||
return g.db
|
||||
|
||||
def init_app(app):
|
||||
@app.teardown_appcontext
|
||||
def close_connection(exception):
|
||||
db = g.pop('db', None)
|
||||
if db is not None:
|
||||
db.close()
|
||||
11
app/db_connection.py
Normal file
11
app/db_connection.py
Normal file
@@ -0,0 +1,11 @@
|
||||
import mysql.connector
|
||||
import os
|
||||
|
||||
def get_connection():
|
||||
return mysql.connector.connect(
|
||||
host=os.getenv('DB_HOST'),
|
||||
port=int(os.getenv('DB_PORT', 3306)),
|
||||
user=os.getenv('DB_USER'),
|
||||
password=os.getenv('DB_PASSWORD'),
|
||||
database=os.getenv('DB_NAME'),
|
||||
)
|
||||
721
app/db_interface.py
Normal file
721
app/db_interface.py
Normal file
@@ -0,0 +1,721 @@
|
||||
from flask import current_app, request, redirect, url_for, flash
|
||||
from db_connection import get_connection
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import mysql.connector
|
||||
import requests
|
||||
import time
|
||||
import os
|
||||
import subprocess
|
||||
import pytz
|
||||
import shutil
|
||||
|
||||
|
||||
# ------------------------------
|
||||
# User Management Functions
|
||||
# ------------------------------
|
||||
|
||||
def get_all_users():
|
||||
"""Retrieve all users with associated group and vendor information."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
u.*,
|
||||
g.vlan_id AS group_vlan_id,
|
||||
g.description AS group_description,
|
||||
COALESCE(m.vendor_name, '...') AS vendor
|
||||
FROM users u
|
||||
LEFT JOIN groups g ON u.vlan_id = g.vlan_id
|
||||
LEFT JOIN mac_vendors m ON LOWER(REPLACE(REPLACE(u.mac_address, ':', ''), '-', '')) LIKE CONCAT(m.mac_prefix, '%')
|
||||
""")
|
||||
users = cursor.fetchall()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return users
|
||||
|
||||
def get_user_by_mac(mac_address):
|
||||
"""Retrieve a user record from the database by MAC address."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("""
|
||||
SELECT * FROM users WHERE mac_address = %s
|
||||
""", (mac_address,))
|
||||
user = cursor.fetchone()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return user
|
||||
|
||||
def get_users_by_vlan_id(vlan_id):
|
||||
"""Fetch users assigned to a specific VLAN ID."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("SELECT mac_address, description FROM users WHERE vlan_id = %s", (vlan_id,))
|
||||
users = cursor.fetchall()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return users
|
||||
|
||||
def add_user(mac_address, description, vlan_id):
|
||||
"""Insert a new user with MAC address, description, and VLAN assignment."""
|
||||
print(f"→ Adding to DB: mac={mac_address}, desc={description}, vlan={vlan_id}")
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"INSERT INTO users (mac_address, description, vlan_id) VALUES (%s, %s, %s)",
|
||||
(mac_address.lower(), description, vlan_id)
|
||||
)
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
def update_user(mac_address, description, vlan_id):
|
||||
"""Update both description and VLAN ID for a given MAC address."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"UPDATE users SET description = %s, vlan_id = %s WHERE mac_address = %s",
|
||||
(description, vlan_id, mac_address.lower())
|
||||
)
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
def delete_user(mac_address):
|
||||
"""Remove a user from the database by their MAC address."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM users WHERE mac_address = %s", (mac_address.lower(),))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
# ------------------------------
|
||||
# Group Management Functions
|
||||
# ------------------------------
|
||||
|
||||
def get_all_groups():
|
||||
"""Retrieve all groups along with user count for each group."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("""
|
||||
SELECT g.*, (
|
||||
SELECT COUNT(*) FROM users WHERE vlan_id = g.vlan_id
|
||||
) AS user_count
|
||||
FROM groups g
|
||||
ORDER BY g.vlan_id
|
||||
""")
|
||||
available_groups = cursor.fetchall()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return available_groups
|
||||
|
||||
def add_group(vlan_id, description):
|
||||
"""Insert a new group with a specified VLAN ID and description."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("INSERT INTO groups (vlan_id, description) VALUES (%s, %s)", (vlan_id, description))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
def update_group_description(vlan_id, description):
|
||||
"""Update the description for a given MAC address in the users table."""
|
||||
# Docstring seems incorrect (mentions MAC address), but keeping original text.
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("UPDATE groups SET description = %s WHERE vlan_id = %s", (description, vlan_id))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
def delete_group(vlan_id, force_delete=False):
|
||||
"""Delete a group, and optionally its associated users if force_delete=True."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
if force_delete:
|
||||
cursor.execute("DELETE FROM users WHERE vlan_id = %s", (vlan_id,))
|
||||
cursor.execute("DELETE FROM groups WHERE vlan_id = %s", (vlan_id,))
|
||||
conn.commit()
|
||||
except mysql.connector.IntegrityError as e:
|
||||
print(f"❌ Cannot delete group '{vlan_id}': it is still in use. Error: {e}")
|
||||
raise
|
||||
finally:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
def delete_group_route():
|
||||
"""Handle deletion of a group and optionally its users via form POST."""
|
||||
# Note: This function interacts with Flask's request/flash/redirect.
|
||||
vlan_id = request.form.get("group_id")
|
||||
force = request.form.get("force_delete") == "true"
|
||||
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM users WHERE vlan_id = %s", (vlan_id,))
|
||||
user_count = cursor.fetchone()[0]
|
||||
|
||||
if user_count > 0 and not force:
|
||||
conn.close()
|
||||
flash("Group has users. Please confirm deletion or reassign users.", "error")
|
||||
# Assuming 'group.group_list' is a valid endpoint name
|
||||
return redirect(url_for("group.group_list"))
|
||||
|
||||
try:
|
||||
# Consider calling the delete_group() function here for consistency,
|
||||
# but keeping original structure as requested.
|
||||
if force:
|
||||
cursor.execute("DELETE FROM users WHERE vlan_id = %s", (vlan_id,))
|
||||
cursor.execute("DELETE FROM groups WHERE vlan_id = %s", (vlan_id,))
|
||||
conn.commit()
|
||||
flash(f"Group {vlan_id} and associated users deleted." if force else f"Group {vlan_id} deleted.", "success")
|
||||
except mysql.connector.IntegrityError as e:
|
||||
flash(f"Cannot delete group {vlan_id}: it is still in use. Error: {e}", "error")
|
||||
except Exception as e:
|
||||
flash(f"Error deleting group: {e}", "error")
|
||||
finally:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
# Assuming 'group.group_list' is a valid endpoint name
|
||||
return redirect(url_for("group.group_list"))
|
||||
|
||||
|
||||
# ------------------------------
|
||||
# MAC Vendor Functions
|
||||
# ------------------------------
|
||||
|
||||
def get_known_mac_vendors():
|
||||
"""Fetch all known MAC prefixes and their vendor info from the local database."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("SELECT mac_prefix, vendor_name, status FROM mac_vendors")
|
||||
entries = cursor.fetchall()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
row['mac_prefix'].lower(): {
|
||||
'vendor': row['vendor_name'],
|
||||
'status': row['status']
|
||||
}
|
||||
for row in entries
|
||||
}
|
||||
|
||||
def get_vendor_info(mac, insert_if_found=True):
|
||||
"""Get vendor info for a MAC address, optionally inserting into the database."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
prefix = mac.lower().replace(":", "").replace("-", "")[:6]
|
||||
|
||||
print(f">>> Looking up MAC: {mac} → Prefix: {prefix}")
|
||||
print("→ Searching in local database...")
|
||||
cursor.execute("SELECT vendor_name, status FROM mac_vendors WHERE mac_prefix = %s", (prefix,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
print(f"✓ Found locally: {row['vendor_name']} (Status: {row['status']})")
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return {
|
||||
"mac": mac,
|
||||
"vendor": row['vendor_name'],
|
||||
"source": "local",
|
||||
"status": row['status']
|
||||
}
|
||||
|
||||
print("✗ Not found locally, querying API...")
|
||||
|
||||
url_template = current_app.config.get("OUI_API_URL", "https://api.maclookup.app/v2/macs/{}")
|
||||
api_key = current_app.config.get("OUI_API_KEY", "")
|
||||
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
||||
|
||||
try:
|
||||
url = url_template.format(prefix)
|
||||
print(f"→ Querying API: {url}")
|
||||
response = requests.get(url, headers=headers)
|
||||
vendor_to_insert = "not found"
|
||||
status_to_insert = "not_found"
|
||||
result = { "mac": mac, "vendor": "", "source": "api", "status": "not_found" } # Default
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
vendor = data.get("company", "").strip()
|
||||
|
||||
if vendor:
|
||||
print(f"✓ Found from API: {vendor}")
|
||||
vendor_to_insert = vendor
|
||||
status_to_insert = "found"
|
||||
result = { "mac": mac, "vendor": vendor, "source": "api", "status": "found" }
|
||||
else:
|
||||
print("⚠️ API returned empty company field. Treating as not_found.")
|
||||
# vendor/status/result remain default 'not_found'
|
||||
elif response.status_code == 404:
|
||||
print("✗ API returned 404 - vendor not found.")
|
||||
# vendor/status/result remain default 'not_found'
|
||||
else:
|
||||
print(f"✗ API error: {response.status_code}")
|
||||
# Don't insert on other API errors
|
||||
result = {"mac": mac, "vendor": "", "error": f"API error: {response.status_code}"}
|
||||
# Skip insert logic below by returning early
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return result
|
||||
|
||||
# Insert/Update logic (Based on original code's comments, it always inserts/updates)
|
||||
# Using 'insert_if_found' flag is ignored as per original code's apparent behaviour
|
||||
cursor.execute("""
|
||||
INSERT INTO mac_vendors (mac_prefix, vendor_name, status, last_checked, last_updated)
|
||||
VALUES (%s, %s, %s, NOW(), NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
vendor_name = VALUES(vendor_name),
|
||||
status = VALUES(status),
|
||||
last_checked = NOW(),
|
||||
last_updated = NOW()
|
||||
""", (prefix, vendor_to_insert, status_to_insert))
|
||||
print(f"→ Inserted/Updated '{vendor_to_insert}' ({status_to_insert}) for {prefix} → rowcount: {cursor.rowcount}")
|
||||
conn.commit()
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Exception while querying API: {e}")
|
||||
return {"mac": mac, "vendor": "", "error": str(e)}
|
||||
|
||||
finally:
|
||||
if conn and conn.is_connected():
|
||||
if cursor:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
def lookup_mac_verbose(mac):
|
||||
"""Look up vendor info for a MAC with verbose output, querying API if needed."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
output = []
|
||||
prefix = mac.lower().replace(":", "").replace("-", "")[:6]
|
||||
|
||||
output.append(f"🔍 Searching local database for prefix: {prefix}...")
|
||||
|
||||
cursor.execute("SELECT vendor_name, status FROM mac_vendors WHERE mac_prefix = %s", (prefix,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
output.append(f"✅ Found locally: {row['vendor_name']} (status: {row['status']})")
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return "\n".join(output)
|
||||
|
||||
output.append("❌ Not found locally.")
|
||||
output.append("🌐 Querying API...")
|
||||
|
||||
url_template = current_app.config.get("OUI_API_URL", "https://api.maclookup.app/v2/macs/{}")
|
||||
api_key = current_app.config.get("OUI_API_KEY", "")
|
||||
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
||||
|
||||
try:
|
||||
url = url_template.format(prefix)
|
||||
response = requests.get(url, headers=headers, timeout=10) # Add timeout
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
vendor_name = data.get("company", "").strip()
|
||||
if vendor_name:
|
||||
output.append(f"✅ Found via API: {vendor_name}")
|
||||
output.append("💾 Inserting into local database...")
|
||||
|
||||
# Original code here used simple INSERT, not INSERT...ON DUPLICATE KEY UPDATE
|
||||
# Consider changing to match get_vendor_info for consistency if desired.
|
||||
# Sticking to original code for now.
|
||||
cursor.execute("""
|
||||
INSERT INTO mac_vendors (mac_prefix, vendor_name, status, last_checked, last_updated)
|
||||
VALUES (%s, %s, 'found', NOW(), NOW())
|
||||
""", (prefix, vendor_name))
|
||||
conn.commit()
|
||||
output.append(f" → Inserted '{vendor_name}' for {prefix} (rowcount: {cursor.rowcount})")
|
||||
else:
|
||||
output.append("⚠️ API responded but no vendor name found. Not inserting.")
|
||||
# Consider inserting 'not_found' status here for consistency? Original code doesn't.
|
||||
elif response.status_code == 404:
|
||||
output.append("❌ Not found via API (404). Not inserting.")
|
||||
# Consider inserting 'not_found' status here for consistency? Original code doesn't.
|
||||
else:
|
||||
output.append(f"❌ API returned unexpected status: {response.status_code}")
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
output.append(f"🚨 Network/Request Exception during API request: {e}")
|
||||
except Exception as e:
|
||||
output.append(f"🚨 Unexpected Exception during API request: {e}")
|
||||
conn.rollback() # Rollback on general exception during DB interaction phase
|
||||
|
||||
finally:
|
||||
if conn and conn.is_connected():
|
||||
if cursor:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
def refresh_vendors():
|
||||
"""Fetch and cache vendor info for unknown MAC prefixes using the API."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
# Fetch all distinct 6-char prefixes from users table that are NOT in mac_vendors table
|
||||
cursor.execute("""
|
||||
SELECT DISTINCT SUBSTRING(REPLACE(REPLACE(mac_address, ':', ''), '-', ''), 1, 6) AS mac_prefix
|
||||
FROM users
|
||||
WHERE SUBSTRING(REPLACE(REPLACE(mac_address, ':', ''), '-', ''), 1, 6) NOT IN (
|
||||
SELECT mac_prefix FROM mac_vendors
|
||||
)
|
||||
""")
|
||||
prefixes = [row['mac_prefix'].lower() for row in cursor.fetchall() if row['mac_prefix']]
|
||||
cursor.close()
|
||||
|
||||
if not prefixes:
|
||||
print("→ No unknown MAC prefixes found in users table to refresh.")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
print(f"→ Found {len(prefixes)} unknown prefixes to look up.")
|
||||
|
||||
url_template = current_app.config.get("OUI_API_URL", "https://api.maclookup.app/v2/macs/{}")
|
||||
api_key = current_app.config.get("OUI_API_KEY", "")
|
||||
rate_limit = int(current_app.config.get("OUI_API_LIMIT_PER_SEC", 2))
|
||||
daily_limit = int(current_app.config.get("OUI_API_DAILY_LIMIT", 10000))
|
||||
|
||||
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
||||
|
||||
inserted_count = 0
|
||||
skipped_count = 0
|
||||
error_count = 0
|
||||
cursor = conn.cursor() # Use standard cursor for inserts
|
||||
|
||||
for i, prefix in enumerate(prefixes):
|
||||
if inserted_count >= daily_limit:
|
||||
print(f"🛑 Reached daily API limit ({daily_limit}). Stopping refresh.")
|
||||
break
|
||||
|
||||
print(f" ({i+1}/{len(prefixes)}) Looking up prefix: {prefix}")
|
||||
|
||||
try:
|
||||
url = url_template.format(prefix)
|
||||
response = requests.get(url, headers=headers, timeout=10)
|
||||
|
||||
vendor_name = "not found"
|
||||
status = "not_found"
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
api_vendor = data.get("company", "").strip()
|
||||
if api_vendor:
|
||||
vendor_name = api_vendor
|
||||
status = "found"
|
||||
print(f" ✓ Found: {vendor_name}")
|
||||
else:
|
||||
print(f" ⚠️ API OK, but empty vendor for {prefix}. Marked as 'not found'.")
|
||||
elif response.status_code == 404:
|
||||
print(f" ✗ Not found (404) for {prefix}.")
|
||||
else:
|
||||
print(f" ❌ API error {response.status_code} for {prefix}, skipping insert.")
|
||||
error_count += 1
|
||||
time.sleep(1.0 / rate_limit)
|
||||
continue
|
||||
|
||||
# Insert or Update logic matches get_vendor_info
|
||||
cursor.execute("""
|
||||
INSERT INTO mac_vendors (mac_prefix, vendor_name, status, last_checked, last_updated)
|
||||
VALUES (%s, %s, %s, NOW(), NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
vendor_name = VALUES(vendor_name),
|
||||
status = VALUES(status),
|
||||
last_checked = NOW(),
|
||||
last_updated = NOW()
|
||||
""", (prefix, vendor_name, status))
|
||||
conn.commit()
|
||||
if cursor.rowcount > 0:
|
||||
inserted_count += 1
|
||||
print(f" → Stored '{vendor_name}' ({status}) for {prefix}")
|
||||
else:
|
||||
print(f" → No change recorded for {prefix}.")
|
||||
skipped_count +=1
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f" 🚨 Network/Request Exception fetching vendor for {prefix}: {e}")
|
||||
error_count += 1
|
||||
except Exception as e:
|
||||
print(f" 🚨 Unexpected Exception fetching vendor for {prefix}: {e}")
|
||||
error_count += 1
|
||||
conn.rollback()
|
||||
|
||||
time.sleep(1.0 / rate_limit)
|
||||
|
||||
print(f"→ Refresh finished. Inserted/Updated: {inserted_count}, Skipped/No change: {skipped_count}, Errors: {error_count}")
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
# ------------------------------
|
||||
# Authentication Log Functions
|
||||
# ------------------------------
|
||||
|
||||
def get_latest_auth_logs(reply_type=None, limit=5, time_range=None, offset=0):
|
||||
"""Retrieve recent authentication logs filtered by reply type and time range."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
tz_str = current_app.config.get('APP_TIMEZONE', 'UTC')
|
||||
try:
|
||||
app_tz = pytz.timezone(tz_str)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
print(f"Warning: Unknown timezone '{tz_str}', falling back to UTC.")
|
||||
app_tz = pytz.utc
|
||||
now = datetime.now(app_tz)
|
||||
print(f"🕒 Using timezone: {tz_str} → Now: {now.isoformat()}")
|
||||
|
||||
query_base = "SELECT * FROM auth_logs"
|
||||
filters = []
|
||||
params = []
|
||||
|
||||
if reply_type == 'Accept-Fallback':
|
||||
filters.append("reply = 'Access-Accept'")
|
||||
filters.append("result LIKE %s")
|
||||
params.append('%Fallback%')
|
||||
elif reply_type is not None:
|
||||
filters.append("reply = %s")
|
||||
params.append(reply_type)
|
||||
|
||||
time_filter_dt = None
|
||||
if time_range and time_range != 'all':
|
||||
delta = {
|
||||
'last_minute': timedelta(minutes=1),
|
||||
'last_5_minutes': timedelta(minutes=5),
|
||||
'last_10_minutes': timedelta(minutes=10),
|
||||
'last_hour': timedelta(hours=1),
|
||||
'last_6_hours': timedelta(hours=6),
|
||||
'last_12_hours': timedelta(hours=12),
|
||||
'last_day': timedelta(days=1),
|
||||
'last_30_days': timedelta(days=30)
|
||||
}.get(time_range)
|
||||
|
||||
if delta:
|
||||
time_filter_dt = now - delta
|
||||
print(f"🕒 Filtering logs after: {time_filter_dt.isoformat()}")
|
||||
filters.append("timestamp >= %s")
|
||||
params.append(time_filter_dt)
|
||||
|
||||
if filters:
|
||||
query_base += " WHERE " + " AND ".join(filters)
|
||||
|
||||
query_base += " ORDER BY timestamp DESC LIMIT %s OFFSET %s"
|
||||
params.extend([limit, offset])
|
||||
|
||||
cursor.execute(query_base, tuple(params))
|
||||
logs = cursor.fetchall()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return logs
|
||||
|
||||
def count_auth_logs(reply_type=None, time_range=None):
|
||||
"""Count the number of authentication logs matching a reply type and time."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
tz_str = current_app.config.get('APP_TIMEZONE', 'UTC')
|
||||
try:
|
||||
app_tz = pytz.timezone(tz_str)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
print(f"Warning: Unknown timezone '{tz_str}', falling back to UTC.")
|
||||
app_tz = pytz.utc
|
||||
now = datetime.now(app_tz)
|
||||
print(f"🕒 Using timezone: {tz_str} → Now: {now.isoformat()}")
|
||||
|
||||
query_base = "SELECT COUNT(*) FROM auth_logs"
|
||||
filters = []
|
||||
params = []
|
||||
|
||||
if reply_type == 'Accept-Fallback':
|
||||
filters.append("reply = 'Access-Accept'")
|
||||
filters.append("result LIKE %s")
|
||||
params.append('%Fallback%')
|
||||
elif reply_type is not None:
|
||||
filters.append("reply = %s")
|
||||
params.append(reply_type)
|
||||
|
||||
time_filter_dt = None
|
||||
if time_range and time_range != 'all':
|
||||
delta = {
|
||||
'last_minute': timedelta(minutes=1),
|
||||
'last_5_minutes': timedelta(minutes=5),
|
||||
'last_10_minutes': timedelta(minutes=10),
|
||||
'last_hour': timedelta(hours=1),
|
||||
'last_6_hours': timedelta(hours=6),
|
||||
'last_12_hours': timedelta(hours=12),
|
||||
'last_day': timedelta(days=1),
|
||||
'last_30_days': timedelta(days=30)
|
||||
}.get(time_range)
|
||||
|
||||
if delta:
|
||||
time_filter_dt = now - delta
|
||||
print(f"🕒 Filtering logs after: {time_filter_dt.isoformat()}")
|
||||
filters.append("timestamp >= %s")
|
||||
params.append(time_filter_dt)
|
||||
|
||||
if filters:
|
||||
query_base += " WHERE " + " AND ".join(filters)
|
||||
|
||||
cursor.execute(query_base, tuple(params))
|
||||
count = cursor.fetchone()[0]
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return count
|
||||
|
||||
|
||||
# ------------------------------
|
||||
# Summary Functions
|
||||
# ------------------------------
|
||||
|
||||
def get_summary_counts():
|
||||
"""Return total counts of users and groups from the database."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
total_users = 0
|
||||
total_groups = 0
|
||||
|
||||
try:
|
||||
cursor.execute("SELECT COUNT(*) AS count FROM users")
|
||||
result = cursor.fetchone()
|
||||
total_users = result['count'] if result else 0
|
||||
|
||||
cursor.execute("SELECT COUNT(*) AS count FROM groups")
|
||||
result = cursor.fetchone()
|
||||
total_groups = result['count'] if result else 0
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting summary counts: {e}")
|
||||
finally:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
return total_users, total_groups
|
||||
|
||||
def get_database_stats():
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
stats = {}
|
||||
|
||||
# Get total size of the database
|
||||
cursor.execute("""
|
||||
SELECT table_schema AS db_name,
|
||||
ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS total_mb
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
GROUP BY table_schema
|
||||
""")
|
||||
row = cursor.fetchone()
|
||||
stats["total_size_mb"] = row[1] if row else 0
|
||||
|
||||
# Optional: count total rows in key tables
|
||||
cursor.execute("SELECT COUNT(*) FROM auth_logs")
|
||||
stats["auth_logs_count"] = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM users")
|
||||
stats["users_count"] = cursor.fetchone()[0]
|
||||
|
||||
conn.close()
|
||||
return stats
|
||||
|
||||
# ------------------------------
|
||||
# Maintenance Functions
|
||||
# ------------------------------
|
||||
|
||||
def clear_auth_logs():
|
||||
"""Route to clear authentication logs."""
|
||||
from db_connection import get_connection
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
cursor.execute("DELETE FROM auth_logs")
|
||||
conn.commit()
|
||||
flash("✅ Authentication logs cleared.", "success")
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
flash(f"❌ Error clearing logs: {e}", "error")
|
||||
finally:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return redirect(url_for("maintenance.maintenance_page"))
|
||||
|
||||
def backup_database():
|
||||
"""Create a SQL backup of the entire database and return the path to the file."""
|
||||
conn = get_connection()
|
||||
db_name = conn.database
|
||||
user = conn.user
|
||||
password = conn._password
|
||||
host = conn.server_host if hasattr(conn, 'server_host') else 'localhost'
|
||||
conn.close()
|
||||
|
||||
# Check if mysqldump exists
|
||||
if not shutil.which("mysqldump"):
|
||||
raise Exception("❌ 'mysqldump' command not found. Please install mariadb-client or mysql-client.")
|
||||
|
||||
backup_file = "backup.sql"
|
||||
|
||||
try:
|
||||
with open(backup_file, "w") as f:
|
||||
subprocess.run(
|
||||
["mysqldump", "-h", host, "-u", user, f"-p{password}", db_name],
|
||||
stdout=f,
|
||||
check=True
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise Exception(f"❌ Backup failed: {e}")
|
||||
|
||||
return backup_file
|
||||
|
||||
def restore_database(sql_content):
|
||||
"""Restore the database from raw SQL content (as string)."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
for statement in sql_content.split(';'):
|
||||
stmt = statement.strip()
|
||||
if stmt:
|
||||
cursor.execute(stmt)
|
||||
conn.commit()
|
||||
flash("✅ Database restored successfully.", "success")
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
flash(f"❌ Error restoring database: {e}", "error")
|
||||
finally:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return redirect(url_for("maintenance.maintenance_page"))
|
||||
|
||||
def get_table_stats():
|
||||
"""Return a dictionary of table names and their row counts."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute("SHOW TABLES")
|
||||
tables = [row[0] for row in cursor.fetchall()]
|
||||
stats = {}
|
||||
|
||||
for table in tables:
|
||||
cursor.execute(f"SELECT COUNT(*) FROM `{table}`")
|
||||
count = cursor.fetchone()[0]
|
||||
stats[table] = count
|
||||
|
||||
return stats
|
||||
except Exception as e:
|
||||
print(f"❌ Error retrieving table stats: {e}")
|
||||
return None
|
||||
finally:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
Flask
|
||||
mysql-connector-python
|
||||
requests
|
||||
BeautifulSoup4
|
||||
lxml
|
||||
gunicorn
|
||||
pytz
|
||||
humanize
|
||||
Binary file not shown.
BIN
app/static/android-chrome-192x192.png
Normal file
BIN
app/static/android-chrome-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
BIN
app/static/android-chrome-512x512.png
Normal file
BIN
app/static/android-chrome-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 262 KiB |
BIN
app/static/apple-touch-icon.png
Normal file
BIN
app/static/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
BIN
app/static/favicon-16x16.png
Normal file
BIN
app/static/favicon-16x16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 728 B |
BIN
app/static/favicon-32x32.png
Normal file
BIN
app/static/favicon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
BIN
app/static/favicon.ico
Normal file
BIN
app/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
511
app/static/styles.css
Normal file
511
app/static/styles.css
Normal file
@@ -0,0 +1,511 @@
|
||||
:root {
|
||||
--bg: #121212;
|
||||
--fg: #f0f0f0;
|
||||
--accent: #ffdd57; /* Soft yellow */
|
||||
--error: crimson;
|
||||
--cell-bg: #1e1e1e;
|
||||
--card-bg: #2c2c2c;
|
||||
--header-bg: #2a2a2a;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg: #f8f9fa;
|
||||
--fg: #212529;
|
||||
--accent: #4a90e2; /* Softer blue */
|
||||
--error: red;
|
||||
--cell-bg: #ffffff;
|
||||
--card-bg: #e9ecef;
|
||||
--header-bg: #dee2e6;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
margin: 0;
|
||||
padding: 1rem 2rem;
|
||||
}
|
||||
|
||||
nav {
|
||||
background-color: var(--card-bg);
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #666;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
nav .links a {
|
||||
margin-right: 1rem;
|
||||
color: var(--fg);
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
nav .links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
button#theme-toggle {
|
||||
background: none;
|
||||
border: 1px solid var(--fg);
|
||||
padding: 4px 8px;
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background-color: var(--accent);
|
||||
color: black;
|
||||
padding: 10px 16px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#scrollTopBtn {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 30px;
|
||||
left: 30px;
|
||||
z-index: 1000;
|
||||
font-size: 1.2rem;
|
||||
background-color: var(--accent);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
color: black;
|
||||
}
|
||||
|
||||
table.styled-table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
background-color: var(--cell-bg);
|
||||
}
|
||||
|
||||
.styled-table thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: var(--header-bg);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.styled-table th,
|
||||
.styled-table td {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
.styled-table th {
|
||||
background-color: var(--header-bg);
|
||||
color: var(--fg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 🧩 Fix: Remove right border from last column */
|
||||
.styled-table thead th:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.styled-table input[type="text"],
|
||||
.styled-table select {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 6px;
|
||||
color: var(--fg);
|
||||
background-color: var(--cell-bg);
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.styled-table input[type="text"]:focus,
|
||||
.styled-table select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
form.inline-form {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.styled-table button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
font-size: 1rem;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
[data-theme="light"] .styled-table button {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.styled-table button[title="Save"] {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
#refresh-vendors {
|
||||
background: none;
|
||||
color: var(--accent);
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.styled-table button[onclick*="Delete"] {
|
||||
color: var(--error);
|
||||
background: none;
|
||||
}
|
||||
|
||||
.styled-table td form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stats-cards {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--cell-bg);
|
||||
border: 1px solid #666;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card.neutral {
|
||||
background-color: var(--card-bg);
|
||||
}
|
||||
|
||||
.event-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.event-list li {
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px dashed #666;
|
||||
}
|
||||
|
||||
.event-list.green li { color: #4caf50; }
|
||||
.event-list.red li { color: #ff4d4d; }
|
||||
|
||||
#mac-lookup-form input {
|
||||
padding: 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #999;
|
||||
width: 250px;
|
||||
color: var(--fg);
|
||||
background-color: var(--cell-bg);
|
||||
}
|
||||
|
||||
#mac-lookup-form button {
|
||||
padding: 6px 12px;
|
||||
margin-left: 10px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: var(--accent);
|
||||
color: black;
|
||||
}
|
||||
|
||||
.debug-output {
|
||||
background-color: #222;
|
||||
color: #b6fcd5;
|
||||
border: 1px solid #333;
|
||||
padding: 1em;
|
||||
font-size: 0.9rem;
|
||||
white-space: pre-wrap;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.stats-page .stats-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.stats-page .card {
|
||||
flex: 1;
|
||||
min-width: 45%;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
background-color: var(--card-bg);
|
||||
color: var(--fg);
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.stats-page .success-card {
|
||||
border-left: 6px solid limegreen !important;
|
||||
}
|
||||
|
||||
.stats-page .error-card {
|
||||
border-left: 6px solid crimson !important;
|
||||
}
|
||||
|
||||
.stats-page .fallback-card {
|
||||
border-left: 6px solid orange !important;
|
||||
}
|
||||
|
||||
.stats-page .styled-table.small-table td,
|
||||
.stats-page .styled-table.small-table th {
|
||||
padding: 6px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.stats-page form.inline-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stats-page form.inline-form select {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.stats-page form.inline-form button {
|
||||
flex: 0 0 auto;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pagination a,
|
||||
.pagination span.current-page {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
margin: 0 3px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 4px;
|
||||
color: var(--fg);
|
||||
background-color: transparent;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pagination span.current-page {
|
||||
font-weight: bold;
|
||||
background-color: var(--accent);
|
||||
color: black;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
background: var(--card-bg);
|
||||
color: var(--fg);
|
||||
padding: 2rem;
|
||||
border-radius: 10px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.modal-actions button,
|
||||
.modal-actions form button {
|
||||
padding: 0.5rem 1rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.modal-actions button {
|
||||
background-color: #ccc;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.modal-actions button.danger {
|
||||
background-color: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
left: 0; top: 0;
|
||||
width: 100%; height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--card-bg);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
color: var(--fg);
|
||||
width: 500px;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 0 15px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.user-list {
|
||||
margin-top: 1rem;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #555;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
background: var(--cell-bg);
|
||||
}
|
||||
|
||||
.flash-messages {
|
||||
margin: 1em 0;
|
||||
}
|
||||
.alert {
|
||||
padding: 1em;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.alert-success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
.alert-error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.auto-refresh-toggle {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid #666;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.auto-refresh-toggle label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auto-refresh-toggle input[type="checkbox"] {
|
||||
transform: scale(1.2);
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.auto-refresh-toggle #refresh-status {
|
||||
font-style: italic;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.controls-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
#stats-search {
|
||||
flex: 1 1 300px;
|
||||
max-width: 300px;
|
||||
margin-left: auto;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--accent);
|
||||
background-color: var(--cell-bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.controls-card {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem 2rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid #666;
|
||||
border-radius: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1 1 auto;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.auto-refresh-block select {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.search-block {
|
||||
flex-grow: 2;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.search-block input {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--accent);
|
||||
background-color: var(--cell-bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
170
app/templates/_stats_cards.html
Normal file
170
app/templates/_stats_cards.html
Normal file
@@ -0,0 +1,170 @@
|
||||
{# Partial for rendering all three stats cards with AJAX-aware pagination #}
|
||||
|
||||
<div class="card success-card">
|
||||
<h2>Recent Access-Accept</h2>
|
||||
<table class="styled-table small-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>MAC Address</th>
|
||||
<th>Description</th>
|
||||
<th>Vendor</th>
|
||||
<th>VLAN</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in accept_entries %}
|
||||
<tr>
|
||||
<td>{{ entry.mac_address }}</td>
|
||||
<td>{{ entry.description or '' }}</td>
|
||||
<td class="vendor-cell" data-mac="{{ entry.mac_address }}">{{ entry.vendor or '...' }}</td>
|
||||
<td>{{ entry.vlan_id or '?' }}</td>
|
||||
<td>{{ entry.ago }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if pagination_accept.pages|length > 1 %}
|
||||
<div class="pagination" data-type="accept">
|
||||
{% if pagination_accept.show_first %}
|
||||
<a href="#" data-page="1">1</a>
|
||||
{% endif %}
|
||||
{% if pagination_accept.show_prev %}
|
||||
<a href="#" data-page="{{ pagination_accept.prev_page }}">‹</a>
|
||||
{% endif %}
|
||||
{% for page in pagination_accept.pages %}
|
||||
{% if page == page_accept %}
|
||||
<span class="current-page">{{ page }}</span>
|
||||
{% else %}
|
||||
<a href="#" data-page="{{ page }}">{{ page }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if pagination_accept.show_next %}
|
||||
<a href="#" data-page="{{ pagination_accept.next_page }}">›</a>
|
||||
{% endif %}
|
||||
{% if pagination_accept.show_last %}
|
||||
<a href="#" data-page="{{ pagination_accept.last_page }}">{{ pagination_accept.last_page }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card error-card">
|
||||
<h2>Recent Access-Reject</h2>
|
||||
<table class="styled-table small-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>MAC Address</th>
|
||||
<th>Description</th>
|
||||
<th>Vendor</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in reject_entries %}
|
||||
<tr>
|
||||
<td>{{ entry.mac_address }}</td>
|
||||
<td>{{ entry.description or '' }}</td>
|
||||
<td class="vendor-cell" data-mac="{{ entry.mac_address }}">{{ entry.vendor or '...' }}</td>
|
||||
<td>{{ entry.ago }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if pagination_reject.pages|length > 1 %}
|
||||
<div class="pagination" data-type="reject">
|
||||
{% if pagination_reject.show_first %}
|
||||
<a href="#" data-page="1">1</a>
|
||||
{% endif %}
|
||||
{% if pagination_reject.show_prev %}
|
||||
<a href="#" data-page="{{ pagination_reject.prev_page }}">‹</a>
|
||||
{% endif %}
|
||||
{% for page in pagination_reject.pages %}
|
||||
{% if page == page_reject %}
|
||||
<span class="current-page">{{ page }}</span>
|
||||
{% else %}
|
||||
<a href="#" data-page="{{ page }}">{{ page }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if pagination_reject.show_next %}
|
||||
<a href="#" data-page="{{ pagination_reject.next_page }}">›</a>
|
||||
{% endif %}
|
||||
{% if pagination_reject.show_last %}
|
||||
<a href="#" data-page="{{ pagination_reject.last_page }}">{{ pagination_reject.last_page }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card fallback-card">
|
||||
<h2>Recent Access-Fallback</h2>
|
||||
<table class="styled-table small-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>MAC Address</th>
|
||||
<th>Description</th>
|
||||
<th>Vendor</th>
|
||||
<th>Time</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in fallback_entries %}
|
||||
<tr>
|
||||
<td>{{ entry.mac_address }}</td>
|
||||
<td>
|
||||
{% if not entry.already_exists %}
|
||||
<input type="text" name="description" value="{{ entry.description or '' }}" placeholder="Description (optional)" form="form-{{ loop.index }}">
|
||||
{% else %}
|
||||
{{ entry.description or '' }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="vendor-cell" data-mac="{{ entry.mac_address }}">{{ entry.vendor or '...' }}</td>
|
||||
<td>{{ entry.ago }}</td>
|
||||
<td>
|
||||
{% if not entry.already_exists %}
|
||||
<form method="POST" action="{{ url_for('stats.add') }}" class="inline-form" id="form-{{ loop.index }}">
|
||||
<input type="hidden" name="mac_address" value="{{ entry.mac_address }}">
|
||||
<select name="group_id" required>
|
||||
<option value="">Assign to VLAN</option>
|
||||
{% for group in available_groups %}
|
||||
<option value="{{ group.vlan_id }}">
|
||||
VLAN {{ group.vlan_id }}{% if group.description %} - {{ group.description }}{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" title="Add">💾</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span style="color: limegreen;">Already exists in VLAN {{ entry.existing_vlan or 'unknown' }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if pagination_fallback.pages|length > 1 %}
|
||||
<div class="pagination" data-type="fallback">
|
||||
{% if pagination_fallback.show_first %}
|
||||
<a href="#" data-page="1">1</a>
|
||||
{% endif %}
|
||||
{% if pagination_fallback.show_prev %}
|
||||
<a href="#" data-page="{{ pagination_fallback.prev_page }}">‹</a>
|
||||
{% endif %}
|
||||
{% for page in pagination_fallback.pages %}
|
||||
{% if page == page_fallback %}
|
||||
<span class="current-page">{{ page }}</span>
|
||||
{% else %}
|
||||
<a href="#" data-page="{{ page }}">{{ page }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if pagination_fallback.show_next %}
|
||||
<a href="#" data-page="{{ pagination_fallback.next_page }}">›</a>
|
||||
{% endif %}
|
||||
{% if pagination_fallback.show_last %}
|
||||
<a href="#" data-page="{{ pagination_fallback.last_page }}">{{ pagination_fallback.last_page }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Add User</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Add User</h1>
|
||||
<form method="POST">
|
||||
<label for="mac_address">MAC Address:</label>
|
||||
<input type="text" name="mac_address" required><br><br>
|
||||
<label for="vlan_id">VLAN ID:</label>
|
||||
<input type="text" name="vlan_id" required><br><br>
|
||||
<label for="description">Description:</label>
|
||||
<input type="text" name="description"><br><br>
|
||||
<input type="submit" value="Add User">
|
||||
</form>
|
||||
<a href="{{ url_for('user_list') }}">Back to User List</a>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,35 +1,70 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{% block title %}{% endblock %}</title>
|
||||
<style>
|
||||
nav {
|
||||
background-color: #f0f0f0;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
nav a {
|
||||
margin-right: 10px;
|
||||
text-decoration: none;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
nav a.active {
|
||||
background-color: #ddd;
|
||||
}
|
||||
</style>
|
||||
<meta charset="UTF-8" />
|
||||
<title>{% block title %}RadMac{% endblock %}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href="/" {% if request.path == '/' %}class="active"{% endif %}>Home</a>
|
||||
<a href="/user_list" {% if request.path == '/user_list' %}class="active"{% endif %}>User List</a>
|
||||
<a href="/groups" {% if request.path == '/groups' %}class="active"{% endif %}>Group List</a>
|
||||
<div class="links">
|
||||
<a href="{{ url_for('index_redirect') }}">Home</a>
|
||||
<a href="{{ url_for('user.user_list') }}">Users</a>
|
||||
<a href="{{ url_for('group.group_list') }}">Groups</a>
|
||||
<a href="{{ url_for('stats.stats_page') }}">Stats</a>
|
||||
<a href="{{ url_for('maintenance.maintenance_page') }}">Maintenance</a>
|
||||
</div>
|
||||
<div class="right">
|
||||
<button id="theme-toggle">🌓 Theme</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="content">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
<button id="scrollTopBtn" title="Back to top">⬆</button>
|
||||
|
||||
<script>
|
||||
// Theme toggle
|
||||
const toggleBtn = document.getElementById('theme-toggle');
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme) document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
const current = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||
const next = current === 'light' ? 'dark' : 'light';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
localStorage.setItem('theme', next);
|
||||
});
|
||||
|
||||
// Toast function
|
||||
window.showToast = function (msg, duration = 3000) {
|
||||
const toast = document.getElementById('toast');
|
||||
toast.textContent = msg;
|
||||
toast.classList.add('show');
|
||||
setTimeout(() => toast.classList.remove('show'), duration);
|
||||
};
|
||||
|
||||
// Scroll-to-top button
|
||||
const scrollBtn = document.getElementById('scrollTopBtn');
|
||||
window.onscroll = () => {
|
||||
scrollBtn.style.display = window.scrollY > 150 ? 'block' : 'none';
|
||||
};
|
||||
scrollBtn.onclick = () => window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
|
||||
// Preserve scroll position
|
||||
window.addEventListener('beforeunload', () => {
|
||||
sessionStorage.setItem('scrollTop', window.scrollY);
|
||||
});
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
const scrollTop = sessionStorage.getItem('scrollTop');
|
||||
if (scrollTop !== null) {
|
||||
window.scrollTo(0, parseInt(scrollTop));
|
||||
sessionStorage.removeItem('scrollTop');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,22 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Edit Attribute</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Edit Attribute</h1>
|
||||
|
||||
<form method="POST">
|
||||
<label for="attribute">Attribute:</label><br>
|
||||
<input type="text" id="attribute" name="attribute" value="{{ attribute_data.attribute }}"><br><br>
|
||||
|
||||
<label for="op">Op:</label><br>
|
||||
<input type="text" id="op" name="op" value="{{ attribute_data.op }}"><br><br>
|
||||
|
||||
<label for="value">Value:</label><br>
|
||||
<input type="text" id="value" name="value" value="{{ attribute_data.value }}"><br><br>
|
||||
|
||||
<input type="submit" value="Save Changes">
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,26 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Edit Group</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Edit Group: {{ group.id }}</h1>
|
||||
|
||||
<form method="POST">
|
||||
<label for="groupname">Group Name:</label><br>
|
||||
<input type="text" id="groupname" name="groupname" value="{{ group.groupname }}"><br><br>
|
||||
|
||||
<label for="attribute">Attribute:</label><br>
|
||||
<input type="text" id="attribute" name="attribute" value="{{ group.attribute }}"><br><br>
|
||||
|
||||
<label for="op">Op:</label><br>
|
||||
<input type="text" id="op" name="op" value="{{ group.op }}"><br><br>
|
||||
|
||||
<label for="value">Value:</label><br>
|
||||
<input type="text" id="value" name="value" value="{{ group.value }}"><br><br>
|
||||
|
||||
<input type="submit" value="Save Changes">
|
||||
</form>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,16 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Edit Group Name</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Edit Group Name: {{ old_groupname }}</h1>
|
||||
|
||||
<form method="POST">
|
||||
<label for="groupname">New Group Name:</label><br>
|
||||
<input type="text" id="groupname" name="groupname" value="{{ old_groupname }}"><br><br>
|
||||
|
||||
<input type="submit" value="Save Changes">
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,20 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Edit User</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Edit User: {{ user.mac_address }}</h1>
|
||||
|
||||
<form method="POST">
|
||||
<label for="description">Description:</label><br>
|
||||
<input type="text" id="description" name="description" value="{{ user.description }}"><br><br>
|
||||
|
||||
<label for="vlan_id">VLAN ID:</label><br>
|
||||
<input type="text" id="vlan_id" name="vlan_id" value="{{ user.vlan_id }}"><br><br>
|
||||
|
||||
<input type="submit" value="Save Changes">
|
||||
</form>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,38 +1,118 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Group List</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Group List</h1>
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}VLAN Groups{% endblock %}
|
||||
|
||||
<table border="1">
|
||||
{% block content %}
|
||||
<h1 class="page-title">VLAN Groups</h1>
|
||||
|
||||
<form method="POST" action="{{ url_for('group.add_group_route') }}" style="margin-bottom: 1rem;">
|
||||
<input type="text" name="vlan_id" placeholder="VLAN ID" required pattern="[0-9]+" style="width: 80px;">
|
||||
<input type="text" name="description" placeholder="Group Description">
|
||||
<button type="submit">➕ Add Group</button>
|
||||
</form>
|
||||
|
||||
<table class="styled-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Group Name</th>
|
||||
<th>Attribute</th>
|
||||
<th>Op</th>
|
||||
<th>Value</th>
|
||||
<th>VLAN ID</th>
|
||||
<th>Description</th>
|
||||
<th>User Count</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for group in results %}
|
||||
{% for group in available_groups %}
|
||||
<tr>
|
||||
<td>{{ group.id }}</td>
|
||||
<td>{{ group.groupname }}</td>
|
||||
<td>{{ group.attribute }}</td>
|
||||
<td>{{ group.op }}</td>
|
||||
<td>{{ group.value }}</td>
|
||||
<form method="POST" action="{{ url_for('group.update_description_route') }}" class="preserve-scroll">
|
||||
<input type="hidden" name="group_id" value="{{ group.vlan_id }}">
|
||||
<td>{{ group.vlan_id }}</td>
|
||||
<td>
|
||||
<a href="/edit_group/{{ group.id }}">Edit</a> |
|
||||
<a href="/delete_group/{{ group.id }}">Delete</a>
|
||||
<input type="text" name="description" value="{{ group.description or '' }}" class="description-input">
|
||||
</td>
|
||||
<td>{{ group.user_count }}</td>
|
||||
<td>
|
||||
<button type="submit" title="Save">💾</button>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="{{ url_for('group.delete_group_route_handler') }}" class="preserve-scroll delete-group-form" data-user-count="{{ group.user_count }}" style="display:inline;">
|
||||
<input type="hidden" name="group_id" value="{{ group.vlan_id }}">
|
||||
<button type="submit">❌</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
<!-- Modal for confirm delete -->
|
||||
<div id="confirmModal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<p>This group has users assigned. What would you like to do?</p>
|
||||
<div id="userList" class="user-list"></div>
|
||||
<div class="modal-actions">
|
||||
<button onclick="closeModal()">Cancel</button>
|
||||
<form id="confirmDeleteForm" method="POST" action="{{ url_for('group.delete_group_route_handler') }}">
|
||||
<input type="hidden" name="group_id" id="modalGroupId">
|
||||
<input type="hidden" name="force_delete" value="true">
|
||||
<button type="submit" class="danger">Delete Group and Users</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('form.preserve-scroll').forEach(form => {
|
||||
form.addEventListener('submit', () => {
|
||||
localStorage.setItem('scrollY', window.scrollY);
|
||||
});
|
||||
});
|
||||
window.addEventListener('load', () => {
|
||||
const scrollY = localStorage.getItem('scrollY');
|
||||
if (scrollY) {
|
||||
window.scrollTo(0, parseInt(scrollY));
|
||||
localStorage.removeItem('scrollY');
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('.delete-group-form').forEach(form => {
|
||||
form.addEventListener('submit', function (e) {
|
||||
const userCount = parseInt(this.dataset.userCount);
|
||||
const groupId = this.querySelector('[name="group_id"]').value;
|
||||
|
||||
if (userCount > 0) {
|
||||
e.preventDefault();
|
||||
fetch('{{ url_for("group.get_users_for_group") }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ vlan_id: groupId })
|
||||
})
|
||||
.then(resp => resp.json())
|
||||
.then(users => {
|
||||
const userListDiv = document.getElementById('userList');
|
||||
userListDiv.innerHTML = '';
|
||||
|
||||
if (users.length > 0) {
|
||||
const list = document.createElement('ul');
|
||||
users.forEach(user => {
|
||||
const item = document.createElement('li');
|
||||
item.textContent = `${user.mac_address} — ${user.description || 'No description'}`;
|
||||
list.appendChild(item);
|
||||
});
|
||||
userListDiv.appendChild(list);
|
||||
} else {
|
||||
userListDiv.textContent = 'No users found in this group.';
|
||||
}
|
||||
|
||||
document.getElementById('modalGroupId').value = groupId;
|
||||
document.getElementById('confirmModal').style.display = 'flex';
|
||||
});
|
||||
} else {
|
||||
if (!confirm('Delete this group?')) e.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('confirmModal').style.display = 'none';
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,364 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Group List{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Group List</h1>
|
||||
|
||||
{% for groupname, attributes in grouped_results.items() %}
|
||||
<table border="1">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Group Name</th>
|
||||
<th>Attributes</th>
|
||||
<th>Op</th>
|
||||
<th>Value</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<input type="text" id="groupname-{{ groupname }}" value="{{ groupname }}">
|
||||
</td>
|
||||
<td colspan="3" class="merged-cell">
|
||||
<button onclick="addRow('{{ groupname }}')">➕</button>
|
||||
</td>
|
||||
<td>
|
||||
<button onclick="updateGroupName('{{ groupname }}')">✅ Rename Group</button>
|
||||
<button onclick="location.reload()">❌</button>
|
||||
<a href="/delete_group_rows/{{ groupname }}" onclick="saveScrollPosition()">🗑️</a>
|
||||
<button onclick="duplicateGroup('{{ groupname }}')">Duplicate</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% for attribute in attributes %}
|
||||
<tr>
|
||||
<td class="merged-cell"></td>
|
||||
<td><input type="text" id="attribute-{{ attribute.id }}" value="{{ attribute.attribute }}"></td>
|
||||
<td>
|
||||
<select id="op-{{ attribute.id }}">
|
||||
<option value="=" ${attribute.op === '=' ? 'selected' : ''}>=</option>
|
||||
<option value="!=" ${attribute.op === '!=' ? 'selected' : ''}>!=</option>
|
||||
<option value=">" ${attribute.op === '>' ? 'selected' : ''}>></option>
|
||||
<option value="<" ${attribute.op === '<' ? 'selected' : ''}><</option>
|
||||
<option value=">=" ${attribute.op === '>=' ? 'selected' : ''}>>=</option>
|
||||
<option value="<=" ${attribute.op === '<=' ? 'selected' : ''}><=</option>
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="text" id="value-{{ attribute.id }}" value="{{ attribute.value }}"></td>
|
||||
<td>
|
||||
<button onclick="updateAttribute('{{ attribute.id }}')">✅</button>
|
||||
<button onclick="location.reload()">❌</button>
|
||||
<a href="/delete_group/{{ attribute.id }}" onclick="saveScrollPosition()">🗑️</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endfor %}
|
||||
|
||||
<table border="1">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Group Name</th>
|
||||
<th>Attributes</th>
|
||||
<th>Op</th>
|
||||
<th>Value</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<input type="text" id="new-groupname" value="">
|
||||
</td>
|
||||
<td colspan="3" class="merged-cell"></td>
|
||||
<td>
|
||||
<button onclick="addNewGroup()">Add New Group</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<dialog id="duplicate-dialog">
|
||||
<div id="duplicate-dialog-content"></div>
|
||||
<button id="close-dialog">❌</button>
|
||||
<button id="save-duplicated-group">Save</button>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.merged-cell {
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function updateAttribute(attributeId) {
|
||||
const attribute = document.getElementById(`attribute-${attributeId}`).value;
|
||||
const op = document.getElementById(`op-${attributeId}`).value;
|
||||
const value = document.getElementById(`value-${attributeId}`).value;
|
||||
|
||||
fetch('/update_attribute', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: `attributeId=${attributeId}&attribute=${attribute}&op=${op}&value=${value}`
|
||||
})
|
||||
.then(response => response.text())
|
||||
.then(data => {
|
||||
if (data === 'success') {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error updating attribute: ' + data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateGroupName(oldGroupName) {
|
||||
const newGroupName = document.getElementById(`groupname-${oldGroupName}`).value;
|
||||
|
||||
fetch('/update_group_name', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: `oldGroupName=${oldGroupName}&newGroupName=${newGroupName}`
|
||||
})
|
||||
.then(response => response.text())
|
||||
.then(data => {
|
||||
if (data === 'success') {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error updating group name: ' + data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function addRow(groupName) {
|
||||
const table = event.target.closest('table').querySelector('tbody');
|
||||
const newRow = table.insertRow(table.rows.length);
|
||||
|
||||
const cell1 = newRow.insertCell(0);
|
||||
const cell2 = newRow.insertCell(1);
|
||||
const cell3 = newRow.insertCell(2);
|
||||
const cell4 = newRow.insertCell(3);
|
||||
const cell5 = newRow.insertCell(4);
|
||||
|
||||
cell1.classList.add('merged-cell');
|
||||
cell2.innerHTML = '<input type="text" id="new-attribute" value="">';
|
||||
cell3.innerHTML = `
|
||||
<select id="new-op">
|
||||
<option value="=">=</option>
|
||||
<option value="!=">!=</option>
|
||||
<option value=">">></option>
|
||||
<option value="<"><</option>
|
||||
<option value=">=">>=</option>
|
||||
<option value="<="><=</option>
|
||||
</select>
|
||||
`;
|
||||
cell4.innerHTML = '<input type="text" id="new-value" value="">';
|
||||
cell5.innerHTML = '<button onclick="saveNewRow(\'' + groupName + '\', this)">✅</button> <button onclick="removeRow(this)">❌</button>';
|
||||
}
|
||||
|
||||
function saveNewRow(groupName, button) {
|
||||
const row = button.parentNode.parentNode;
|
||||
const attribute = row.querySelector('#new-attribute').value;
|
||||
const op = row.querySelector('#new-op').value;
|
||||
const value = row.querySelector('#new-value').value;
|
||||
|
||||
fetch('/add_attribute', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: `groupname=${groupName}&attribute=${attribute}&op=${op}&value=${value}`
|
||||
})
|
||||
.then(response => response.text())
|
||||
.then(data => {
|
||||
if (data === 'success') {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error adding attribute: ' + data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function removeRow(button) {
|
||||
const row = button.parentNode.parentNode;
|
||||
row.parentNode.removeChild(row);
|
||||
}
|
||||
|
||||
function addNewGroup() {
|
||||
const newGroupName = document.getElementById('new-groupname').value;
|
||||
|
||||
fetch('/add_group', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: `groupname=${newGroupName}`
|
||||
})
|
||||
.then(response => response.text())
|
||||
.then(data => {
|
||||
if (data === 'success') {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error adding group: ' + data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function saveScrollPosition() {
|
||||
sessionStorage.setItem('scrollPosition', window.scrollY);
|
||||
}
|
||||
|
||||
window.onload = function() {
|
||||
const scrollPosition = sessionStorage.getItem('scrollPosition');
|
||||
if (scrollPosition) {
|
||||
window.scrollTo(0, scrollPosition);
|
||||
sessionStorage.removeItem('scrollPosition');
|
||||
}
|
||||
}
|
||||
|
||||
function duplicateGroup(groupName) {
|
||||
fetch('/duplicate_group', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: `groupname=${groupName}`
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const newGroupName = 'Copy of ' + groupName;
|
||||
let newTable = `<table border="1">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Group Name</th>
|
||||
<th>Attributes</th>
|
||||
<th>Op</th>
|
||||
<th>Value</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<input type="text" id="new-groupname" value="${newGroupName}">
|
||||
</td>
|
||||
<td colspan="3" class="merged-cell"></td>
|
||||
<td></td>
|
||||
</tr>`;
|
||||
|
||||
data.forEach((attribute, index) => {
|
||||
newTable += `<tr>
|
||||
<td class="merged-cell"></td>
|
||||
<td><input type="text" class="new-attribute" value="${attribute.attribute}"></td>
|
||||
<td>
|
||||
<select class="new-op">
|
||||
<option value="=" ${attribute.op === '=' ? 'selected' : ''}>=</option>
|
||||
<option value="!=" ${attribute.op === '!=' ? 'selected' : ''}>!=</option>
|
||||
<option value=">" ${attribute.op === '>' ? 'selected' : ''}>></option>
|
||||
<option value="<" ${attribute.op === '<' ? 'selected' : ''}><</option>
|
||||
<option value=">=" ${attribute.op === '>=' ? 'selected' : ''}>>=</option>
|
||||
<option value="<=" ${attribute.op === '<=' ? 'selected' : ''}><=</option>
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="text" class="new-value" value="${attribute.value}"></td>
|
||||
<td><button onclick="removeDuplicatedRow(this)">🗑️</button></td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
newTable += `<tr>
|
||||
<td class="merged-cell"></td>
|
||||
<td colspan="3">
|
||||
<button onclick="addDuplicatedRow()">➕</button>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr></tbody></table>`;
|
||||
|
||||
document.getElementById('duplicate-dialog-content').innerHTML = newTable;
|
||||
document.getElementById('duplicate-dialog').showModal();
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('close-dialog').addEventListener('click', () => {
|
||||
document.getElementById('duplicate-dialog').close();
|
||||
});
|
||||
|
||||
document.getElementById('save-duplicated-group').addEventListener('click', () => {
|
||||
saveDuplicatedGroup();
|
||||
});
|
||||
|
||||
function saveDuplicatedGroup() {
|
||||
let rows = document.querySelectorAll('#duplicate-dialog-content table tbody tr');
|
||||
let groupname = rows[0].querySelector('#new-groupname').value;
|
||||
let attributes = [];
|
||||
for (let i = 1; i < rows.length - 1; i++) {
|
||||
const attributeInput = rows[i].querySelector(`.new-attribute`);
|
||||
const opInput = rows[i].querySelector(`.new-op`);
|
||||
const valueInput = rows[i].querySelector(`.new-value`);
|
||||
|
||||
if (attributeInput && opInput && valueInput) {
|
||||
attributes.push({
|
||||
attribute: attributeInput.value,
|
||||
op: opInput.value,
|
||||
value: valueInput.value
|
||||
});
|
||||
} else {
|
||||
console.warn(`Input elements not found for row ${i}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
fetch('/save_duplicated_group', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ groupname: groupname, attributes: attributes })
|
||||
})
|
||||
.then(response => response.text())
|
||||
.then(data => {
|
||||
if (data === 'success') {
|
||||
document.getElementById('duplicate-dialog').close();
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error saving duplicated group: ' + data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function addDuplicatedRow() {
|
||||
const table = document.querySelector('#duplicate-dialog-content table tbody');
|
||||
const newRow = table.insertRow(table.rows.length - 1);
|
||||
|
||||
const cell1 = newRow.insertCell(0);
|
||||
const cell2 = newRow.insertCell(1);
|
||||
const cell3 = newRow.insertCell(2);
|
||||
const cell4 = newRow.insertCell(3);
|
||||
const cell5 = newRow.insertCell(4);
|
||||
|
||||
cell1.classList.add('merged-cell');
|
||||
cell2.innerHTML = `<input type="text" class="new-attribute" value="">`;
|
||||
cell3.innerHTML = `
|
||||
<select class="new-op">
|
||||
<option value="=">=</option>
|
||||
<option value="!=">!=</option>
|
||||
<option value=">">></option>
|
||||
<option value="<"><</option>
|
||||
<option value=">=">>=</option>
|
||||
<option value="<="><=</option>
|
||||
</select>
|
||||
`;
|
||||
cell4.innerHTML = `<input type="text" class="new-value" value="">`;
|
||||
cell5.innerHTML = `<button onclick="removeDuplicatedRow(this)">🗑️</button>`;
|
||||
}
|
||||
|
||||
function removeDuplicatedRow(button) {
|
||||
const row = button.parentNode.parentNode;
|
||||
row.parentNode.removeChild(row);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,43 +1,70 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}FreeRADIUS Manager{% endblock %}
|
||||
{% block title %}RadMac{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>FreeRADIUS Manager</h1>
|
||||
<h1 class="page-title">RadMac</h1>
|
||||
|
||||
<h2>Statistics:</h2>
|
||||
<p>Total Users: {{ total_users }}</p>
|
||||
<p>Total Groups: {{ total_groups }}</p>
|
||||
<div class="stats-cards">
|
||||
<div class="card neutral">
|
||||
<strong>Total MAC Addresses</strong>
|
||||
<p>{{ total_users }}</p>
|
||||
</div>
|
||||
<div class="card neutral">
|
||||
<strong>Total VLAN Groups</strong>
|
||||
<p>{{ total_groups }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>SQL Query Tool:</h2>
|
||||
<form method="POST" action="/sql">
|
||||
<textarea name="query" rows="5" cols="50"></textarea><br>
|
||||
<button type="submit">Execute Query</button>
|
||||
</form>
|
||||
|
||||
{% if sql_results %}
|
||||
<h2>Query Results:</h2>
|
||||
<table border="1">
|
||||
<thead>
|
||||
<tr>
|
||||
{% for key in sql_results[0].keys() %}
|
||||
<th>{{ key }}</th>
|
||||
<h2>Recent Access Logs</h2>
|
||||
<ul class="event-list">
|
||||
<li><strong>Access-Accept Logs</strong></li>
|
||||
{% for log in latest_accept %}
|
||||
<li>
|
||||
<strong>{{ log.mac_address }}</strong> - {{ log.reply }}
|
||||
<br>
|
||||
<small>{{ log.timestamp }} - {{ log.result }}</small>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in sql_results %}
|
||||
<tr>
|
||||
{% for value in row.values() %}
|
||||
<td>{{ value }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if sql_error %}
|
||||
<p style="color: red;">{{ sql_error }}</p>
|
||||
{% endif %}
|
||||
<li><strong>Access-Reject Logs</strong></li>
|
||||
{% for log in latest_reject %}
|
||||
<li>
|
||||
<strong>{{ log.mac_address }}</strong> - {{ log.reply }}
|
||||
<br>
|
||||
<small>{{ log.timestamp }} - {{ log.result }}</small>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<h2>MAC Vendor Lookup</h2>
|
||||
<form id="mac-lookup-form" method="POST" action="/lookup_mac">
|
||||
<input type="text" name="mac" id="mac-input" placeholder="Enter MAC address" required>
|
||||
<button type="submit">🔍 Lookup</button>
|
||||
</form>
|
||||
|
||||
<pre id="mac-result" class="debug-output"></pre>
|
||||
|
||||
<script>
|
||||
document.getElementById('mac-lookup-form').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const data = new URLSearchParams(new FormData(form));
|
||||
const resultBox = document.getElementById('mac-result');
|
||||
resultBox.textContent = "Querying...";
|
||||
|
||||
fetch(form.action, {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
// Update: Use 'output' from the API response
|
||||
resultBox.textContent = data.output || "No data returned.";
|
||||
})
|
||||
.catch(err => {
|
||||
resultBox.textContent = `Error: ${err}`;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
93
app/templates/maintenance.html
Normal file
93
app/templates/maintenance.html
Normal file
@@ -0,0 +1,93 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Maintenance{% endblock %}
|
||||
{% block content %}
|
||||
<div class="maintenance-page">
|
||||
<h1>Database Maintenance</h1>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flash-messages">
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="section">
|
||||
<div class="card neutral">
|
||||
<div class="card-header">Database Overview</div>
|
||||
<div class="card-body">
|
||||
<table class="styled-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Database Size</th>
|
||||
<td>{{ db_stats.total_size_mb }} MB</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>auth_logs Rows</th>
|
||||
<td>{{ db_stats.auth_logs_count }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>users Rows</th>
|
||||
<td>{{ db_stats.users_count }}</td>
|
||||
</tr>
|
||||
{% if table_stats %}
|
||||
{% for table, row_count in table_stats.items() %}
|
||||
{% if table != 'auth_logs' and table != 'users' %}
|
||||
<tr>
|
||||
<th>{{ table }} Rows</th>
|
||||
<td>{{ row_count }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="card">
|
||||
<div class="card-header">Clear auth_logs Table</div>
|
||||
<div class="card-body">
|
||||
<p>Permanently remove all rows from the <code>auth_logs</code> table. This action cannot be undone.</p>
|
||||
<form action="/maintenance/clear_auth_logs" method="post">
|
||||
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to clear all authentication logs? This action is irreversible!')">
|
||||
Clear Logs
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="card">
|
||||
<div class="card-header">Backup Database</div>
|
||||
<div class="card-body">
|
||||
<p>Dump the current SQL database to a downloadable file.</p>
|
||||
<p class="alert-error" style="margin: 1rem 0;">Warning: Backup size can be large if <code>auth_logs</code> has not been cleared.</p>
|
||||
<form action="/maintenance/backup_database" method="get">
|
||||
<button type="submit" class="btn">Backup Database</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="card">
|
||||
<div class="card-header">Restore Database</div>
|
||||
<div class="card-body">
|
||||
<p>Restore the SQL database from a previously exported file. This will overwrite all current data.</p>
|
||||
<form action="/maintenance/restore_database" method="post" enctype="multipart/form-data">
|
||||
<input type="file" name="file" accept=".sql" required>
|
||||
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to restore the database from this file? This will OVERWRITE the current database.')">
|
||||
Restore Database
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
163
app/templates/stats.html
Normal file
163
app/templates/stats.html
Normal file
@@ -0,0 +1,163 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Authentication Stats{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="stats-page">
|
||||
<h1 class="page-title">Authentication Stats</h1>
|
||||
|
||||
<div class="controls-card">
|
||||
<div class="control-group">
|
||||
<label for="time_range">Time Range:</label>
|
||||
<select name="time_range" id="time_range">
|
||||
<option value="last_minute">Last 1 Minute</option>
|
||||
<option value="last_5_minutes">Last 5 Minutes</option>
|
||||
<option value="last_10_minutes">Last 10 Minutes</option>
|
||||
<option value="last_hour">Last Hour</option>
|
||||
<option value="last_6_hours">Last 6 Hours</option>
|
||||
<option value="last_12_hours">Last 12 Hours</option>
|
||||
<option value="last_day">Last Day</option>
|
||||
<option value="last_30_days">Last 30 Days</option>
|
||||
<option value="all">All Time</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label for="per_page">Entries per page:</label>
|
||||
<select name="per_page" id="per_page">
|
||||
<option value="5">5</option>
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="control-group auto-refresh-block">
|
||||
<label>
|
||||
<input type="checkbox" id="auto-refresh-checkbox"> Auto-refresh
|
||||
</label>
|
||||
<select id="refresh-interval">
|
||||
<option value="15000">15s</option>
|
||||
<option value="30000" selected>30s</option>
|
||||
<option value="60000">1 min</option>
|
||||
<option value="300000">5 min</option>
|
||||
</select>
|
||||
<span id="refresh-status"></span>
|
||||
</div>
|
||||
|
||||
<div class="control-group search-block">
|
||||
<input type="text" id="stats-search" placeholder="Search MAC, vendor, VLAN, description">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="stats-root" class="stats-container">
|
||||
{% include '_stats_cards.html' %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const statsRoot = document.getElementById('stats-root');
|
||||
const timeRangeSelect = document.getElementById('time_range');
|
||||
const perPageSelect = document.getElementById('per_page');
|
||||
const searchInput = document.getElementById('stats-search');
|
||||
const refreshCheckbox = document.getElementById('auto-refresh-checkbox');
|
||||
const refreshInterval = document.getElementById('refresh-interval');
|
||||
const refreshStatus = document.getElementById('refresh-status');
|
||||
|
||||
let intervalId = null;
|
||||
let currentPageAccept = 1;
|
||||
let currentPageReject = 1;
|
||||
let currentPageFallback = 1;
|
||||
|
||||
function setInitialSelectValuesFromURL() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const time = urlParams.get('time_range');
|
||||
const page = urlParams.get('per_page');
|
||||
if (time) timeRangeSelect.value = time;
|
||||
if (page) perPageSelect.value = page;
|
||||
}
|
||||
|
||||
async function fetchStatsData() {
|
||||
try {
|
||||
const timeRange = timeRangeSelect.value;
|
||||
const perPage = perPageSelect.value;
|
||||
const params = new URLSearchParams({
|
||||
time_range: timeRange,
|
||||
per_page: perPage,
|
||||
page_accept: currentPageAccept,
|
||||
page_reject: currentPageReject,
|
||||
page_fallback: currentPageFallback
|
||||
});
|
||||
|
||||
const response = await fetch(`/stats/fetch_stats_data?${params}`);
|
||||
const html = await response.text();
|
||||
statsRoot.innerHTML = html;
|
||||
filterRows();
|
||||
attachPaginationHandlers();
|
||||
} catch (err) {
|
||||
console.error('Error fetching stats data:', err);
|
||||
refreshStatus.textContent = 'Error loading stats data.';
|
||||
}
|
||||
}
|
||||
|
||||
function startAutoRefresh() {
|
||||
refreshStatus.textContent = `Refreshing every ${refreshInterval.selectedOptions[0].text}`;
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
intervalId = setInterval(fetchStatsData, parseInt(refreshInterval.value));
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
refreshStatus.textContent = "Auto-refresh disabled";
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
}
|
||||
|
||||
function filterRows() {
|
||||
const query = searchInput.value.toLowerCase();
|
||||
document.querySelectorAll('.styled-table tbody tr').forEach(row => {
|
||||
row.style.display = row.textContent.toLowerCase().includes(query) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function attachPaginationHandlers() {
|
||||
document.querySelectorAll('.pagination').forEach(pagination => {
|
||||
const type = pagination.getAttribute('data-type');
|
||||
pagination.querySelectorAll('a[data-page]').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const page = parseInt(link.getAttribute('data-page'));
|
||||
if (type === 'accept') currentPageAccept = page;
|
||||
else if (type === 'reject') currentPageReject = page;
|
||||
else if (type === 'fallback') currentPageFallback = page;
|
||||
fetchStatsData();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initial setup
|
||||
setInitialSelectValuesFromURL();
|
||||
fetchStatsData();
|
||||
|
||||
timeRangeSelect.addEventListener('change', () => {
|
||||
currentPageAccept = currentPageReject = currentPageFallback = 1;
|
||||
fetchStatsData();
|
||||
});
|
||||
|
||||
perPageSelect.addEventListener('change', () => {
|
||||
currentPageAccept = currentPageReject = currentPageFallback = 1;
|
||||
fetchStatsData();
|
||||
});
|
||||
|
||||
refreshCheckbox.addEventListener('change', () => {
|
||||
refreshCheckbox.checked ? startAutoRefresh() : stopAutoRefresh();
|
||||
});
|
||||
|
||||
refreshInterval.addEventListener('change', () => {
|
||||
if (refreshCheckbox.checked) startAutoRefresh();
|
||||
});
|
||||
|
||||
searchInput.addEventListener('input', filterRows);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,364 +1,78 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}User List{% endblock %}
|
||||
{% block title %}MAC Address List{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>User List</h1>
|
||||
<h1 class="page-title">MAC Address List</h1>
|
||||
|
||||
{% for username, attributes in grouped_users.items() %}
|
||||
<table border="1">
|
||||
<form id="add-user-form" method="POST" action="{{ url_for('user.add') }}">
|
||||
<input type="text" name="mac_address" placeholder="MAC address (12 hex)" required maxlength="12">
|
||||
<input type="text" name="description" placeholder="Description (optional)">
|
||||
<select name="group_id" required>
|
||||
<option value="">Assign to VLAN</option>
|
||||
{% for group in available_groups %}
|
||||
<option value="{{ group.vlan_id }}">VLAN {{ group.vlan_id }}{% if group.description %} - {{ group.description }}{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit">➕ Add</button>
|
||||
</form>
|
||||
|
||||
<table class="styled-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User Name</th>
|
||||
<th>Attributes</th>
|
||||
<th>Op</th>
|
||||
<th>Value</th>
|
||||
<th>MAC Address</th>
|
||||
<th>Description</th>
|
||||
<th>Vendor <button id="refresh-vendors" title="Refresh unknown vendors">🔄</button></th>
|
||||
<th>VLAN</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in users %}
|
||||
<tr>
|
||||
<td>{{ entry.mac_address }}</td>
|
||||
|
||||
<form method="POST" action="{{ url_for('user.update_user_route') }}">
|
||||
<input type="hidden" name="mac_address" value="{{ entry.mac_address }}">
|
||||
|
||||
<td>
|
||||
<input type="text" id="username-{{ username }}" value="{{ username }}">
|
||||
</td>
|
||||
<td colspan="3" class="merged-cell">
|
||||
<button onclick="addUserRow('{{ username }}')">➕</button>
|
||||
<input type="text" name="description" value="{{ entry.description or '' }}">
|
||||
</td>
|
||||
|
||||
<td>{{ entry.vendor or "..." }}</td>
|
||||
|
||||
<td>
|
||||
<button onclick="updateUserName('{{ username }}')">✅ Rename User</button>
|
||||
<button onclick="location.reload()">❌</button>
|
||||
<a href="/delete_user_rows/{{ username }}" onclick="saveScrollPosition()">🗑️</a>
|
||||
<button onclick="duplicateUser('{{ username }}')">Duplicate</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% for attribute in attributes %}
|
||||
<tr>
|
||||
<td class="merged-cell"></td>
|
||||
<td><input type="text" id="attribute-{{ attribute.id }}" value="{{ attribute.attribute }}"></td>
|
||||
<td>
|
||||
<select id="op-{{ attribute.id }}">
|
||||
<option value="=" ${attribute.op === '=' ? 'selected' : ''}>=</option>
|
||||
<option value="!=" ${attribute.op === '!=' ? 'selected' : ''}>!=</option>
|
||||
<option value=">" ${attribute.op === '>' ? 'selected' : ''}>></option>
|
||||
<option value="<" ${attribute.op === '<' ? 'selected' : ''}><</option>
|
||||
<option value=">=" ${attribute.op === '>=' ? 'selected' : ''}>>=</option>
|
||||
<option value="<=" ${attribute.op === '<=' ? 'selected' : ''}><=</option>
|
||||
<select name="group_id">
|
||||
{% for group in available_groups %}
|
||||
<option value="{{ group.vlan_id }}" {% if group.vlan_id == entry.vlan_id %}selected{% endif %}>
|
||||
VLAN {{ group.vlan_id }}{% if group.description %} - {{ group.description }}{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="text" id="value-{{ attribute.id }}" value="{{ attribute.value }}"></td>
|
||||
|
||||
<td>
|
||||
<button onclick="updateAttribute('{{ attribute.id }}')">✅</button>
|
||||
<button onclick="location.reload()">❌</button>
|
||||
<a href="/delete_user/{{ attribute.id }}" onclick="saveScrollPosition()">🗑️</a>
|
||||
<button type="submit" title="Save">💾</button>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="{{ url_for('user.delete') }}" style="display:inline;">
|
||||
<input type="hidden" name="mac_address" value="{{ entry.mac_address }}">
|
||||
<button type="submit" onclick="return confirm('Delete this MAC address?')">❌</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
<table border="1">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User Name</th>
|
||||
<th>Attributes</th>
|
||||
<th>Op</th>
|
||||
<th>Value</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<input type="text" id="new-username" value="">
|
||||
</td>
|
||||
<td colspan="3" class="merged-cell"></td>
|
||||
<td>
|
||||
<button onclick="addNewUser()">Add New User</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<dialog id="duplicate-dialog">
|
||||
<div id="duplicate-dialog-content"></div>
|
||||
<button id="close-dialog">❌</button>
|
||||
<button id="save-duplicated-user">Save</button>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.merged-cell {
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function updateAttribute(attributeId) {
|
||||
const attribute = document.getElementById(`attribute-${attributeId}`).value;
|
||||
const op = document.getElementById(`op-${attributeId}`).value;
|
||||
const value = document.getElementById(`value-${attributeId}`).value;
|
||||
|
||||
fetch('/update_user_attribute', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: `attributeId=${attributeId}&attribute=${attribute}&op=${op}&value=${value}`
|
||||
})
|
||||
.then(response => response.text())
|
||||
<script>
|
||||
document.getElementById('refresh-vendors').addEventListener('click', function () {
|
||||
fetch("{{ url_for('user.refresh') }}", { method: "POST" })
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data === 'success') {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error updating attribute: ' + data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateUserName(oldUserName) {
|
||||
const newUserName = document.getElementById(`username-${oldUserName}`).value;
|
||||
|
||||
fetch('/update_user_name', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: `oldUserName=${oldUserName}&newUserName=${newUserName}`
|
||||
window.showToast("Vendor refresh complete.");
|
||||
window.location.reload();
|
||||
})
|
||||
.then(response => response.text())
|
||||
.then(data => {
|
||||
if (data === 'success') {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error updating user name: ' + data);
|
||||
}
|
||||
.catch(err => alert("Error: " + err));
|
||||
});
|
||||
}
|
||||
|
||||
function addUserRow(userName) {
|
||||
const table = event.target.closest('table').querySelector('tbody');
|
||||
const newRow = table.insertRow(table.rows.length);
|
||||
|
||||
const cell1 = newRow.insertCell(0);
|
||||
const cell2 = newRow.insertCell(1);
|
||||
const cell3 = newRow.insertCell(2);
|
||||
const cell4 = newRow.insertCell(3);
|
||||
const cell5 = newRow.insertCell(4);
|
||||
|
||||
cell1.classList.add('merged-cell');
|
||||
cell2.innerHTML = '<input type="text" id="new-attribute" value="">';
|
||||
cell3.innerHTML = `
|
||||
<select id="new-op">
|
||||
<option value="=">=</option>
|
||||
<option value="!=">!=</option>
|
||||
<option value=">">></option>
|
||||
<option value="<"><</option>
|
||||
<option value=">=">>=</option>
|
||||
<option value="<="><=</option>
|
||||
</select>
|
||||
`;
|
||||
cell4.innerHTML = '<input type="text" id="new-value" value="">';
|
||||
cell5.innerHTML = '<button onclick="saveNewUserRow(\'' + userName + '\', this)">✅</button> <button onclick="removeUserRow(this)">❌</button>';
|
||||
}
|
||||
|
||||
function saveNewUserRow(userName, button) {
|
||||
const row = button.parentNode.parentNode;
|
||||
const attribute = row.querySelector('#new-attribute').value;
|
||||
const op = row.querySelector('#new-op').value;
|
||||
const value = row.querySelector('#new-value').value;
|
||||
|
||||
fetch('/add_user_attribute', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: `username=${userName}&attribute=${attribute}&op=${op}&value=${value}`
|
||||
})
|
||||
.then(response => response.text())
|
||||
.then(data => {
|
||||
if (data === 'success') {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error adding attribute: ' + data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function removeUserRow(button) {
|
||||
const row = button.parentNode.parentNode;
|
||||
row.parentNode.removeChild(row);
|
||||
}
|
||||
|
||||
function addNewUser() {
|
||||
const newUserName = document.getElementById('new-username').value;
|
||||
|
||||
fetch('/add_user', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: `username=${newUserName}`
|
||||
})
|
||||
.then(response => response.text())
|
||||
.then(data => {
|
||||
if (data === 'success') {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error adding user: ' + data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function saveScrollPosition() {
|
||||
sessionStorage.setItem('scrollPosition', window.scrollY);
|
||||
}
|
||||
|
||||
window.onload = function() {
|
||||
const scrollPosition = sessionStorage.getItem('scrollPosition');
|
||||
if (scrollPosition) {
|
||||
window.scrollTo(0, scrollPosition);
|
||||
sessionStorage.removeItem('scrollPosition');
|
||||
}
|
||||
}
|
||||
|
||||
function duplicateUser(userName) {
|
||||
fetch('/duplicate_user', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: `username=${userName}`
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const newUserName = 'Copy of ' + userName;
|
||||
let newTable = `<table border="1">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User Name</th>
|
||||
<th>Attributes</th>
|
||||
<th>Op</th>
|
||||
<th>Value</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<input type="text" id="new-username" value="${newUserName}">
|
||||
</td>
|
||||
<td colspan="3" class="merged-cell"></td>
|
||||
<td></td>
|
||||
</tr>`;
|
||||
|
||||
data.forEach((attribute, index) => {
|
||||
newTable += `<tr>
|
||||
<td class="merged-cell"></td>
|
||||
<td><input type="text" class="new-attribute" value="${attribute.attribute}"></td>
|
||||
<td>
|
||||
<select class="new-op">
|
||||
<option value="=" ${attribute.op === '=' ? 'selected' : ''}>=</option>
|
||||
<option value="!=" ${attribute.op === '!=' ? 'selected' : ''}>!=</option>
|
||||
<option value=">" ${attribute.op === '>' ? 'selected' : ''}>></option>
|
||||
<option value="<" ${attribute.op === '<' ? 'selected' : ''}><</option>
|
||||
<option value=">=" ${attribute.op === '>=' ? 'selected' : ''}>>=</option>
|
||||
<option value="<=" ${attribute.op === '<=' ? 'selected' : ''}><=</option>
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="text" class="new-value" value="${attribute.value}"></td>
|
||||
<td><button onclick="removeDuplicatedUserRow(this)">🗑️</button></td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
newTable += `<tr>
|
||||
<td class="merged-cell"></td>
|
||||
<td colspan="3">
|
||||
<button onclick="addDuplicatedUserRow()">➕</button>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr></tbody></table>`;
|
||||
|
||||
document.getElementById('duplicate-dialog-content').innerHTML = newTable;
|
||||
document.getElementById('duplicate-dialog').showModal();
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('close-dialog').addEventListener('click', () => {
|
||||
document.getElementById('duplicate-dialog').close();
|
||||
});
|
||||
|
||||
document.getElementById('save-duplicated-user').addEventListener('click', () => {
|
||||
saveDuplicatedUser();
|
||||
});
|
||||
|
||||
function saveDuplicatedUser() {
|
||||
let rows = document.querySelectorAll('#duplicate-dialog-content table tbody tr');
|
||||
let username = rows[0].querySelector('#new-username').value;
|
||||
let attributes = [];
|
||||
for (let i = 1; i < rows.length - 1; i++) {
|
||||
const attributeInput = rows[i].querySelector(`.new-attribute`);
|
||||
const opInput = rows[i].querySelector(`.new-op`);
|
||||
const valueInput = rows[i].querySelector(`.new-value`);
|
||||
|
||||
if (attributeInput && opInput && valueInput) {
|
||||
attributes.push({
|
||||
attribute: attributeInput.value,
|
||||
op: opInput.value,
|
||||
value: valueInput.value
|
||||
});
|
||||
} else {
|
||||
console.warn(`Input elements not found for row ${i}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
fetch('/save_duplicated_user', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username: username, attributes: attributes })
|
||||
})
|
||||
.then(response => response.text())
|
||||
.then(data => {
|
||||
if (data === 'success') {
|
||||
document.getElementById('duplicate-dialog').close();
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error saving duplicated user: ' + data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function addDuplicatedUserRow() {
|
||||
const table = document.querySelector('#duplicate-dialog-content table tbody');
|
||||
const newRow = table.insertRow(table.rows.length - 1);
|
||||
|
||||
const cell1 = newRow.insertCell(0);
|
||||
const cell2 = newRow.insertCell(1);
|
||||
const cell3 = newRow.insertCell(2);
|
||||
const cell4 = newRow.insertCell(3);
|
||||
const cell5 = newRow.insertCell(4);
|
||||
|
||||
cell1.classList.add('merged-cell');
|
||||
cell2.innerHTML = `<input type="text" class="new-attribute" value="">`;
|
||||
cell3.innerHTML = `
|
||||
<select class="new-op">
|
||||
<option value="=">=</option>
|
||||
<option value="!=">!=</option>
|
||||
<option value=">">></option>
|
||||
<option value="<"><</option>
|
||||
<option value=">=">>=</option>
|
||||
<option value="<="><=</option>
|
||||
</select>
|
||||
`;
|
||||
cell4.innerHTML = `<input type="text" class="new-value" value="">`;
|
||||
cell5.innerHTML = `<button onclick="removeDuplicatedUserRow(this)">🗑️</button>`;
|
||||
}
|
||||
|
||||
function removeDuplicatedUserRow(button) {
|
||||
const row = button.parentNode.parentNode;
|
||||
row.parentNode.removeChild(row);
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,200 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}User List{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>User List</h1>
|
||||
|
||||
<table border="1">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>MAC Address</th>
|
||||
<th>Description</th>
|
||||
<th>VLAN ID</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in results %}
|
||||
<tr>
|
||||
<td><input type="text" id="mac_address-{{ user.mac_address }}" value="{{ user.mac_address }}"></td>
|
||||
<td><input type="text" id="description-{{ user.mac_address }}" value="{{ user.description }}"></td>
|
||||
<td>
|
||||
<select id="vlan_id-{{ user.mac_address }}">
|
||||
{% for group in groups %}
|
||||
<option value="{{ group.groupname }}" {% if user.vlan_id == group.groupname %} selected {% endif %}>
|
||||
{{ group.groupname }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<button onclick="updateUser('{{ user.mac_address }}')">✅</button>
|
||||
<a href="/delete_user/{{ user.mac_address }}" onclick="saveScrollPosition()">🗑️</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr>
|
||||
<td colspan="4">
|
||||
<button onclick="addNewUserRow()">➕ Add User</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<dialog id="add-user-dialog">
|
||||
<div id="add-user-dialog-content">
|
||||
<table border="1">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>MAC Address</th>
|
||||
<th>Description</th>
|
||||
<th>VLAN ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><input type="text" id="new-mac"></td>
|
||||
<td><input type="text" id="new-description"></td>
|
||||
<td>
|
||||
<select id="new-vlan_id">
|
||||
{% for group in groups %}
|
||||
<option value="{{ group.groupname }}">
|
||||
{{ group.groupname }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: flex-end; margin-top: 10px;">
|
||||
<button id="cancel-add-user-dialog">Cancel</button>
|
||||
<button id="save-new-user">Save</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
#cancel-add-user-dialog {
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
background-color: #f44336;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
#cancel-add-user-dialog:hover {
|
||||
background-color: #d32f2f;
|
||||
}
|
||||
|
||||
#save-new-user {
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#save-new-user:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
|
||||
#add-user-dialog-content + div {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const groups = {{ groups | tojson | safe }};
|
||||
|
||||
function updateUser(mac_address) {
|
||||
const description = document.getElementById('description-' + mac_address).value;
|
||||
const vlan_id = document.getElementById('vlan_id-' + mac_address).value;
|
||||
|
||||
fetch('/update_user', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: `mac_address=${mac_address}&description=${description}&vlan_id=${vlan_id}`
|
||||
})
|
||||
.then(response => response.text())
|
||||
.then(data => {
|
||||
if (data === 'success') {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error updating user: ' + data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function saveScrollPosition() {
|
||||
sessionStorage.setItem('scrollPosition', window.scrollY);
|
||||
}
|
||||
|
||||
window.onload = function () {
|
||||
const scrollPosition = sessionStorage.getItem('scrollPosition');
|
||||
if (scrollPosition) {
|
||||
window.scrollTo(0, scrollPosition);
|
||||
sessionStorage.removeItem('scrollPosition');
|
||||
}
|
||||
}
|
||||
|
||||
function addNewUserRow() {
|
||||
document.getElementById('add-user-dialog').showModal();
|
||||
}
|
||||
|
||||
document.getElementById('cancel-add-user-dialog').addEventListener('click', () => {
|
||||
document.getElementById('add-user-dialog').close();
|
||||
});
|
||||
|
||||
document.getElementById('save-new-user').addEventListener('click', () => {
|
||||
saveNewUser();
|
||||
});
|
||||
|
||||
function saveNewUser() {
|
||||
const mac = document.getElementById('new-mac').value;
|
||||
const description = document.getElementById('new-description').value;
|
||||
const vlan_id = document.getElementById('new-vlan_id').value;
|
||||
|
||||
if (!mac) {
|
||||
alert('MAC Address cannot be empty.');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/add_user', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ mac_address: mac, description: description, vlan_id: vlan_id }),
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.text().then(text => {
|
||||
throw new Error(`HTTP error! status: ${response.status}, body: ${text}`);
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data && data.success) {
|
||||
document.getElementById('add-user-dialog').close();
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error adding user: ' + (data && data.message ? data.message : 'Unknown error'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Fetch error:', error);
|
||||
alert('Error adding user: ' + error.message);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
3
app/views/__init__.py
Normal file
3
app/views/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .index_views import index # not index_views
|
||||
from .user_views import user
|
||||
from .group_views import group
|
||||
37
app/views/group_views.py
Normal file
37
app/views/group_views.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
||||
from db_interface import get_all_groups, add_group, update_group_description, delete_group_route, get_users_by_vlan_id
|
||||
|
||||
group = Blueprint('group', __name__, url_prefix='/group')
|
||||
|
||||
|
||||
@group.route('/')
|
||||
def group_list():
|
||||
available_groups = get_all_groups()
|
||||
return render_template('group_list.html', available_groups=available_groups)
|
||||
|
||||
|
||||
@group.route('/add', methods=['POST'])
|
||||
def add_group_route():
|
||||
vlan_id = request.form['vlan_id']
|
||||
desc = request.form.get('description', '')
|
||||
add_group(vlan_id, desc)
|
||||
return redirect(url_for('group.group_list'))
|
||||
|
||||
|
||||
@group.route('/update_description', methods=['POST'])
|
||||
def update_description_route():
|
||||
group_id = request.form['group_id']
|
||||
desc = request.form.get('description', '')
|
||||
update_group_description(group_id, desc)
|
||||
return redirect(url_for('group.group_list'))
|
||||
|
||||
|
||||
@group.route('/delete', methods=['POST'])
|
||||
def delete_group_route_handler():
|
||||
return delete_group_route()
|
||||
|
||||
@group.route('/get_users_for_group', methods=['POST'])
|
||||
def get_users_for_group():
|
||||
vlan_id = request.form.get('vlan_id')
|
||||
users = get_users_by_vlan_id(vlan_id)
|
||||
return jsonify(users)
|
||||
73
app/views/index_views.py
Normal file
73
app/views/index_views.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from flask import Blueprint, render_template, request, jsonify, current_app
|
||||
from datetime import datetime
|
||||
from db_interface import (
|
||||
get_connection,
|
||||
get_vendor_info,
|
||||
get_latest_auth_logs,
|
||||
get_all_groups,
|
||||
lookup_mac_verbose,
|
||||
)
|
||||
import pytz
|
||||
|
||||
index = Blueprint('index', __name__)
|
||||
|
||||
def time_ago(dt):
|
||||
if not dt:
|
||||
return "n/a"
|
||||
|
||||
tz_name = current_app.config.get('APP_TIMEZONE', 'UTC')
|
||||
local_tz = pytz.timezone(tz_name)
|
||||
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=pytz.utc)
|
||||
|
||||
dt = dt.astimezone(local_tz)
|
||||
now = datetime.now(local_tz)
|
||||
diff = now - dt
|
||||
|
||||
seconds = int(diff.total_seconds())
|
||||
if seconds < 60:
|
||||
return f"{seconds}s ago"
|
||||
elif seconds < 3600:
|
||||
return f"{seconds // 60}m{seconds % 60}s ago"
|
||||
elif seconds < 86400:
|
||||
return f"{seconds // 3600}h{(seconds % 3600) // 60}m ago"
|
||||
else:
|
||||
return f"{seconds // 86400}d{(seconds % 86400) // 3600}h ago"
|
||||
|
||||
@index.route('/')
|
||||
def homepage():
|
||||
total_users, total_groups = get_summary_counts()
|
||||
latest_accept = get_latest_auth_logs('Access-Accept', limit=5)
|
||||
latest_reject = get_latest_auth_logs('Access-Reject', limit=5)
|
||||
|
||||
for row in latest_accept + latest_reject:
|
||||
row['ago'] = time_ago(row['timestamp'])
|
||||
|
||||
return render_template('index.html',
|
||||
total_users=total_users,
|
||||
total_groups=total_groups,
|
||||
latest_accept=latest_accept,
|
||||
latest_reject=latest_reject)
|
||||
|
||||
@index.route('/lookup_mac', methods=['POST'])
|
||||
def lookup_mac():
|
||||
mac = request.form.get('mac', '').strip()
|
||||
if not mac:
|
||||
return jsonify({"error": "MAC address is required"}), 400
|
||||
|
||||
result = lookup_mac_verbose(mac)
|
||||
return jsonify({"mac": mac, "output": result})
|
||||
|
||||
def get_summary_counts():
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
cursor.execute("SELECT COUNT(*) AS count FROM users")
|
||||
total_users = cursor.fetchone()['count']
|
||||
|
||||
cursor.execute("SELECT COUNT(*) AS count FROM groups")
|
||||
total_groups = cursor.fetchone()['count']
|
||||
|
||||
cursor.close()
|
||||
return total_users, total_groups
|
||||
51
app/views/maintenance_views.py
Normal file
51
app/views/maintenance_views.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from flask import Blueprint, render_template, request, send_file
|
||||
import mysql.connector
|
||||
import os
|
||||
from db_interface import get_database_stats, clear_auth_logs, backup_database, restore_database, get_table_stats # Import the functions from db_interface.py
|
||||
|
||||
|
||||
maintenance = Blueprint('maintenance', __name__, url_prefix='/maintenance')
|
||||
|
||||
@maintenance.route('/')
|
||||
def maintenance_page():
|
||||
"""Renders the maintenance page with table and DB stats."""
|
||||
table_stats = get_table_stats()
|
||||
db_stats = get_database_stats()
|
||||
return render_template('maintenance.html', table_stats=table_stats, db_stats=db_stats)
|
||||
|
||||
@maintenance.route('/clear_auth_logs', methods=['POST'])
|
||||
def clear_auth_logs_route():
|
||||
"""Route to clear authentication logs."""
|
||||
return clear_auth_logs()
|
||||
|
||||
@maintenance.route('/backup_database', methods=['GET'])
|
||||
def backup_database_route():
|
||||
"""Route to backup the database."""
|
||||
try:
|
||||
backup_file = backup_database()
|
||||
return send_file(backup_file, as_attachment=True, download_name='database_backup.sql')
|
||||
except Exception as e:
|
||||
return str(e), 500
|
||||
finally:
|
||||
if os.path.exists('backup.sql'):
|
||||
os.remove('backup.sql')
|
||||
|
||||
@maintenance.route('/restore_database', methods=['POST'])
|
||||
def restore_database_route():
|
||||
"""Route to restore the database."""
|
||||
if 'file' not in request.files:
|
||||
return "No file provided", 400
|
||||
|
||||
sql_file = request.files['file']
|
||||
if sql_file.filename == '':
|
||||
return "No file selected", 400
|
||||
|
||||
if not sql_file.filename.endswith('.sql'):
|
||||
return "Invalid file type. Only .sql files are allowed.", 400
|
||||
|
||||
try:
|
||||
sql_content = sql_file.read().decode('utf-8')
|
||||
message = restore_database(sql_content)
|
||||
return message
|
||||
except Exception as e:
|
||||
return str(e), 500
|
||||
246
app/views/stats_views.py
Normal file
246
app/views/stats_views.py
Normal file
@@ -0,0 +1,246 @@
|
||||
from flask import Blueprint, render_template, request, current_app, redirect, url_for, jsonify
|
||||
from db_interface import get_latest_auth_logs, count_auth_logs, get_all_groups, get_vendor_info, get_user_by_mac, add_user, get_known_mac_vendors
|
||||
from math import ceil
|
||||
import re
|
||||
import pytz
|
||||
import humanize
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from time import sleep
|
||||
|
||||
stats = Blueprint('stats', __name__)
|
||||
|
||||
def get_time_filter_delta(time_range):
|
||||
return {
|
||||
"last_minute": timedelta(minutes=1),
|
||||
"last_5_minutes": timedelta(minutes=5),
|
||||
"last_10_minutes": timedelta(minutes=10),
|
||||
"last_hour": timedelta(hours=1),
|
||||
"last_6_hours": timedelta(hours=6),
|
||||
"last_12_hours": timedelta(hours=12),
|
||||
"last_day": timedelta(days=1),
|
||||
"last_30_days": timedelta(days=30),
|
||||
}.get(time_range)
|
||||
|
||||
def get_pagination_data(current_page, total_pages, max_display=7):
|
||||
if total_pages == 0:
|
||||
return {
|
||||
"pages": [],
|
||||
"show_first": False,
|
||||
"show_last": False,
|
||||
"show_prev": False,
|
||||
"show_next": False,
|
||||
"prev_page": 1,
|
||||
"next_page": 1,
|
||||
"first_page": 1,
|
||||
"last_page": 1
|
||||
}
|
||||
|
||||
if total_pages <= max_display:
|
||||
pages = list(range(1, total_pages + 1))
|
||||
else:
|
||||
half = max_display // 2
|
||||
start = max(1, current_page - half)
|
||||
end = min(total_pages, start + max_display - 1)
|
||||
if end - start + 1 < max_display:
|
||||
start = max(1, end - max_display + 1)
|
||||
pages = list(range(start, end + 1))
|
||||
|
||||
return {
|
||||
"pages": pages,
|
||||
"show_first": 1 not in pages,
|
||||
"show_last": total_pages not in pages,
|
||||
"show_prev": current_page > 1,
|
||||
"show_next": current_page < total_pages,
|
||||
"prev_page": max(current_page - 1, 1),
|
||||
"next_page": min(current_page + 1, total_pages),
|
||||
"first_page": 1,
|
||||
"last_page": total_pages
|
||||
}
|
||||
|
||||
@stats.route('/stats', methods=['GET', 'POST'])
|
||||
def stats_page():
|
||||
if request.method == 'POST':
|
||||
return redirect(url_for('stats.stats_page',
|
||||
time_range=request.form.get('time_range'),
|
||||
per_page=request.form.get('per_page')
|
||||
))
|
||||
|
||||
time_range = request.args.get('time_range', 'last_minute')
|
||||
per_page = int(request.args.get('per_page', 25))
|
||||
page_accept = int(request.args.get('page_accept', 1))
|
||||
page_reject = int(request.args.get('page_reject', 1))
|
||||
page_fallback = int(request.args.get('page_fallback', 1))
|
||||
|
||||
tz_name = current_app.config.get('APP_TIMEZONE', 'UTC')
|
||||
local_tz = pytz.timezone(tz_name)
|
||||
|
||||
def enrich(entry):
|
||||
ts = entry.get('timestamp')
|
||||
if ts:
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
local_time = ts.astimezone(local_tz)
|
||||
entry['ago'] = humanize.naturaltime(datetime.now(local_tz) - local_time)
|
||||
else:
|
||||
entry['ago'] = 'unknown'
|
||||
|
||||
vendor_info = get_vendor_info(entry['mac_address'], insert_if_found=False)
|
||||
entry['vendor'] = vendor_info['vendor'] if vendor_info else None
|
||||
|
||||
user = get_user_by_mac(entry['mac_address'])
|
||||
entry['already_exists'] = user is not None
|
||||
entry['existing_vlan'] = user['vlan_id'] if user else None
|
||||
entry['description'] = user['description'] if user else None
|
||||
|
||||
match = re.search(r'VLAN\s+(\d+)', entry.get('result', ''))
|
||||
entry['vlan_id'] = match.group(1) if match else None
|
||||
|
||||
return entry
|
||||
|
||||
total_accept = count_auth_logs('Access-Accept', time_range)
|
||||
total_pages_accept = ceil(total_accept / per_page)
|
||||
offset_accept = (page_accept - 1) * per_page
|
||||
accept_entries = [enrich(e) for e in get_latest_auth_logs('Access-Accept', per_page, time_range, offset_accept)]
|
||||
|
||||
total_reject = count_auth_logs('Access-Reject', time_range)
|
||||
total_pages_reject = ceil(total_reject / per_page)
|
||||
offset_reject = (page_reject - 1) * per_page
|
||||
reject_entries = [enrich(e) for e in get_latest_auth_logs('Access-Reject', per_page, time_range, offset_reject)]
|
||||
|
||||
total_fallback = count_auth_logs('Accept-Fallback', time_range)
|
||||
total_pages_fallback = ceil(total_fallback / per_page)
|
||||
offset_fallback = (page_fallback - 1) * per_page
|
||||
fallback_entries = [enrich(e) for e in get_latest_auth_logs('Accept-Fallback', per_page, time_range, offset_fallback)]
|
||||
|
||||
available_groups = get_all_groups()
|
||||
|
||||
return render_template(
|
||||
"stats.html",
|
||||
time_range=time_range,
|
||||
per_page=per_page,
|
||||
accept_entries=accept_entries,
|
||||
reject_entries=reject_entries,
|
||||
fallback_entries=fallback_entries,
|
||||
available_groups=available_groups,
|
||||
page_accept=page_accept,
|
||||
pagination_accept=get_pagination_data(page_accept, total_pages_accept),
|
||||
page_reject=page_reject,
|
||||
pagination_reject=get_pagination_data(page_reject, total_pages_reject),
|
||||
page_fallback=page_fallback,
|
||||
pagination_fallback=get_pagination_data(page_fallback, total_pages_fallback),
|
||||
total_pages_accept=total_pages_accept,
|
||||
total_pages_reject=total_pages_reject,
|
||||
total_pages_fallback=total_pages_fallback
|
||||
)
|
||||
|
||||
@stats.route('/add', methods=['POST'])
|
||||
def add():
|
||||
mac = request.form['mac_address']
|
||||
desc = request.form.get('description', '')
|
||||
group_id = request.form.get('group_id')
|
||||
current_app.logger.info(f"Received MAC={mac}, DESC={desc}, VLAN={group_id}")
|
||||
|
||||
add_user(mac, desc, group_id)
|
||||
return redirect(url_for('stats.stats_page'))
|
||||
|
||||
@stats.route('/lookup_mac_async', methods=['POST'])
|
||||
def lookup_mac_async():
|
||||
data = request.get_json()
|
||||
macs = data.get('macs', [])
|
||||
results = {}
|
||||
|
||||
rate_limit = int(current_app.config.get("OUI_API_LIMIT_PER_SEC", 2))
|
||||
delay = 1.0 / rate_limit if rate_limit > 0 else 0.5
|
||||
|
||||
prefixes_to_lookup = {}
|
||||
for mac in macs:
|
||||
prefix = mac.lower().replace(":", "").replace("-", "")[:6]
|
||||
prefixes_to_lookup[prefix] = mac
|
||||
|
||||
known_vendors = get_known_mac_vendors()
|
||||
vendor_cache = {}
|
||||
|
||||
for prefix, mac in prefixes_to_lookup.items():
|
||||
if prefix in known_vendors:
|
||||
results[mac] = known_vendors[prefix]['vendor']
|
||||
continue
|
||||
|
||||
if prefix in vendor_cache:
|
||||
results[mac] = vendor_cache[prefix]
|
||||
continue
|
||||
|
||||
info = get_vendor_info(mac)
|
||||
vendor_name = info.get('vendor', '')
|
||||
vendor_cache[prefix] = vendor_name
|
||||
results[mac] = vendor_name
|
||||
|
||||
sleep(delay)
|
||||
|
||||
return jsonify(results)
|
||||
|
||||
@stats.route('/fetch_stats_data')
|
||||
def fetch_stats_data():
|
||||
time_range = request.args.get('time_range', 'last_minute')
|
||||
per_page = int(request.args.get('per_page', 25))
|
||||
page_accept = int(request.args.get('page_accept', 1))
|
||||
page_reject = int(request.args.get('page_reject', 1))
|
||||
page_fallback = int(request.args.get('page_fallback', 1))
|
||||
|
||||
tz_name = current_app.config.get('APP_TIMEZONE', 'UTC')
|
||||
local_tz = pytz.timezone(tz_name)
|
||||
|
||||
def enrich(entry):
|
||||
ts = entry.get('timestamp')
|
||||
if ts:
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
local_time = ts.astimezone(local_tz)
|
||||
entry['ago'] = humanize.naturaltime(datetime.now(local_tz) - local_time)
|
||||
else:
|
||||
entry['ago'] = 'unknown'
|
||||
|
||||
vendor_info = get_vendor_info(entry['mac_address'], insert_if_found=False)
|
||||
entry['vendor'] = vendor_info['vendor'] if vendor_info else None
|
||||
|
||||
user = get_user_by_mac(entry['mac_address'])
|
||||
entry['already_exists'] = user is not None
|
||||
entry['existing_vlan'] = user['vlan_id'] if user else None
|
||||
entry['description'] = user['description'] if user else None
|
||||
|
||||
match = re.search(r'VLAN\s+(\d+)', entry.get('result', ''))
|
||||
entry['vlan_id'] = match.group(1) if match else None
|
||||
|
||||
return entry
|
||||
|
||||
total_accept = count_auth_logs('Access-Accept', time_range)
|
||||
total_pages_accept = ceil(total_accept / per_page)
|
||||
offset_accept = (page_accept - 1) * per_page
|
||||
accept_entries = [enrich(e) for e in get_latest_auth_logs('Access-Accept', per_page, time_range, offset_accept)]
|
||||
|
||||
total_reject = count_auth_logs('Access-Reject', time_range)
|
||||
total_pages_reject = ceil(total_reject / per_page)
|
||||
offset_reject = (page_reject - 1) * per_page
|
||||
reject_entries = [enrich(e) for e in get_latest_auth_logs('Access-Reject', per_page, time_range, offset_reject)]
|
||||
|
||||
total_fallback = count_auth_logs('Accept-Fallback', time_range)
|
||||
total_pages_fallback = ceil(total_fallback / per_page)
|
||||
offset_fallback = (page_fallback - 1) * per_page
|
||||
fallback_entries = [enrich(e) for e in get_latest_auth_logs('Accept-Fallback', per_page, time_range, offset_fallback)]
|
||||
|
||||
available_groups = get_all_groups()
|
||||
|
||||
return render_template(
|
||||
"_stats_cards.html",
|
||||
time_range=time_range,
|
||||
per_page=per_page,
|
||||
page_accept=page_accept,
|
||||
pagination_accept=get_pagination_data(page_accept, total_pages_accept),
|
||||
accept_entries=accept_entries,
|
||||
page_reject=page_reject,
|
||||
pagination_reject=get_pagination_data(page_reject, total_pages_reject),
|
||||
reject_entries=reject_entries,
|
||||
page_fallback=page_fallback,
|
||||
pagination_fallback=get_pagination_data(page_fallback, total_pages_fallback),
|
||||
fallback_entries=fallback_entries,
|
||||
available_groups=available_groups
|
||||
)
|
||||
48
app/views/user_views.py
Normal file
48
app/views/user_views.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
from db_interface import (
|
||||
get_all_users,
|
||||
get_all_groups,
|
||||
add_user,
|
||||
update_user,
|
||||
delete_user,
|
||||
refresh_vendors,
|
||||
get_user_by_mac
|
||||
)
|
||||
|
||||
user = Blueprint('user', __name__, url_prefix='/user')
|
||||
|
||||
|
||||
@user.route('/')
|
||||
def user_list():
|
||||
users = get_all_users()
|
||||
available_groups = get_all_groups()
|
||||
return render_template('user_list.html', users=users, available_groups=available_groups)
|
||||
|
||||
|
||||
@user.route('/add', methods=['POST'])
|
||||
def add():
|
||||
mac = request.form['mac_address']
|
||||
desc = request.form.get('description', '')
|
||||
group_id = request.form['group_id']
|
||||
add_user(mac, desc, group_id)
|
||||
return redirect(url_for('user.user_list'))
|
||||
|
||||
@user.route('/update_user', methods=['POST'])
|
||||
def update_user_route():
|
||||
mac = request.form['mac_address']
|
||||
desc = request.form.get('description', '')
|
||||
vlan_id = request.form['group_id']
|
||||
update_user(mac, desc, vlan_id)
|
||||
return redirect(url_for('user.user_list'))
|
||||
|
||||
@user.route('/delete', methods=['POST'])
|
||||
def delete():
|
||||
mac = request.form['mac_address']
|
||||
delete_user(mac)
|
||||
return redirect(url_for('user.user_list'))
|
||||
|
||||
|
||||
@user.route('/refresh_vendors', methods=['POST'])
|
||||
def refresh():
|
||||
refresh_vendors()
|
||||
return {'status': 'OK'}
|
||||
3
app/wsgi.py
Normal file
3
app/wsgi.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app import app
|
||||
|
||||
# This file is used by Gunicorn to start the application
|
||||
@@ -1,6 +0,0 @@
|
||||
[mariadb-client]
|
||||
port=3306
|
||||
socket=/run/mysqld/mysqld.sock
|
||||
user=healthcheck
|
||||
password=$57>*_&uD[dBNnC[tO%5&Dztu)Od)C_`
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,149 +0,0 @@
|
||||
4,3
|
||||
2,3
|
||||
1,3
|
||||
0,243
|
||||
0,9
|
||||
0,1
|
||||
0,300
|
||||
0,299
|
||||
0,298
|
||||
0,297
|
||||
0,296
|
||||
0,295
|
||||
0,294
|
||||
0,293
|
||||
0,292
|
||||
0,291
|
||||
0,290
|
||||
0,289
|
||||
0,288
|
||||
0,287
|
||||
0,286
|
||||
0,285
|
||||
0,284
|
||||
0,283
|
||||
0,282
|
||||
0,281
|
||||
0,280
|
||||
0,279
|
||||
0,278
|
||||
0,277
|
||||
0,276
|
||||
0,275
|
||||
0,274
|
||||
0,273
|
||||
0,272
|
||||
0,271
|
||||
0,270
|
||||
0,269
|
||||
0,268
|
||||
0,267
|
||||
0,266
|
||||
0,265
|
||||
0,264
|
||||
0,263
|
||||
0,262
|
||||
0,261
|
||||
0,260
|
||||
0,259
|
||||
0,258
|
||||
0,257
|
||||
0,256
|
||||
0,255
|
||||
0,254
|
||||
0,253
|
||||
0,252
|
||||
0,251
|
||||
0,250
|
||||
0,249
|
||||
0,248
|
||||
0,247
|
||||
0,246
|
||||
0,245
|
||||
0,244
|
||||
0,242
|
||||
0,241
|
||||
0,240
|
||||
0,239
|
||||
0,238
|
||||
0,237
|
||||
0,236
|
||||
0,235
|
||||
0,234
|
||||
0,233
|
||||
0,232
|
||||
0,231
|
||||
0,230
|
||||
0,229
|
||||
0,228
|
||||
0,227
|
||||
0,226
|
||||
0,225
|
||||
0,224
|
||||
0,223
|
||||
0,222
|
||||
0,221
|
||||
0,220
|
||||
0,219
|
||||
0,218
|
||||
0,217
|
||||
0,216
|
||||
0,215
|
||||
0,214
|
||||
0,213
|
||||
0,212
|
||||
0,211
|
||||
0,210
|
||||
0,209
|
||||
0,208
|
||||
0,207
|
||||
0,206
|
||||
0,205
|
||||
0,204
|
||||
0,203
|
||||
0,202
|
||||
0,201
|
||||
0,200
|
||||
0,199
|
||||
0,198
|
||||
0,197
|
||||
0,196
|
||||
0,195
|
||||
0,194
|
||||
0,193
|
||||
0,192
|
||||
0,63
|
||||
0,62
|
||||
0,61
|
||||
0,60
|
||||
0,59
|
||||
0,58
|
||||
0,57
|
||||
0,56
|
||||
0,55
|
||||
0,54
|
||||
0,53
|
||||
0,52
|
||||
0,51
|
||||
0,50
|
||||
0,49
|
||||
0,48
|
||||
0,47
|
||||
0,46
|
||||
0,45
|
||||
0,6
|
||||
0,0
|
||||
0,5
|
||||
0,304
|
||||
0,303
|
||||
0,306
|
||||
0,305
|
||||
0,302
|
||||
0,12
|
||||
0,10
|
||||
0,8
|
||||
0,11
|
||||
0,4
|
||||
0,2
|
||||
0,3
|
||||
0,7
|
||||
Binary file not shown.
BIN
db-data/ibdata1
BIN
db-data/ibdata1
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,2 +0,0 @@
|
||||
default-character-set=utf8mb4
|
||||
default-collation=utf8mb4_general_ci
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user