refactor: 🎨 improved audit log filtering

This commit is contained in:
2025-12-04 22:31:41 +00:00
parent f6795f5281
commit f0165985fc
2 changed files with 320 additions and 44 deletions
+112 -8
View File
@@ -958,24 +958,52 @@ def register_routes(app):
PER_PAGE = 25
page = int(request.args.get('page', 1))
offset = (page - 1) * PER_PAGE
user_id = request.args.get('user_id')
# Get filter parameters
user_ids = request.args.getlist('user_ids') # Multiple users
subnet_id = request.args.get('subnet_id')
action = request.args.get('action')
device_name = request.args.get('device_name')
date_from = request.args.get('date_from')
date_to = request.args.get('date_to')
search_query = request.args.get('search', '').strip()
query = '''SELECT AuditLog.id, COALESCE(User.name, 'Deleted User'), AuditLog.action, AuditLog.details, Subnet.name, AuditLog.timestamp FROM AuditLog LEFT JOIN User ON AuditLog.user_id = User.id LEFT JOIN Subnet ON AuditLog.subnet_id = Subnet.id WHERE 1=1'''
params = []
if user_id:
query += ' AND AuditLog.user_id = %s'
params.append(user_id)
# Multiple user filtering
if user_ids:
placeholders = ','.join(['%s'] * len(user_ids))
query += f' AND AuditLog.user_id IN ({placeholders})'
params.extend(user_ids)
if subnet_id:
query += ' AND AuditLog.subnet_id = %s'
params.append(subnet_id)
if action:
query += ' AND AuditLog.action = %s'
params.append(action)
if device_name:
query += ' AND AuditLog.details LIKE %s'
params.append(f'%{device_name}%')
# Date range filtering
if date_from:
query += ' AND AuditLog.timestamp >= %s'
params.append(date_from)
if date_to:
query += ' AND AuditLog.timestamp <= %s'
params.append(date_to + ' 23:59:59')
# Search query (searches in details, user name, action, subnet name)
if search_query:
query += ' AND (AuditLog.details LIKE %s OR COALESCE(User.name, \'\') LIKE %s OR AuditLog.action LIKE %s OR COALESCE(Subnet.name, \'\') LIKE %s)'
search_pattern = f'%{search_query}%'
params.extend([search_pattern, search_pattern, search_pattern, search_pattern])
count_query = 'SELECT COUNT(*) FROM (' + query + ') AS count_subquery'
with get_db_connection() as conn:
cursor = conn.cursor()
@@ -984,17 +1012,93 @@ def register_routes(app):
query += ' ORDER BY AuditLog.timestamp DESC LIMIT %s OFFSET %s'
cursor.execute(query, params + [PER_PAGE, offset])
logs = cursor.fetchall()
cursor.execute('SELECT id, name FROM User')
cursor.execute('SELECT id, name FROM User ORDER BY name')
users = cursor.fetchall()
cursor.execute('SELECT id, name FROM Subnet')
cursor.execute('SELECT id, name FROM Subnet ORDER BY name')
subnets = cursor.fetchall()
cursor.execute('SELECT DISTINCT action FROM AuditLog')
cursor.execute('SELECT DISTINCT action FROM AuditLog ORDER BY action')
actions = [row[0] for row in cursor.fetchall()]
cursor.execute('SELECT name FROM Device ORDER BY name')
devices = cursor.fetchall()
query_args = request.args.to_dict()
total_pages = (total_logs + PER_PAGE - 1) // PER_PAGE
return render_with_user('audit.html', logs=logs, users=users, subnets=subnets, actions=actions, devices=devices, page=page, total_pages=total_pages, query_args=query_args)
return render_with_user('audit.html', logs=logs, users=users, subnets=subnets, actions=actions, devices=devices, page=page, total_pages=total_pages, query_args=query_args, selected_user_ids=user_ids, date_from=date_from, date_to=date_to, search_query=search_query)
@app.route('/audit/export_csv')
@permission_required('view_audit')
def export_audit_csv():
"""Export audit logs to CSV with current filters applied"""
# Get filter parameters (same as audit route)
user_ids = request.args.getlist('user_ids')
subnet_id = request.args.get('subnet_id')
action = request.args.get('action')
device_name = request.args.get('device_name')
date_from = request.args.get('date_from')
date_to = request.args.get('date_to')
search_query = request.args.get('search', '').strip()
query = '''SELECT COALESCE(User.name, 'Deleted User'), AuditLog.action, AuditLog.details, COALESCE(Subnet.name, 'N/A'), AuditLog.timestamp FROM AuditLog LEFT JOIN User ON AuditLog.user_id = User.id LEFT JOIN Subnet ON AuditLog.subnet_id = Subnet.id WHERE 1=1'''
params = []
# Apply same filters as audit route
if user_ids:
placeholders = ','.join(['%s'] * len(user_ids))
query += f' AND AuditLog.user_id IN ({placeholders})'
params.extend(user_ids)
if subnet_id:
query += ' AND AuditLog.subnet_id = %s'
params.append(subnet_id)
if action:
query += ' AND AuditLog.action = %s'
params.append(action)
if device_name:
query += ' AND AuditLog.details LIKE %s'
params.append(f'%{device_name}%')
if date_from:
query += ' AND AuditLog.timestamp >= %s'
params.append(date_from)
if date_to:
query += ' AND AuditLog.timestamp <= %s'
params.append(date_to + ' 23:59:59')
if search_query:
query += ' AND (AuditLog.details LIKE %s OR COALESCE(User.name, \'\') LIKE %s OR AuditLog.action LIKE %s OR COALESCE(Subnet.name, \'\') LIKE %s)'
search_pattern = f'%{search_query}%'
params.extend([search_pattern, search_pattern, search_pattern, search_pattern])
query += ' ORDER BY AuditLog.timestamp DESC'
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute(query, params)
logs = cursor.fetchall()
# Create CSV
output = StringIO()
writer = csv.writer(output)
writer.writerow(['User', 'Action', 'Details', 'Subnet', 'Timestamp'])
for log in logs:
writer.writerow(log)
csv_bytes = output.getvalue().encode('utf-8')
output_bytes = BytesIO(csv_bytes)
from datetime import datetime
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f'audit_logs_{timestamp}.csv'
return send_file(
output_bytes,
mimetype='text/csv',
as_attachment=True,
download_name=filename
)
@app.route('/get_available_ips')
@permission_required('view_device')
+208 -36
View File
@@ -13,43 +13,105 @@
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-8xl pt-20">
<h1 class="text-3xl font-bold mb-6 text-center">Audit Log</h1>
<form method="GET" class="flex flex-wrap gap-4 mb-6 justify-center">
<select name="user_id" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
<option value="">All Users</option>
{% for user in users %}
<option value="{{ user[0] }}" {% if request.args.get('user_id') == user[0]|string %}selected{% endif %}>{{ user[1] }}</option>
{% endfor %}
</select>
<select name="subnet_id" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
<option value="">All Subnets</option>
{% for subnet in subnets %}
<option value="{{ subnet[0] }}" {% if request.args.get('subnet_id') == subnet[0]|string %}selected{% endif %}>{{ subnet[1] }}</option>
{% endfor %}
</select>
<select name="action" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
<option value="">All Actions</option>
{% for a in actions %}
<option value="{{ a }}" {% if request.args.get('action') == a %}selected{% endif %}>{{ a }}</option>
{% endfor %}
</select>
<select name="device_name" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
<option value="">All Devices</option>
{% for device in devices %}
<option value="{{ device[0] }}" {% if request.args.get('device_name') == device[0] %}selected{% endif %}>{{ device[0] }}</option>
{% endfor %}
</select>
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
<i class="fas fa-search"></i>
<span>Filter</span>
<!-- Collapsible Filter Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg mb-6">
<button type="button" id="filter-toggle" class="w-full flex items-center justify-between p-4 hover:bg-gray-300 dark:hover:bg-zinc-700 rounded-lg transition-colors hover:cursor-pointer">
<h2 class="text-lg font-semibold">Filters</h2>
<i class="fas fa-chevron-down transition-transform duration-200 transform" id="filter-arrow"></i>
</button>
</form>
<!-- Advanced Filter Form -->
<form method="GET" id="audit-filter-form" class="px-4 pb-4 {% if not (search_query or selected_user_ids or request.args.get('subnet_id') or request.args.get('action') or request.args.get('device_name') or date_from or date_to or request.args.get('expand_filters')) %}hidden{% endif %}">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4">
<!-- Search -->
<div class="lg:col-span-3">
<label class="block text-sm font-medium mb-1">Search</label>
<input type="text" name="search" value="{{ search_query or '' }}" placeholder="Search in details, user, action, subnet..." class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
</div>
<!-- Multiple Users -->
<div>
<label class="block text-sm font-medium mb-1">Users</label>
<select name="user_ids" multiple size="5" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
{% for user in users %}
<option value="{{ user[0] }}" {% if selected_user_ids and user[0]|string in selected_user_ids %}selected{% endif %}>{{ user[1] }}</option>
{% endfor %}
</select>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">Hold Ctrl/Cmd to select multiple</p>
</div>
<!-- Subnet -->
<div>
<label class="block text-sm font-medium mb-1">Subnet</label>
<select name="subnet_id" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
<option value="">All Subnets</option>
{% for subnet in subnets %}
<option value="{{ subnet[0] }}" {% if request.args.get('subnet_id') == subnet[0]|string %}selected{% endif %}>{{ subnet[1] }}</option>
{% endfor %}
</select>
</div>
<!-- Action -->
<div>
<label class="block text-sm font-medium mb-1">Action</label>
<select name="action" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
<option value="">All Actions</option>
{% for a in actions %}
<option value="{{ a }}" {% if request.args.get('action') == a %}selected{% endif %}>{{ a }}</option>
{% endfor %}
</select>
</div>
<!-- Device Name -->
<div>
<label class="block text-sm font-medium mb-1">Device</label>
<select name="device_name" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
<option value="">All Devices</option>
{% for device in devices %}
<option value="{{ device[0] }}" {% if request.args.get('device_name') == device[0] %}selected{% endif %}>{{ device[0] }}</option>
{% endfor %}
</select>
</div>
<!-- Date From -->
<div>
<label class="block text-sm font-medium mb-1">Date From</label>
<input type="date" name="date_from" value="{{ date_from or '' }}" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
</div>
<!-- Date To -->
<div>
<label class="block text-sm font-medium mb-1">Date To</label>
<input type="date" name="date_to" value="{{ date_to or '' }}" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
</div>
</div>
<div class="flex gap-2 justify-center">
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
<i class="fas fa-search"></i>
<span>Filter</span>
</button>
<a href="/audit?expand_filters=1" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
<i class="fas fa-times"></i>
<span>Clear</span>
</a>
<button type="button" id="export-btn" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
<i class="fas fa-file-csv"></i>
<span>Export CSV</span>
</button>
</div>
</form>
</div>
<!-- Audit Log Table -->
<div class="overflow-x-auto">
<table class="w-full table-auto bg-gray-200 dark:bg-zinc-800 rounded-lg overflow-hidden">
<thead>
<tr class="bg-gray-400 dark:bg-zinc-700">
<th class="px-4 py-2 text-center">User</th>
<th class="px-4 py-2 text-center">Action</th>
<th class="px-4 py-2 text-center">Details</th>
<th class="px-4 py-2 text-center details-cell">Details</th>
<th class="px-4 py-2 text-center">Subnet</th>
<th class="px-4 py-2 text-center">Timestamp</th>
</tr>
@@ -59,7 +121,9 @@
<tr class="border-b border-gray-700">
<td class="px-4 py-2 text-center">{{ log[1] or 'Unknown' }}</td>
<td class="px-4 py-2 text-center">{{ log[2] }}</td>
<td class="px-4 py-2 text-center truncate" title="{{ log[3] }}">{{ log[3][:100] ~ ('…' if log[3]|length > 100 else '') }}</td>
<td class="px-4 py-2 text-center details-cell">
<div class="diff-container" data-details="{{ log[3]|e }}"></div>
</td>
<td class="px-4 py-2 text-center">{{ log[4] or 'N/A' }}</td>
<td class="px-4 py-2 text-center" data-utc="{{ log[5] }}">{{ log[5] }}</td>
</tr>
@@ -67,12 +131,13 @@
</tbody>
</table>
</div>
{% if total_pages > 1 %}
<div class="flex justify-center mt-6 space-x-2">
{% if page > 1 %}
{% set prev_args = query_args.copy() %}
{% set _ = prev_args.update({'page': page-1}) %}
<a href="{{ url_for('audit', **prev_args) }}" class="px-3 py-1 rounded bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600 flex items-center gap-2">
<a href="{{ url_for('audit', **prev_args) }}" class="px-3 py-1 rounded bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer flex items-center gap-2">
<i class="fa fa-angle-left"></i>
<span class="hidden sm:inline">Prev</span>
</a>
@@ -87,7 +152,7 @@
{% if start_page > 1 %}
{% set page_args = query_args.copy() %}
{% set _ = page_args.update({'page': 1}) %}
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded {{ 'bg-gray-200 dark:bg-gray-500' if 1 == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">1</a>
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded hover:cursor-pointer {{ 'bg-gray-200 dark:bg-gray-500' if 1 == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">1</a>
{% if start_page > 2 %}
<span class="px-3 py-1 text-gray-600 dark:text-gray-400"></span>
{% endif %}
@@ -97,7 +162,7 @@
{% for p in range(start_page, end_page + 1) %}
{% set page_args = query_args.copy() %}
{% set _ = page_args.update({'page': p}) %}
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded {{ 'bg-gray-200 dark:bg-gray-500' if p == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">{{ p }}</a>
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded hover:cursor-pointer {{ 'bg-gray-200 dark:bg-gray-500' if p == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">{{ p }}</a>
{% endfor %}
{# Show last page if we're not near the end #}
@@ -107,13 +172,13 @@
{% endif %}
{% set page_args = query_args.copy() %}
{% set _ = page_args.update({'page': total_pages}) %}
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded {{ 'bg-gray-200 dark:bg-gray-500' if total_pages == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">{{ total_pages }}</a>
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded hover:cursor-pointer {{ 'bg-gray-200 dark:bg-gray-500' if total_pages == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">{{ total_pages }}</a>
{% endif %}
{% if page < total_pages %}
{% set next_args = query_args.copy() %}
{% set _ = next_args.update({'page': page+1}) %}
<a href="{{ url_for('audit', **next_args) }}" class="px-3 py-1 rounded bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600 flex items-center gap-2">
<a href="{{ url_for('audit', **next_args) }}" class="px-3 py-1 rounded bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer flex items-center gap-2">
<span class="hidden sm:inline">Next</span>
<i class="fa fa-angle-right"></i>
</a>
@@ -124,6 +189,29 @@
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Filter toggle functionality
const filterToggle = document.getElementById('filter-toggle');
const filterForm = document.getElementById('audit-filter-form');
const filterArrow = document.getElementById('filter-arrow');
if (filterToggle && filterForm && filterArrow) {
filterToggle.addEventListener('click', function() {
filterForm.classList.toggle('hidden');
// Toggle rotation using inline style for better compatibility
if (filterForm.classList.contains('hidden')) {
filterArrow.style.transform = 'rotate(0deg)';
} else {
filterArrow.style.transform = 'rotate(180deg)';
}
});
// Set initial arrow rotation if form is visible (has active filters or expand_filters param)
if (!filterForm.classList.contains('hidden')) {
filterArrow.style.transform = 'rotate(180deg)';
}
}
// Format timestamps
document.querySelectorAll('td[data-utc]').forEach(function(td) {
const utc = td.getAttribute('data-utc');
if (utc) {
@@ -131,6 +219,90 @@
td.textContent = date.toLocaleString();
}
});
// Parse and display visual diffs
document.querySelectorAll('.diff-container').forEach(function(container) {
const details = container.getAttribute('data-details');
if (!details) return;
// Try to parse common change patterns
let html = details;
// Pattern 1: "Changed X from 'old' to 'new'"
html = html.replace(/Changed (.+?) from ['"](.+?)['"] to ['"](.+?)['"]/gi, function(match, field, oldVal, newVal) {
return `Changed ${field} from <span class="diff-removed">${oldVal}</span> to <span class="diff-added">${newVal}</span>`;
});
// Pattern 2: "Renamed X to Y"
html = html.replace(/Renamed (.+?) to ['"](.+?)['"]/gi, function(match, oldVal, newVal) {
return `Renamed <span class="diff-removed">${oldVal}</span> to <span class="diff-added">${newVal}</span>`;
});
// Pattern 3: "Updated X: old -> new"
html = html.replace(/Updated (.+?):\s*(.+?)\s*->\s*(.+?)(?:\s|$)/gi, function(match, field, oldVal, newVal) {
return `Updated ${field}: <span class="diff-removed">${oldVal}</span> → <span class="diff-added">${newVal}</span>`;
});
// Pattern 4: "Set X to Y" (when it was previously something else, look for context)
html = html.replace(/Set (.+?) to ['"](.+?)['"]/gi, function(match, field, newVal) {
return `Set ${field} to <span class="diff-added">${newVal}</span>`;
});
// Pattern 5: "Removed X" or "Deleted X"
html = html.replace(/(Removed|Deleted) ['"](.+?)['"]/gi, function(match, action, val) {
return `${action} <span class="diff-removed">${val}</span>`;
});
// Pattern 6: "Added X"
html = html.replace(/Added ['"](.+?)['"]/gi, function(match, val) {
return `Added <span class="diff-added">${val}</span>`;
});
// Pattern 7: "Assigned X to Y" or "Unassigned X from Y"
html = html.replace(/(Assigned|Unassigned) (.+?) (to|from) (.+?)(?:\s|$)/gi, function(match, action, item, prep, target) {
const actionClass = action === 'Assigned' ? 'diff-added' : 'diff-removed';
return `${action} <span class="${actionClass}">${item}</span> ${prep} ${target}`;
});
// Pattern 8: Generic "from X to Y" pattern
html = html.replace(/from ['"](.+?)['"] to ['"](.+?)['"]/gi, function(match, oldVal, newVal) {
return `from <span class="diff-removed">${oldVal}</span> to <span class="diff-added">${newVal}</span>`;
});
container.innerHTML = html || details;
});
// Export button handler
document.getElementById('export-btn').addEventListener('click', function() {
const form = document.getElementById('audit-filter-form');
const formData = new FormData(form);
const params = new URLSearchParams();
// Add all form fields to params
for (const [key, value] of formData.entries()) {
if (value) {
if (key === 'user_ids') {
// Handle multiple user_ids
params.append('user_ids', value);
} else {
params.append(key, value);
}
}
}
// Handle multiple user_ids separately
const userSelect = form.querySelector('select[name="user_ids"]');
if (userSelect) {
const selectedUsers = Array.from(userSelect.selectedOptions).map(opt => opt.value);
params.delete('user_ids');
selectedUsers.forEach(userId => {
params.append('user_ids', userId);
});
}
// Redirect to export endpoint
window.location.href = '/audit/export_csv?' + params.toString();
});
});
</script>
</body>