From b25ebfe9bb6c8e733a13b1be480f31b472fbbcb4 Mon Sep 17 00:00:00 2001 From: Simon Cloutier Date: Tue, 8 Apr 2025 15:52:23 -0400 Subject: [PATCH] lots of work on the stats page layout and features --- app/static/styles.css | 64 ++++- app/templates/_stats_cards.html | 170 ++++++++++++++ app/templates/stats.html | 398 ++++++++++++-------------------- app/views/stats_views.py | 163 +++++++++---- docker-compose.yml | 2 +- 5 files changed, 493 insertions(+), 304 deletions(-) create mode 100644 app/templates/_stats_cards.html diff --git a/app/static/styles.css b/app/static/styles.css index 7a918c0..7e3ca6c 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -259,15 +259,15 @@ form.inline-form { } .stats-page .success-card { - border-left: 6px solid limegreen; + border-left: 6px solid limegreen !important; } .stats-page .error-card { - border-left: 6px solid crimson; + border-left: 6px solid crimson !important; } .stats-page .fallback-card { - border-left: 6px solid orange; + border-left: 6px solid orange !important; } .stats-page .styled-table.small-table td, @@ -451,3 +451,61 @@ form.inline-form { 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); +} diff --git a/app/templates/_stats_cards.html b/app/templates/_stats_cards.html new file mode 100644 index 0000000..1bfe98f --- /dev/null +++ b/app/templates/_stats_cards.html @@ -0,0 +1,170 @@ +{# Partial for rendering all three stats cards with AJAX-aware pagination #} + +
+

Recent Access-Accept

+ + + + + + + + + + + + {% for entry in accept_entries %} + + + + + + + + {% endfor %} + +
MAC AddressDescriptionVendorVLANTime
{{ entry.mac_address }}{{ entry.description or '' }}{{ entry.vendor or '...' }}{{ entry.vlan_id or '?' }}{{ entry.ago }}
+ {% if pagination_accept.pages|length > 1 %} + + {% endif %} +
+ +
+

Recent Access-Reject

+ + + + + + + + + + + {% for entry in reject_entries %} + + + + + + + {% endfor %} + +
MAC AddressDescriptionVendorTime
{{ entry.mac_address }}{{ entry.description or '' }}{{ entry.vendor or '...' }}{{ entry.ago }}
+ {% if pagination_reject.pages|length > 1 %} + + {% endif %} +
+ +
+

Recent Access-Fallback

+ + + + + + + + + + + + {% for entry in fallback_entries %} + + + + + + + + {% endfor %} + +
MAC AddressDescriptionVendorTimeActions
{{ entry.mac_address }} + {% if not entry.already_exists %} + + {% else %} + {{ entry.description or '' }} + {% endif %} + {{ entry.vendor or '...' }}{{ entry.ago }} + {% if not entry.already_exists %} +
+ + + +
+ {% else %} + Already exists in VLAN {{ entry.existing_vlan or 'unknown' }} + {% endif %} +
+ {% if pagination_fallback.pages|length > 1 %} + + {% endif %} +
+ \ No newline at end of file diff --git a/app/templates/stats.html b/app/templates/stats.html index 7fef905..fa59bf9 100644 --- a/app/templates/stats.html +++ b/app/templates/stats.html @@ -3,275 +3,161 @@ {% block content %}
-

Authentication Stats

+

Authentication Stats

-
- - - - - - - -
- -
- - -
- -
- - -
-

Recent Access-Accept

- - - - - - - - - - - - {% for entry in accept_entries %} - - - - - - - - {% endfor %} - -
MAC AddressDescriptionVendorVLANTime
{{ entry.mac_address }}{{ entry.description or '' }}{{ entry.vendor or '...' }}{{ entry.vlan_id or '?' }}{{ entry.ago }}
- {% if pagination_accept.pages|length > 1 %} - + } -{% endblock %} \ No newline at end of file + 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); +}); + +{% endblock %} diff --git a/app/views/stats_views.py b/app/views/stats_views.py index 68861e2..d43b04b 100644 --- a/app/views/stats_views.py +++ b/app/views/stats_views.py @@ -22,22 +22,33 @@ def get_time_filter_delta(time_range): }.get(time_range) def get_pagination_data(current_page, total_pages, max_display=7): - pagination = [] + 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: - pagination = list(range(1, total_pages + 1)) + pages = list(range(1, total_pages + 1)) else: half = max_display // 2 - if current_page <= half: - pagination = list(range(1, max_display + 1)) - elif current_page >= total_pages - half: - pagination = list(range(total_pages - max_display + 1, total_pages + 1)) - else: - pagination = list(range(current_page - half, current_page + half + 1)) + 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": pagination, - "show_first": current_page > 1, - "show_last": current_page < total_pages, + "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), @@ -48,9 +59,14 @@ def get_pagination_data(current_page, total_pages, max_display=7): @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' - per_page = int(request.form.get('per_page') or request.args.get('per_page') or 25) + 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)) @@ -58,21 +74,6 @@ def stats_page(): tz_name = current_app.config.get('APP_TIMEZONE', 'UTC') local_tz = pytz.timezone(tz_name) - 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 = 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 = 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 = get_latest_auth_logs('Accept-Fallback', per_page, time_range, offset_fallback) - def enrich(entry): ts = entry.get('timestamp') if ts: @@ -96,9 +97,21 @@ def stats_page(): return entry - accept_entries = [enrich(e) for e in accept_entries] - reject_entries = [enrich(e) for e in reject_entries] - fallback_entries = [enrich(e) for e in fallback_entries] + 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( @@ -109,23 +122,22 @@ def stats_page(): 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) + 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') # keep as string since VARCHAR + 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) @@ -140,14 +152,13 @@ def lookup_mac_async(): 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 - # Lowercase cleaned prefixes prefixes_to_lookup = {} for mac in macs: prefix = mac.lower().replace(":", "").replace("-", "")[:6] - prefixes_to_lookup[prefix] = mac # Use last MAC that used this prefix + prefixes_to_lookup[prefix] = mac - known_vendors = get_known_mac_vendors() # local DB cache - vendor_cache = {} # cache during this request + known_vendors = get_known_mac_vendors() + vendor_cache = {} for prefix, mac in prefixes_to_lookup.items(): if prefix in known_vendors: @@ -155,17 +166,81 @@ def lookup_mac_async(): continue if prefix in vendor_cache: - print(f"→ Prefix {prefix} already queried in this request, skipping.") results[mac] = vendor_cache[prefix] continue - info = get_vendor_info(mac) # will insert into DB + info = get_vendor_info(mac) vendor_name = info.get('vendor', '') vendor_cache[prefix] = vendor_name results[mac] = vendor_name - sleep(delay) # throttle + 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 + ) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index abd2214..65363c8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,7 +34,7 @@ services: - webnet app: - image: simonclr/radmac-app:latest + image: simonclr/radmac-app:dev env_file: - .env environment: