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:
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.alerts import bp as alerts_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.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(alerts_bp, url_prefix='/api/alerts')
|
||||
app.register_blueprint(settings_bp, url_prefix='/api/settings')
|
||||
app.register_blueprint(stats_bp, url_prefix='/api/stats')
|
||||
|
||||
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>
|
||||
</button>
|
||||
<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>
|
||||
@@ -140,6 +185,8 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
refreshScans();
|
||||
loadStats();
|
||||
loadScanTrend();
|
||||
loadSchedules();
|
||||
|
||||
// Auto-refresh every 10 seconds if there are running scans
|
||||
refreshInterval = setInterval(function() {
|
||||
@@ -149,6 +196,9 @@
|
||||
loadStats();
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
// Refresh schedules every 30 seconds
|
||||
setInterval(loadSchedules, 30000);
|
||||
});
|
||||
|
||||
// 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
|
||||
async function deleteScan(scanId) {
|
||||
if (!confirm(`Are you sure you want to delete scan ${scanId}?`)) {
|
||||
|
||||
Reference in New Issue
Block a user