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:
2025-11-14 14:50:20 -06:00
parent d68d9133c1
commit effce42f21
5 changed files with 688 additions and 4 deletions

151
web/api/stats.py Normal file
View 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

View File

@@ -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")

View File

@@ -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}?`)) {