Phase 3 Step 5: Enhanced Dashboard with Charts & Analytics
Implemented dashboard visualizations and statistics API endpoints: New Features: - Stats API endpoints (/api/stats/scan-trend, /api/stats/summary) - Chart.js trending chart showing 30-day scan activity - Schedules widget displaying next 3 upcoming scheduled scans - Enhanced Quick Actions with Manage Schedules button Stats API (web/api/stats.py): - scan-trend endpoint with configurable days (1-365) - Summary endpoint for dashboard statistics - Automatic date range filling with zeros for missing days - Proper authentication and validation Dashboard Enhancements (web/templates/dashboard.html): - Chart.js line chart with dark theme styling - Real-time schedules widget with human-readable time display - Auto-refresh for schedules every 30 seconds - Responsive 8-4 column layout for chart and schedules Tests (tests/test_stats_api.py): - 18 comprehensive test cases for stats API - Coverage for date validation, authentication, edge cases - Tests for empty data handling and date formatting Progress: 64% complete (9/14 days) Next: Step 6 - Scheduler Integration
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
# Phase 3 Implementation Plan: Dashboard Enhancement & Scheduled Scans
|
# Phase 3 Implementation Plan: Dashboard Enhancement & Scheduled Scans
|
||||||
|
|
||||||
**Status:** In Progress
|
**Status:** In Progress
|
||||||
**Progress:** 5/14 days complete (36%)
|
**Progress:** 9/14 days complete (64%)
|
||||||
**Estimated Duration:** 14 days (2 weeks)
|
**Estimated Duration:** 14 days (2 weeks)
|
||||||
**Dependencies:** Phase 2 Complete ✅
|
**Dependencies:** Phase 2 Complete ✅
|
||||||
|
|
||||||
@@ -10,9 +10,9 @@
|
|||||||
- ✅ **Step 1: Fix Styling Issues & CSS Refactor** (Day 1) - COMPLETE
|
- ✅ **Step 1: Fix Styling Issues & CSS Refactor** (Day 1) - COMPLETE
|
||||||
- ✅ **Step 2: ScheduleService Implementation** (Days 2-3) - COMPLETE
|
- ✅ **Step 2: ScheduleService Implementation** (Days 2-3) - COMPLETE
|
||||||
- ✅ **Step 3: Schedules API Endpoints** (Days 4-5) - COMPLETE
|
- ✅ **Step 3: Schedules API Endpoints** (Days 4-5) - COMPLETE
|
||||||
- 📋 **Step 4: Schedule Management UI** (Days 6-7) - NEXT
|
- ✅ **Step 4: Schedule Management UI** (Days 6-7) - COMPLETE
|
||||||
- 📋 **Step 5: Enhanced Dashboard with Charts** (Days 8-9)
|
- ✅ **Step 5: Enhanced Dashboard with Charts** (Days 8-9) - COMPLETE
|
||||||
- 📋 **Step 6: Scheduler Integration** (Day 10)
|
- 📋 **Step 6: Scheduler Integration** (Day 10) - NEXT
|
||||||
- 📋 **Step 7: Scan Comparison Features** (Days 11-12)
|
- 📋 **Step 7: Scan Comparison Features** (Days 11-12)
|
||||||
- 📋 **Step 8: Testing & Documentation** (Days 13-14)
|
- 📋 **Step 8: Testing & Documentation** (Days 13-14)
|
||||||
|
|
||||||
|
|||||||
325
tests/test_stats_api.py
Normal file
325
tests/test_stats_api.py
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
"""
|
||||||
|
Tests for stats API endpoints.
|
||||||
|
|
||||||
|
Tests dashboard statistics and trending data endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from web.models import Scan
|
||||||
|
|
||||||
|
|
||||||
|
class TestStatsAPI:
|
||||||
|
"""Test suite for stats API endpoints."""
|
||||||
|
|
||||||
|
def test_scan_trend_default_30_days(self, client, auth_headers, db_session):
|
||||||
|
"""Test scan trend endpoint with default 30 days."""
|
||||||
|
# Create test scans over multiple days
|
||||||
|
today = datetime.utcnow()
|
||||||
|
for i in range(5):
|
||||||
|
scan_date = today - timedelta(days=i)
|
||||||
|
for j in range(i + 1): # Create 1, 2, 3, 4, 5 scans per day
|
||||||
|
scan = Scan(
|
||||||
|
config_file='/app/configs/test.yaml',
|
||||||
|
timestamp=scan_date,
|
||||||
|
status='completed',
|
||||||
|
duration=10.5
|
||||||
|
)
|
||||||
|
db_session.add(scan)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
# Request trend data
|
||||||
|
response = client.get('/api/stats/scan-trend', headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.get_json()
|
||||||
|
assert 'labels' in data
|
||||||
|
assert 'values' in data
|
||||||
|
assert 'start_date' in data
|
||||||
|
assert 'end_date' in data
|
||||||
|
assert 'total_scans' in data
|
||||||
|
|
||||||
|
# Should have 30 days of data
|
||||||
|
assert len(data['labels']) == 30
|
||||||
|
assert len(data['values']) == 30
|
||||||
|
|
||||||
|
# Total scans should match (1+2+3+4+5 = 15)
|
||||||
|
assert data['total_scans'] == 15
|
||||||
|
|
||||||
|
# Values should be non-negative integers
|
||||||
|
assert all(isinstance(v, int) for v in data['values'])
|
||||||
|
assert all(v >= 0 for v in data['values'])
|
||||||
|
|
||||||
|
def test_scan_trend_custom_days(self, client, auth_headers, db_session):
|
||||||
|
"""Test scan trend endpoint with custom number of days."""
|
||||||
|
# Create test scans
|
||||||
|
today = datetime.utcnow()
|
||||||
|
for i in range(10):
|
||||||
|
scan = Scan(
|
||||||
|
config_file='/app/configs/test.yaml',
|
||||||
|
timestamp=today - timedelta(days=i),
|
||||||
|
status='completed',
|
||||||
|
duration=10.5
|
||||||
|
)
|
||||||
|
db_session.add(scan)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
# Request 7 days of data
|
||||||
|
response = client.get('/api/stats/scan-trend?days=7', headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.get_json()
|
||||||
|
assert len(data['labels']) == 7
|
||||||
|
assert len(data['values']) == 7
|
||||||
|
assert data['total_scans'] == 7
|
||||||
|
|
||||||
|
def test_scan_trend_max_days_365(self, client, auth_headers):
|
||||||
|
"""Test scan trend endpoint accepts maximum 365 days."""
|
||||||
|
response = client.get('/api/stats/scan-trend?days=365', headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.get_json()
|
||||||
|
assert len(data['labels']) == 365
|
||||||
|
|
||||||
|
def test_scan_trend_rejects_days_over_365(self, client, auth_headers):
|
||||||
|
"""Test scan trend endpoint rejects more than 365 days."""
|
||||||
|
response = client.get('/api/stats/scan-trend?days=366', headers=auth_headers)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
data = response.get_json()
|
||||||
|
assert 'error' in data
|
||||||
|
assert '365' in data['error']
|
||||||
|
|
||||||
|
def test_scan_trend_rejects_days_less_than_1(self, client, auth_headers):
|
||||||
|
"""Test scan trend endpoint rejects days less than 1."""
|
||||||
|
response = client.get('/api/stats/scan-trend?days=0', headers=auth_headers)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
data = response.get_json()
|
||||||
|
assert 'error' in data
|
||||||
|
|
||||||
|
def test_scan_trend_fills_missing_days_with_zero(self, client, auth_headers, db_session):
|
||||||
|
"""Test scan trend fills days with no scans as zero."""
|
||||||
|
# Create scans only on specific days
|
||||||
|
today = datetime.utcnow()
|
||||||
|
|
||||||
|
# Create scan 5 days ago
|
||||||
|
scan1 = Scan(
|
||||||
|
config_file='/app/configs/test.yaml',
|
||||||
|
timestamp=today - timedelta(days=5),
|
||||||
|
status='completed',
|
||||||
|
duration=10.5
|
||||||
|
)
|
||||||
|
db_session.add(scan1)
|
||||||
|
|
||||||
|
# Create scan 10 days ago
|
||||||
|
scan2 = Scan(
|
||||||
|
config_file='/app/configs/test.yaml',
|
||||||
|
timestamp=today - timedelta(days=10),
|
||||||
|
status='completed',
|
||||||
|
duration=10.5
|
||||||
|
)
|
||||||
|
db_session.add(scan2)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
# Request 15 days
|
||||||
|
response = client.get('/api/stats/scan-trend?days=15', headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
# Should have 15 days of data
|
||||||
|
assert len(data['values']) == 15
|
||||||
|
|
||||||
|
# Most days should be zero
|
||||||
|
zero_days = sum(1 for v in data['values'] if v == 0)
|
||||||
|
assert zero_days >= 13 # At least 13 days with no scans
|
||||||
|
|
||||||
|
def test_scan_trend_requires_authentication(self, client):
|
||||||
|
"""Test scan trend endpoint requires authentication."""
|
||||||
|
response = client.get('/api/stats/scan-trend')
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_summary_endpoint(self, client, auth_headers, db_session):
|
||||||
|
"""Test summary statistics endpoint."""
|
||||||
|
# Create test scans with different statuses
|
||||||
|
today = datetime.utcnow()
|
||||||
|
|
||||||
|
# 5 completed scans
|
||||||
|
for i in range(5):
|
||||||
|
scan = Scan(
|
||||||
|
config_file='/app/configs/test.yaml',
|
||||||
|
timestamp=today - timedelta(days=i),
|
||||||
|
status='completed',
|
||||||
|
duration=10.5
|
||||||
|
)
|
||||||
|
db_session.add(scan)
|
||||||
|
|
||||||
|
# 2 failed scans
|
||||||
|
for i in range(2):
|
||||||
|
scan = Scan(
|
||||||
|
config_file='/app/configs/test.yaml',
|
||||||
|
timestamp=today - timedelta(days=i),
|
||||||
|
status='failed',
|
||||||
|
duration=5.0
|
||||||
|
)
|
||||||
|
db_session.add(scan)
|
||||||
|
|
||||||
|
# 1 running scan
|
||||||
|
scan = Scan(
|
||||||
|
config_file='/app/configs/test.yaml',
|
||||||
|
timestamp=today,
|
||||||
|
status='running',
|
||||||
|
duration=None
|
||||||
|
)
|
||||||
|
db_session.add(scan)
|
||||||
|
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
# Request summary
|
||||||
|
response = client.get('/api/stats/summary', headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.get_json()
|
||||||
|
assert 'total_scans' in data
|
||||||
|
assert 'completed_scans' in data
|
||||||
|
assert 'failed_scans' in data
|
||||||
|
assert 'running_scans' in data
|
||||||
|
assert 'scans_today' in data
|
||||||
|
assert 'scans_this_week' in data
|
||||||
|
|
||||||
|
# Verify counts
|
||||||
|
assert data['total_scans'] == 8
|
||||||
|
assert data['completed_scans'] == 5
|
||||||
|
assert data['failed_scans'] == 2
|
||||||
|
assert data['running_scans'] == 1
|
||||||
|
assert data['scans_today'] >= 1
|
||||||
|
assert data['scans_this_week'] >= 1
|
||||||
|
|
||||||
|
def test_summary_with_no_scans(self, client, auth_headers):
|
||||||
|
"""Test summary endpoint with no scans."""
|
||||||
|
response = client.get('/api/stats/summary', headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['total_scans'] == 0
|
||||||
|
assert data['completed_scans'] == 0
|
||||||
|
assert data['failed_scans'] == 0
|
||||||
|
assert data['running_scans'] == 0
|
||||||
|
assert data['scans_today'] == 0
|
||||||
|
assert data['scans_this_week'] == 0
|
||||||
|
|
||||||
|
def test_summary_scans_today(self, client, auth_headers, db_session):
|
||||||
|
"""Test summary counts scans today correctly."""
|
||||||
|
today = datetime.utcnow()
|
||||||
|
yesterday = today - timedelta(days=1)
|
||||||
|
|
||||||
|
# Create 3 scans today
|
||||||
|
for i in range(3):
|
||||||
|
scan = Scan(
|
||||||
|
config_file='/app/configs/test.yaml',
|
||||||
|
timestamp=today,
|
||||||
|
status='completed',
|
||||||
|
duration=10.5
|
||||||
|
)
|
||||||
|
db_session.add(scan)
|
||||||
|
|
||||||
|
# Create 2 scans yesterday
|
||||||
|
for i in range(2):
|
||||||
|
scan = Scan(
|
||||||
|
config_file='/app/configs/test.yaml',
|
||||||
|
timestamp=yesterday,
|
||||||
|
status='completed',
|
||||||
|
duration=10.5
|
||||||
|
)
|
||||||
|
db_session.add(scan)
|
||||||
|
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.get('/api/stats/summary', headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['scans_today'] == 3
|
||||||
|
assert data['scans_this_week'] >= 3
|
||||||
|
|
||||||
|
def test_summary_scans_this_week(self, client, auth_headers, db_session):
|
||||||
|
"""Test summary counts scans this week correctly."""
|
||||||
|
today = datetime.utcnow()
|
||||||
|
|
||||||
|
# Create scans over the last 10 days
|
||||||
|
for i in range(10):
|
||||||
|
scan = Scan(
|
||||||
|
config_file='/app/configs/test.yaml',
|
||||||
|
timestamp=today - timedelta(days=i),
|
||||||
|
status='completed',
|
||||||
|
duration=10.5
|
||||||
|
)
|
||||||
|
db_session.add(scan)
|
||||||
|
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.get('/api/stats/summary', headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.get_json()
|
||||||
|
# Last 7 days (0-6) = 7 scans
|
||||||
|
assert data['scans_this_week'] == 7
|
||||||
|
|
||||||
|
def test_summary_requires_authentication(self, client):
|
||||||
|
"""Test summary endpoint requires authentication."""
|
||||||
|
response = client.get('/api/stats/summary')
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_scan_trend_date_format(self, client, auth_headers, db_session):
|
||||||
|
"""Test scan trend returns dates in correct format."""
|
||||||
|
# Create a scan
|
||||||
|
scan = Scan(
|
||||||
|
config_file='/app/configs/test.yaml',
|
||||||
|
timestamp=datetime.utcnow(),
|
||||||
|
status='completed',
|
||||||
|
duration=10.5
|
||||||
|
)
|
||||||
|
db_session.add(scan)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.get('/api/stats/scan-trend?days=7', headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
# Check date format (YYYY-MM-DD)
|
||||||
|
for label in data['labels']:
|
||||||
|
assert len(label) == 10
|
||||||
|
assert label[4] == '-'
|
||||||
|
assert label[7] == '-'
|
||||||
|
# Try parsing to ensure valid date
|
||||||
|
datetime.strptime(label, '%Y-%m-%d')
|
||||||
|
|
||||||
|
def test_scan_trend_consecutive_dates(self, client, auth_headers):
|
||||||
|
"""Test scan trend returns consecutive dates."""
|
||||||
|
response = client.get('/api/stats/scan-trend?days=7', headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.get_json()
|
||||||
|
labels = data['labels']
|
||||||
|
|
||||||
|
# Convert to datetime objects
|
||||||
|
dates = [datetime.strptime(label, '%Y-%m-%d') for label in labels]
|
||||||
|
|
||||||
|
# Check dates are consecutive
|
||||||
|
for i in range(len(dates) - 1):
|
||||||
|
diff = dates[i + 1] - dates[i]
|
||||||
|
assert diff.days == 1, f"Dates not consecutive: {dates[i]} to {dates[i+1]}"
|
||||||
|
|
||||||
|
def test_scan_trend_ends_with_today(self, client, auth_headers):
|
||||||
|
"""Test scan trend ends with today's date."""
|
||||||
|
response = client.get('/api/stats/scan-trend?days=7', headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
# Last date should be today
|
||||||
|
today = datetime.utcnow().date()
|
||||||
|
last_date = datetime.strptime(data['labels'][-1], '%Y-%m-%d').date()
|
||||||
|
assert last_date == today
|
||||||
151
web/api/stats.py
Normal file
151
web/api/stats.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""
|
||||||
|
Stats API blueprint.
|
||||||
|
|
||||||
|
Handles endpoints for dashboard statistics, trending data, and analytics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from flask import Blueprint, current_app, jsonify, request
|
||||||
|
from sqlalchemy import func, Date
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
|
from web.auth.decorators import api_auth_required
|
||||||
|
from web.models import Scan
|
||||||
|
|
||||||
|
bp = Blueprint('stats', __name__)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/scan-trend', methods=['GET'])
|
||||||
|
@api_auth_required
|
||||||
|
def scan_trend():
|
||||||
|
"""
|
||||||
|
Get scan activity trend data for charts.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
days: Number of days to include (default: 30, max: 365)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON response with labels and values arrays for Chart.js
|
||||||
|
{
|
||||||
|
"labels": ["2025-01-01", "2025-01-02", ...],
|
||||||
|
"values": [5, 3, 7, 2, ...]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get and validate query parameters
|
||||||
|
days = request.args.get('days', 30, type=int)
|
||||||
|
|
||||||
|
# Validate days parameter
|
||||||
|
if days < 1:
|
||||||
|
return jsonify({'error': 'days parameter must be at least 1'}), 400
|
||||||
|
if days > 365:
|
||||||
|
return jsonify({'error': 'days parameter cannot exceed 365'}), 400
|
||||||
|
|
||||||
|
# Calculate date range
|
||||||
|
end_date = datetime.utcnow().date()
|
||||||
|
start_date = end_date - timedelta(days=days - 1)
|
||||||
|
|
||||||
|
# Query scan counts per day
|
||||||
|
db_session = current_app.db_session
|
||||||
|
scan_counts = (
|
||||||
|
db_session.query(
|
||||||
|
func.date(Scan.timestamp).label('scan_date'),
|
||||||
|
func.count(Scan.id).label('scan_count')
|
||||||
|
)
|
||||||
|
.filter(func.date(Scan.timestamp) >= start_date)
|
||||||
|
.filter(func.date(Scan.timestamp) <= end_date)
|
||||||
|
.group_by(func.date(Scan.timestamp))
|
||||||
|
.order_by('scan_date')
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a dictionary of date -> count
|
||||||
|
scan_dict = {str(row.scan_date): row.scan_count for row in scan_counts}
|
||||||
|
|
||||||
|
# Generate all dates in range (fill missing dates with 0)
|
||||||
|
labels = []
|
||||||
|
values = []
|
||||||
|
current_date = start_date
|
||||||
|
while current_date <= end_date:
|
||||||
|
date_str = str(current_date)
|
||||||
|
labels.append(date_str)
|
||||||
|
values.append(scan_dict.get(date_str, 0))
|
||||||
|
current_date += timedelta(days=1)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'labels': labels,
|
||||||
|
'values': values,
|
||||||
|
'start_date': str(start_date),
|
||||||
|
'end_date': str(end_date),
|
||||||
|
'total_scans': sum(values)
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"Database error in scan_trend: {str(e)}")
|
||||||
|
return jsonify({'error': 'Database error occurred'}), 500
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in scan_trend: {str(e)}")
|
||||||
|
return jsonify({'error': 'An error occurred'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/summary', methods=['GET'])
|
||||||
|
@api_auth_required
|
||||||
|
def summary():
|
||||||
|
"""
|
||||||
|
Get dashboard summary statistics.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON response with summary stats
|
||||||
|
{
|
||||||
|
"total_scans": 150,
|
||||||
|
"completed_scans": 140,
|
||||||
|
"failed_scans": 5,
|
||||||
|
"running_scans": 5,
|
||||||
|
"scans_today": 3,
|
||||||
|
"scans_this_week": 15
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
db_session = current_app.db_session
|
||||||
|
|
||||||
|
# Get total counts by status
|
||||||
|
total_scans = db_session.query(func.count(Scan.id)).scalar() or 0
|
||||||
|
completed_scans = db_session.query(func.count(Scan.id)).filter(
|
||||||
|
Scan.status == 'completed'
|
||||||
|
).scalar() or 0
|
||||||
|
failed_scans = db_session.query(func.count(Scan.id)).filter(
|
||||||
|
Scan.status == 'failed'
|
||||||
|
).scalar() or 0
|
||||||
|
running_scans = db_session.query(func.count(Scan.id)).filter(
|
||||||
|
Scan.status == 'running'
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
# Get scans today
|
||||||
|
today = datetime.utcnow().date()
|
||||||
|
scans_today = db_session.query(func.count(Scan.id)).filter(
|
||||||
|
func.date(Scan.timestamp) == today
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
# Get scans this week (last 7 days)
|
||||||
|
week_ago = today - timedelta(days=6)
|
||||||
|
scans_this_week = db_session.query(func.count(Scan.id)).filter(
|
||||||
|
func.date(Scan.timestamp) >= week_ago
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'total_scans': total_scans,
|
||||||
|
'completed_scans': completed_scans,
|
||||||
|
'failed_scans': failed_scans,
|
||||||
|
'running_scans': running_scans,
|
||||||
|
'scans_today': scans_today,
|
||||||
|
'scans_this_week': scans_this_week
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"Database error in summary: {str(e)}")
|
||||||
|
return jsonify({'error': 'Database error occurred'}), 500
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in summary: {str(e)}")
|
||||||
|
return jsonify({'error': 'An error occurred'}), 500
|
||||||
@@ -317,6 +317,7 @@ def register_blueprints(app: Flask) -> None:
|
|||||||
from web.api.schedules import bp as schedules_bp
|
from web.api.schedules import bp as schedules_bp
|
||||||
from web.api.alerts import bp as alerts_bp
|
from web.api.alerts import bp as alerts_bp
|
||||||
from web.api.settings import bp as settings_bp
|
from web.api.settings import bp as settings_bp
|
||||||
|
from web.api.stats import bp as stats_bp
|
||||||
from web.auth.routes import bp as auth_bp
|
from web.auth.routes import bp as auth_bp
|
||||||
from web.routes.main import bp as main_bp
|
from web.routes.main import bp as main_bp
|
||||||
|
|
||||||
@@ -331,6 +332,7 @@ def register_blueprints(app: Flask) -> None:
|
|||||||
app.register_blueprint(schedules_bp, url_prefix='/api/schedules')
|
app.register_blueprint(schedules_bp, url_prefix='/api/schedules')
|
||||||
app.register_blueprint(alerts_bp, url_prefix='/api/alerts')
|
app.register_blueprint(alerts_bp, url_prefix='/api/alerts')
|
||||||
app.register_blueprint(settings_bp, url_prefix='/api/settings')
|
app.register_blueprint(settings_bp, url_prefix='/api/settings')
|
||||||
|
app.register_blueprint(stats_bp, url_prefix='/api/stats')
|
||||||
|
|
||||||
app.logger.info("Blueprints registered")
|
app.logger.info("Blueprints registered")
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,51 @@
|
|||||||
<span id="trigger-btn-spinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
|
<span id="trigger-btn-spinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
|
||||||
</button>
|
</button>
|
||||||
<a href="{{ url_for('main.scans') }}" class="btn btn-secondary btn-lg ms-2">View All Scans</a>
|
<a href="{{ url_for('main.scans') }}" class="btn btn-secondary btn-lg ms-2">View All Scans</a>
|
||||||
|
<a href="{{ url_for('main.schedules') }}" class="btn btn-secondary btn-lg ms-2">
|
||||||
|
<i class="bi bi-calendar-plus"></i> Manage Schedules
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scan Activity Chart -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0" style="color: #60a5fa;">Scan Activity (Last 30 Days)</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="chart-loading" class="text-center py-4">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<canvas id="scanTrendChart" height="100" style="display: none;"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schedules Widget -->
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0" style="color: #60a5fa;">Upcoming Schedules</h5>
|
||||||
|
<a href="{{ url_for('main.schedules') }}" class="btn btn-sm btn-secondary">Manage</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="schedules-loading" class="text-center py-4">
|
||||||
|
<div class="spinner-border spinner-border-sm" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="schedules-content" style="display: none;"></div>
|
||||||
|
<div id="schedules-empty" class="text-muted text-center py-4" style="display: none;">
|
||||||
|
No schedules configured yet.
|
||||||
|
<br><br>
|
||||||
|
<a href="{{ url_for('main.create_schedule') }}" class="btn btn-sm btn-primary">Create Schedule</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,6 +185,8 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
refreshScans();
|
refreshScans();
|
||||||
loadStats();
|
loadStats();
|
||||||
|
loadScanTrend();
|
||||||
|
loadSchedules();
|
||||||
|
|
||||||
// Auto-refresh every 10 seconds if there are running scans
|
// Auto-refresh every 10 seconds if there are running scans
|
||||||
refreshInterval = setInterval(function() {
|
refreshInterval = setInterval(function() {
|
||||||
@@ -149,6 +196,9 @@
|
|||||||
loadStats();
|
loadStats();
|
||||||
}
|
}
|
||||||
}, 10000);
|
}, 10000);
|
||||||
|
|
||||||
|
// Refresh schedules every 30 seconds
|
||||||
|
setInterval(loadSchedules, 30000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load dashboard stats
|
// Load dashboard stats
|
||||||
@@ -329,6 +379,162 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load scan trend chart
|
||||||
|
async function loadScanTrend() {
|
||||||
|
const chartLoading = document.getElementById('chart-loading');
|
||||||
|
const canvas = document.getElementById('scanTrendChart');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/stats/scan-trend?days=30');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load trend data');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Hide loading, show chart
|
||||||
|
chartLoading.style.display = 'none';
|
||||||
|
canvas.style.display = 'block';
|
||||||
|
|
||||||
|
// Create chart
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: data.labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Scans per Day',
|
||||||
|
data: data.values,
|
||||||
|
borderColor: '#60a5fa',
|
||||||
|
backgroundColor: 'rgba(96, 165, 250, 0.1)',
|
||||||
|
tension: 0.3,
|
||||||
|
fill: true
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: true,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
callbacks: {
|
||||||
|
title: function(context) {
|
||||||
|
return new Date(context[0].label).toLocaleDateString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
stepSize: 1,
|
||||||
|
color: '#94a3b8'
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: '#334155'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
ticks: {
|
||||||
|
color: '#94a3b8',
|
||||||
|
maxRotation: 0,
|
||||||
|
autoSkip: true,
|
||||||
|
maxTicksLimit: 10
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: '#334155'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading chart:', error);
|
||||||
|
chartLoading.innerHTML = '<p class="text-muted">Failed to load chart data</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load upcoming schedules
|
||||||
|
async function loadSchedules() {
|
||||||
|
const loadingEl = document.getElementById('schedules-loading');
|
||||||
|
const contentEl = document.getElementById('schedules-content');
|
||||||
|
const emptyEl = document.getElementById('schedules-empty');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/schedules?per_page=5');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load schedules');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const schedules = data.schedules || [];
|
||||||
|
|
||||||
|
loadingEl.style.display = 'none';
|
||||||
|
|
||||||
|
if (schedules.length === 0) {
|
||||||
|
emptyEl.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
contentEl.style.display = 'block';
|
||||||
|
|
||||||
|
// Filter enabled schedules and sort by next_run
|
||||||
|
const enabledSchedules = schedules
|
||||||
|
.filter(s => s.enabled && s.next_run)
|
||||||
|
.sort((a, b) => new Date(a.next_run) - new Date(b.next_run))
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
|
if (enabledSchedules.length === 0) {
|
||||||
|
contentEl.innerHTML = '<p class="text-muted">No enabled schedules</p>';
|
||||||
|
} else {
|
||||||
|
contentEl.innerHTML = enabledSchedules.map(schedule => {
|
||||||
|
const nextRun = new Date(schedule.next_run);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = nextRun - now;
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMins / 60);
|
||||||
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
|
||||||
|
let timeStr;
|
||||||
|
if (diffMins < 1) {
|
||||||
|
timeStr = 'In less than 1 minute';
|
||||||
|
} else if (diffMins < 60) {
|
||||||
|
timeStr = `In ${diffMins} minute${diffMins === 1 ? '' : 's'}`;
|
||||||
|
} else if (diffHours < 24) {
|
||||||
|
timeStr = `In ${diffHours} hour${diffHours === 1 ? '' : 's'}`;
|
||||||
|
} else if (diffDays < 7) {
|
||||||
|
timeStr = `In ${diffDays} day${diffDays === 1 ? '' : 's'}`;
|
||||||
|
} else {
|
||||||
|
timeStr = nextRun.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="mb-3 pb-3 border-bottom">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<strong>${schedule.name}</strong>
|
||||||
|
<br>
|
||||||
|
<small class="text-muted">${timeStr}</small>
|
||||||
|
<br>
|
||||||
|
<small class="text-muted mono">${schedule.cron_expression}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading schedules:', error);
|
||||||
|
loadingEl.style.display = 'none';
|
||||||
|
contentEl.style.display = 'block';
|
||||||
|
contentEl.innerHTML = '<p class="text-muted">Failed to load schedules</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Delete scan
|
// Delete scan
|
||||||
async function deleteScan(scanId) {
|
async function deleteScan(scanId) {
|
||||||
if (!confirm(`Are you sure you want to delete scan ${scanId}?`)) {
|
if (!confirm(`Are you sure you want to delete scan ${scanId}?`)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user