working for the most part

This commit is contained in:
2025-04-03 15:58:07 -04:00
parent bfd6d8af57
commit af7e24a948
18 changed files with 367 additions and 168 deletions

Binary file not shown.

View File

@@ -2,6 +2,7 @@ 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 config import app_config
@@ -24,6 +25,7 @@ app.logger.setLevel(logging.INFO)
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.route('/user_list')
def legacy_user_list():
@@ -33,10 +35,6 @@ def legacy_user_list():
def legacy_group_list():
return redirect(url_for('group.group_list'))
@app.route('/stats')
def stats():
return render_template('stats.html')
@app.route('/')
def index_redirect():
return render_template('index.html')

11
app/db_connection.py Normal file
View 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'),
)

View File

@@ -4,14 +4,8 @@ import datetime
import requests
import time
import os
def get_connection():
return mysql.connector.connect(
host=current_app.config['DB_HOST'],
user=current_app.config['DB_USER'],
password=current_app.config['DB_PASSWORD'],
database=current_app.config['DB_NAME']
)
import pytz
from db_connection import get_connection
def get_all_users():
@@ -45,10 +39,10 @@ def get_all_groups():
FROM groups g
ORDER BY g.vlan_id
""")
groups = cursor.fetchall()
available_groups = cursor.fetchall()
cursor.close()
conn.close()
return groups
return available_groups
@@ -106,6 +100,7 @@ def duplicate_group(vlan_id):
def add_user(mac_address, description, vlan_id):
print(f"→ Adding to DB: mac={mac_address}, desc={description}, vlan={vlan_id}")
conn = get_connection()
cursor = conn.cursor()
cursor.execute(
@@ -144,19 +139,61 @@ def delete_user(mac_address):
conn.close()
def get_latest_auth_logs(result, limit=10):
def get_latest_auth_logs(reply_type=None, limit=5, time_range=None):
conn = get_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute(
"SELECT * FROM auth_logs WHERE result = %s ORDER BY timestamp DESC LIMIT %s",
(result, limit)
)
# Determine the time filter based on the time_range
if time_range:
now = datetime.now(pytz.timezone(current_app.config.get('APP_TIMEZONE', 'UTC')))
if time_range == 'last_minute':
time_filter = now - timedelta(minutes=1)
elif time_range == 'last_5_minutes':
time_filter = now - timedelta(minutes=5)
elif time_range == 'last_10_minutes':
time_filter = now - timedelta(minutes=10)
elif time_range == 'last_hour':
time_filter = now - timedelta(hours=1)
elif time_range == 'last_6_hours':
time_filter = now - timedelta(hours=6)
elif time_range == 'last_12_hours':
time_filter = now - timedelta(hours=12)
elif time_range == 'last_day':
time_filter = now - timedelta(days=1)
elif time_range == 'last_30_days':
time_filter = now - timedelta(days=30)
else: # 'all' case
time_filter = None
if time_filter:
cursor.execute("""
SELECT * FROM auth_logs
WHERE reply = %s AND timestamp >= %s
ORDER BY timestamp DESC
LIMIT %s
""", (reply_type, time_filter, limit))
else:
cursor.execute("""
SELECT * FROM auth_logs
WHERE reply = %s
ORDER BY timestamp DESC
LIMIT %s
""", (reply_type, limit))
else:
cursor.execute("""
SELECT * FROM auth_logs
WHERE reply = %s
ORDER BY timestamp DESC
LIMIT %s
""", (reply_type, limit))
logs = cursor.fetchall()
cursor.close()
conn.close()
return logs
def get_vendor_info(mac, insert_if_found=True):
conn = get_connection()
cursor = conn.cursor(dictionary=True)
@@ -410,3 +447,14 @@ def lookup_mac_verbose(mac):
conn.close()
return "\n".join(output)
def get_user_by_mac(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

View File

@@ -115,7 +115,12 @@ table.styled-table {
.styled-table th {
background-color: var(--header-bg);
color: var(--fg);
text-align: left;
text-align: center;
}
/* 🧩 Fix: Remove right border from last column */
.styled-table thead th:last-child {
border-right: none;
}
.styled-table input[type="text"],
@@ -235,4 +240,57 @@ form.inline-form {
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;
}
.stats-page .error-card {
border-left: 6px solid crimson;
}
.stats-page .fallback-card {
border-left: 6px solid orange;
}
.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;
}

View File

@@ -12,7 +12,7 @@
<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</a>
<a href="{{ url_for('stats.stats_page') }}">Stats</a>
</div>
<div class="right">
<button id="theme-toggle">🌓 Theme</button>

View File

@@ -20,7 +20,7 @@
</tr>
</thead>
<tbody>
{% for group in groups %}
{% for group in available_groups %}
<tr>
<td>{{ group.vlan_id }}</td>
<td>

View File

@@ -15,30 +15,27 @@
</div>
</div>
<h2>Recent Access-Accept</h2>
<ul class="event-list green">
{% for entry in latest_accept %}
<h2>Recent Access Logs</h2>
<ul class="event-list">
<li><strong>Access-Accept Logs</strong></li>
{% for log in latest_accept %}
<li>
<strong>{{ entry.mac_address }}</strong>
{% if entry.description %} ({{ entry.description }}){% endif %}
— {{ entry.ago }}
<strong>{{ log.mac_address }}</strong> - {{ log.reply }}
<br>
<small>{{ log.timestamp }} - {{ log.result }}</small>
</li>
{% endfor %}
<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>Recent Access-Reject</h2>
<ul class="event-list red">
{% for entry in latest_reject %}
<li>
<strong>{{ entry.mac_address }}</strong>
{% if entry.description %} ({{ entry.description }}){% endif %}
— {{ entry.ago }}
</li>
{% endfor %}
</ul>
<hr>
<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>

View File

@@ -2,99 +2,132 @@
{% block title %}Authentication Stats{% endblock %}
{% block content %}
<div class="stats-page">
<h1 class="page-title">Authentication Stats</h1>
<div class="stats-container">
<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>Time</th>
</tr>
</thead>
<tbody>
{% for entry in accept_entries %}
<tr>
<td>{{ entry.mac_address }}</td>
<td>{{ entry.description or '' }}</td>
<td>{{ entry.vendor }}</td>
<td>{{ entry.ago }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<form method="POST" action="/stats/stats">
<label for="time_range">Select Time Range:</label>
<select name="time_range" id="time_range">
<option value="last_minute" {% if time_range == 'last_minute' %}selected{% endif %}>Last 1 Minute</option>
<option value="last_5_minutes" {% if time_range == 'last_5_minutes' %}selected{% endif %}>Last 5 Minutes</option>
<option value="last_10_minutes" {% if time_range == 'last_10_minutes' %}selected{% endif %}>Last 10 Minutes</option>
<option value="last_hour" {% if time_range == 'last_hour' %}selected{% endif %}>Last Hour</option>
<option value="last_6_hours" {% if time_range == 'last_6_hours' %}selected{% endif %}>Last 6 Hours</option>
<option value="last_12_hours" {% if time_range == 'last_12_hours' %}selected{% endif %}>Last 12 Hours</option>
<option value="last_day" {% if time_range == 'last_day' %}selected{% endif %}>Last Day</option>
<option value="last_30_days" {% if time_range == 'last_30_days' %}selected{% endif %}>Last 30 Days</option>
<option value="all" {% if time_range == 'all' %}selected{% endif %}>All Time</option>
</select>
<button type="submit">Update</button>
</form>
<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>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for entry in reject_entries %}
<tr>
<td>{{ entry.mac_address }}</td>
<td>{{ entry.description or '' }}</td>
<td>{{ entry.vendor }}</td>
<td>{{ entry.ago }}</td>
<td>
{% if entry.already_exists %}
<span style="color: limegreen;">Already exists in VLAN {{ entry.existing_vlan or 'unknown' }}</span>
{% else %}
<form method="POST" action="/user/add_from_reject" style="display: flex; gap: 4px;">
<input type="hidden" name="mac_address" value="{{ entry.mac_address }}">
<select name="group_id" required>
<option value="">Select VLAN</option>
{% for group in available_groups %}
<option value="{{ group.id }}">VLAN {{ group.vlan_id }}</option>
{% endfor %}
</select>
<button type="submit" title="Add User">💾</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="stats-container">
<!-- Access-Accept Card -->
<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>Time</th>
</tr>
</thead>
<tbody>
{% for entry in accept_entries %}
<tr>
<td>{{ entry.mac_address }}</td>
<td>{{ entry.description or '' }}</td>
<td>{{ entry.vendor }}</td>
<td>{{ entry.ago }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Access-Reject Card -->
<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>{{ entry.vendor }}</td>
<td>{{ entry.ago }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Access-Fallback Card -->
<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>
{% if fallback_entries %}
{% 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>{{ entry.vendor }}</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 }}</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 %}
{% else %}
<tr><td colspan="5">No data available.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<style>
.stats-container {
display: flex;
flex-wrap: wrap;
gap: 2rem;
}
.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);
}
.success-card {
border-left: 6px solid limegreen;
}
.error-card {
border-left: 6px solid crimson;
}
.styled-table.small-table td, .styled-table.small-table th {
padding: 6px;
font-size: 0.9rem;
}
</style>
</div> {# closes .stats-page #}
{% endblock %}

View File

@@ -9,7 +9,7 @@
<input type="text" name="description" placeholder="Description (optional)">
<select name="group_id" required>
<option value="">Assign to VLAN</option>
{% for group in groups %}
{% for group in available_groups %}
<option value="{{ group.vlan_id }}">VLAN {{ group.vlan_id }}</option>
{% endfor %}
</select>
@@ -44,7 +44,7 @@
<form method="POST" action="{{ url_for('user.update_vlan_route') }}" class="inline-form">
<input type="hidden" name="mac_address" value="{{ entry.mac_address }}">
<select name="group_id" onchange="this.form.submit()">
{% for group in groups %}
{% for group in available_groups %}
<option value="{{ group.vlan_id }}" {% if group.vlan_id == entry.vlan_id %}selected{% endif %}>
VLAN {{ group.vlan_id }}
</option>

View File

@@ -6,8 +6,8 @@ group = Blueprint('group', __name__, url_prefix='/group')
@group.route('/')
def group_list():
groups = get_all_groups()
return render_template('group_list.html', groups=groups)
available_groups = get_all_groups()
return render_template('group_list.html', available_groups=available_groups)
@group.route('/add', methods=['POST'])

View File

@@ -3,9 +3,8 @@ from datetime import datetime
from db_interface import (
get_connection,
get_vendor_info,
get_all_groups,
get_latest_auth_logs,
lookup_mac_verbose
get_all_groups,
)
import pytz
@@ -15,7 +14,6 @@ def time_ago(dt):
if not dt:
return "n/a"
# Use configured timezone
tz_name = current_app.config.get('APP_TIMEZONE', 'UTC')
local_tz = pytz.timezone(tz_name)
@@ -51,31 +49,6 @@ def homepage():
latest_accept=latest_accept,
latest_reject=latest_reject)
@index.route('/stats')
def stats():
accept_entries = get_latest_auth_logs('Access-Accept')
reject_entries = get_latest_auth_logs('Access-Reject')
available_groups = get_all_groups()
# Process entries to add vendor and time-ago
from datetime import datetime, timezone
import humanize
def enrich(entry):
from db_interface import get_vendor_info, get_user_by_mac
entry['vendor'] = get_vendor_info(entry['mac_address'])
entry['ago'] = humanize.naturaltime(datetime.now(timezone.utc) - entry['timestamp'])
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
return entry
accept_entries = [enrich(e) for e in accept_entries]
reject_entries = [enrich(e) for e in reject_entries]
return render_template("stats.html", accept_entries=accept_entries, reject_entries=reject_entries, available_groups=available_groups)
@index.route('/lookup_mac', methods=['POST'])
def lookup_mac():
mac = request.form.get('mac', '').strip()
@@ -85,7 +58,6 @@ def lookup_mac():
result = lookup_mac_verbose(mac)
return jsonify({"mac": mac, "output": result})
def get_summary_counts():
conn = get_connection()
cursor = conn.cursor(dictionary=True)

82
app/views/stats_views.py Normal file
View File

@@ -0,0 +1,82 @@
from flask import Blueprint, render_template, request, current_app, redirect, url_for
from db_interface import get_latest_auth_logs, get_all_groups, get_vendor_info, get_user_by_mac, add_user
import pytz
import humanize
from datetime import datetime, timezone, timedelta
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)
@stats.route('/stats', methods=['GET', 'POST'])
def stats_page():
time_range = request.form.get('time_range') or request.args.get('time_range') or 'last_minute'
limit = 1000 # Fetch enough to allow filtering by time later
# Timezone setup
tz_name = current_app.config.get('APP_TIMEZONE', 'UTC')
local_tz = pytz.timezone(tz_name)
def is_within_selected_range(ts):
if time_range == "all":
return True
delta = get_time_filter_delta(time_range)
if not delta or not ts:
return True
now = datetime.now(timezone.utc)
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
return (now - ts) <= delta
def enrich(entry):
if entry.get('timestamp') and entry['timestamp'].tzinfo is None:
entry['timestamp'] = entry['timestamp'].replace(tzinfo=timezone.utc)
local_time = entry['timestamp'].astimezone(local_tz)
entry['ago'] = humanize.naturaltime(datetime.now(local_tz) - local_time)
vendor_info = get_vendor_info(entry['mac_address']) or {}
entry['vendor'] = vendor_info.get('vendor', 'Unknown Vendor')
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
return entry
# Get and enrich logs after filtering
accept_entries = [enrich(e) for e in get_latest_auth_logs('Access-Accept', limit) if is_within_selected_range(e.get('timestamp'))]
reject_entries = [enrich(e) for e in get_latest_auth_logs('Access-Reject', limit) if is_within_selected_range(e.get('timestamp'))]
fallback_entries = [enrich(e) for e in get_latest_auth_logs('Accept-Fallback', limit) if is_within_selected_range(e.get('timestamp'))]
available_groups = get_all_groups()
return render_template(
"stats.html",
accept_entries=accept_entries,
reject_entries=reject_entries,
fallback_entries=fallback_entries,
available_groups=available_groups,
time_range=time_range
)
@stats.route('/add', methods=['POST'])
def add():
mac = request.form['mac_address']
desc = request.form.get('description', '')
group_id = request.form.get('group_id') # keep as string since VARCHAR
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'))

View File

@@ -1,5 +1,5 @@
from flask import Blueprint, render_template, request, redirect, url_for
from db_interface import get_all_users, get_all_groups, add_user, update_description, update_vlan, delete_user, refresh_vendors
from flask import Blueprint, render_template, request, redirect, url_for, flash
from db_interface import get_all_users, get_all_groups, add_user, update_description, update_vlan, delete_user, refresh_vendors, get_user_by_mac
user = Blueprint('user', __name__, url_prefix='/user')

View File

@@ -4,7 +4,7 @@
CREATE TABLE IF NOT EXISTS users (
mac_address CHAR(12) NOT NULL PRIMARY KEY CHECK (mac_address REGEXP '^[0-9A-Fa-f]{12}$'),
description VARCHAR(200),
vlan_id VARCHAR(64)
vlan_id VARCHAR(64) NOT NULL
);
-- Table for auth logs