Merge pull request #105 from christianlouis/codex/dashboard-trend-charts
feat: add dashboard trend charts
This commit is contained in:
@@ -13,7 +13,7 @@ router = APIRouter()
|
|||||||
async def get_dashboard_statistics(
|
async def get_dashboard_statistics(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
force_refresh: bool = Query(False, title="Force refresh of statistics"),
|
force_refresh: bool = Query(False, title="Force refresh of statistics"),
|
||||||
period_days: int = Query(30, title="Period in days for time-based statistics"),
|
period_days: int = Query(30, ge=1, le=365, title="Period in days for time-based statistics"),
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get optimized statistics for the dashboard using cached data when possible.
|
Get optimized statistics for the dashboard using cached data when possible.
|
||||||
@@ -34,7 +34,7 @@ async def get_dashboard_statistics(
|
|||||||
stats_summarizer.invalidate_cache()
|
stats_summarizer.invalidate_cache()
|
||||||
|
|
||||||
# Get statistics (from cache or calculate if needed)
|
# Get statistics (from cache or calculate if needed)
|
||||||
stats = stats_summarizer.calculate_summary_statistics(db)
|
stats = stats_summarizer.calculate_summary_statistics(db, period_days=period_days)
|
||||||
|
|
||||||
# Add version and timestamp
|
# Add version and timestamp
|
||||||
stats["api_version"] = "1.0"
|
stats["api_version"] = "1.0"
|
||||||
@@ -48,7 +48,7 @@ async def get_domain_statistics(
|
|||||||
domain_id: str = Path(..., title="The domain ID or name"),
|
domain_id: str = Path(..., title="The domain ID or name"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
force_refresh: bool = Query(False, title="Force refresh of statistics"),
|
force_refresh: bool = Query(False, title="Force refresh of statistics"),
|
||||||
period_days: int = Query(30, title="Period in days for time-based statistics"),
|
period_days: int = Query(30, ge=1, le=365, title="Period in days for time-based statistics"),
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get optimized statistics for a specific domain using cached data when possible.
|
Get optimized statistics for a specific domain using cached data when possible.
|
||||||
@@ -69,7 +69,7 @@ async def get_domain_statistics(
|
|||||||
stats_summarizer.invalidate_cache(domain_id)
|
stats_summarizer.invalidate_cache(domain_id)
|
||||||
|
|
||||||
# Get domain statistics (from cache or calculate if needed)
|
# Get domain statistics (from cache or calculate if needed)
|
||||||
stats = stats_summarizer.calculate_summary_statistics(db, domain_id)
|
stats = stats_summarizer.calculate_summary_statistics(db, domain_id, period_days=period_days)
|
||||||
|
|
||||||
# Add version and timestamp
|
# Add version and timestamp
|
||||||
stats["api_version"] = "1.0"
|
stats["api_version"] = "1.0"
|
||||||
|
|||||||
@@ -84,6 +84,37 @@
|
|||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Dashboard Trends -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4" x-show="hasDomainData" x-cloak>
|
||||||
|
{% call card() %}
|
||||||
|
{% call card_header() %}
|
||||||
|
{% call card_title() %}Mail Volume Trend{% endcall %}
|
||||||
|
{% call card_description() %}
|
||||||
|
Daily DMARC mail volume over the last 30 days
|
||||||
|
{% endcall %}
|
||||||
|
{% endcall %}
|
||||||
|
{% call card_content() %}
|
||||||
|
<div class="h-64">
|
||||||
|
<canvas id="volume-trend-chart" aria-label="Daily DMARC mail volume trend"></canvas>
|
||||||
|
</div>
|
||||||
|
{% endcall %}
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
{% call card() %}
|
||||||
|
{% call card_header() %}
|
||||||
|
{% call card_title() %}Authentication Trend{% endcall %}
|
||||||
|
{% call card_description() %}
|
||||||
|
Daily compliance and failure rates over the last 30 days
|
||||||
|
{% endcall %}
|
||||||
|
{% endcall %}
|
||||||
|
{% call card_content() %}
|
||||||
|
<div class="h-64">
|
||||||
|
<canvas id="compliance-trend-chart" aria-label="Daily DMARC compliance and failure trend"></canvas>
|
||||||
|
</div>
|
||||||
|
{% endcall %}
|
||||||
|
{% endcall %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Domain Compliance Table -->
|
<!-- Domain Compliance Table -->
|
||||||
<div x-show="hasDomainData" x-cloak>
|
<div x-show="hasDomainData" x-cloak>
|
||||||
@@ -184,10 +215,13 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
<script src="/static/js/chart.umd.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
function dashboardApp() {
|
function dashboardApp() {
|
||||||
return {
|
return {
|
||||||
hasDomainData: false,
|
hasDomainData: false,
|
||||||
|
volumeTrendChart: null,
|
||||||
|
complianceTrendChart: null,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Fetch domain summary on page load
|
// Fetch domain summary on page load
|
||||||
@@ -239,12 +273,30 @@ function dashboardApp() {
|
|||||||
this.hasDomainData = true;
|
this.hasDomainData = true;
|
||||||
this.updateDashboardStats(data);
|
this.updateDashboardStats(data);
|
||||||
this.populateDomainsTable(data.domains);
|
this.populateDomainsTable(data.domains);
|
||||||
|
this.$nextTick(() => this.fetchDashboardTrends());
|
||||||
} else {
|
} else {
|
||||||
this.hasDomainData = false;
|
this.hasDomainData = false;
|
||||||
|
this.clearDashboardCharts();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching domain summary:', error);
|
console.error('Error fetching domain summary:', error);
|
||||||
this.hasDomainData = false;
|
this.hasDomainData = false;
|
||||||
|
this.clearDashboardCharts();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchDashboardTrends() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/stats/dashboard?period_days=30');
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Error fetching dashboard trends:', response.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
this.renderDashboardCharts(data.compliance_trend || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching dashboard trends:', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -261,6 +313,164 @@ function dashboardApp() {
|
|||||||
if (passRate) passRate.textContent = `${data.overall_pass_rate || 0}%`;
|
if (passRate) passRate.textContent = `${data.overall_pass_rate || 0}%`;
|
||||||
if (reportsProcessed) reportsProcessed.textContent = data.reports_processed || 0;
|
if (reportsProcessed) reportsProcessed.textContent = data.reports_processed || 0;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
renderDashboardCharts(trendData) {
|
||||||
|
if (!window.Chart || !trendData.length) {
|
||||||
|
this.clearDashboardCharts();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderVolumeTrend(trendData);
|
||||||
|
this.renderComplianceTrend(trendData);
|
||||||
|
},
|
||||||
|
|
||||||
|
renderVolumeTrend(trendData) {
|
||||||
|
const canvas = document.getElementById('volume-trend-chart');
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const labels = trendData.map(item => item.date);
|
||||||
|
const volumes = trendData.map(item => item.volume || item.total || 0);
|
||||||
|
|
||||||
|
if (this.volumeTrendChart) {
|
||||||
|
this.volumeTrendChart.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.volumeTrendChart = new Chart(canvas.getContext('2d'), {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Messages',
|
||||||
|
data: volumes,
|
||||||
|
backgroundColor: 'rgba(37, 99, 235, 0.72)',
|
||||||
|
borderColor: 'rgb(37, 99, 235)',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 4,
|
||||||
|
maxBarThickness: 28
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
precision: 0,
|
||||||
|
callback: value => this.formatLargeNumber(value)
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Messages'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
grid: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: context => `${this.formatLargeNumber(context.parsed.y)} messages`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
renderComplianceTrend(trendData) {
|
||||||
|
const canvas = document.getElementById('compliance-trend-chart');
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const labels = trendData.map(item => item.date);
|
||||||
|
const complianceRates = trendData.map(item => item.compliance_rate ?? item.rate ?? 0);
|
||||||
|
const failureRates = trendData.map(item => item.failure_rate ?? 0);
|
||||||
|
|
||||||
|
if (this.complianceTrendChart) {
|
||||||
|
this.complianceTrendChart.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.complianceTrendChart = new Chart(canvas.getContext('2d'), {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Compliance Rate',
|
||||||
|
data: complianceRates,
|
||||||
|
borderColor: 'rgb(22, 163, 74)',
|
||||||
|
backgroundColor: 'rgba(22, 163, 74, 0.12)',
|
||||||
|
pointBackgroundColor: 'rgb(22, 163, 74)',
|
||||||
|
pointRadius: 3,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
tension: 0.35,
|
||||||
|
fill: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Failure Rate',
|
||||||
|
data: failureRates,
|
||||||
|
borderColor: 'rgb(220, 38, 38)',
|
||||||
|
backgroundColor: 'rgba(220, 38, 38, 0.08)',
|
||||||
|
pointBackgroundColor: 'rgb(220, 38, 38)',
|
||||||
|
pointRadius: 3,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
tension: 0.35,
|
||||||
|
fill: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
max: 100,
|
||||||
|
ticks: {
|
||||||
|
callback: value => `${value}%`
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Rate'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
grid: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: context => `${context.dataset.label}: ${context.parsed.y}%`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearDashboardCharts() {
|
||||||
|
if (this.volumeTrendChart) {
|
||||||
|
this.volumeTrendChart.destroy();
|
||||||
|
this.volumeTrendChart = null;
|
||||||
|
}
|
||||||
|
if (this.complianceTrendChart) {
|
||||||
|
this.complianceTrendChart.destroy();
|
||||||
|
this.complianceTrendChart = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
formatLargeNumber(value) {
|
||||||
|
return new Intl.NumberFormat(undefined, { notation: 'compact' }).format(value);
|
||||||
|
},
|
||||||
|
|
||||||
populateDomainsTable(domains) {
|
populateDomainsTable(domains) {
|
||||||
if (!domains || !domains.length) return;
|
if (!domains || !domains.length) return;
|
||||||
@@ -300,4 +510,4 @@ function dashboardApp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -32,6 +32,16 @@ class TestDashboardStatistics:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["period_days"] == 7
|
assert data["period_days"] == 7
|
||||||
|
|
||||||
|
def test_dashboard_passes_period_days_to_summarizer(self, client: TestClient):
|
||||||
|
with patch("app.api.api_v1.endpoints.stats.StatsSummarizer") as MockSummarizer:
|
||||||
|
mock_instance = MagicMock()
|
||||||
|
mock_instance.calculate_summary_statistics.return_value = {"total": 0}
|
||||||
|
MockSummarizer.return_value = mock_instance
|
||||||
|
|
||||||
|
response = client.get("/api/v1/stats/dashboard?period_days=7")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert mock_instance.calculate_summary_statistics.call_args.kwargs["period_days"] == 7
|
||||||
|
|
||||||
def test_dashboard_force_refresh(self, client: TestClient):
|
def test_dashboard_force_refresh(self, client: TestClient):
|
||||||
"""force_refresh=true should trigger cache invalidation without error."""
|
"""force_refresh=true should trigger cache invalidation without error."""
|
||||||
response = client.get("/api/v1/stats/dashboard?force_refresh=true")
|
response = client.get("/api/v1/stats/dashboard?force_refresh=true")
|
||||||
@@ -85,6 +95,17 @@ class TestDomainStatistics:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["period_days"] == 14
|
assert data["period_days"] == 14
|
||||||
|
|
||||||
|
def test_domain_stats_passes_period_days_to_summarizer(self, client: TestClient):
|
||||||
|
with patch("app.api.api_v1.endpoints.stats.StatsSummarizer") as MockSummarizer:
|
||||||
|
mock_instance = MagicMock()
|
||||||
|
mock_instance.calculate_summary_statistics.return_value = {"total": 0}
|
||||||
|
MockSummarizer.return_value = mock_instance
|
||||||
|
|
||||||
|
response = client.get("/api/v1/stats/domain/example.com?period_days=14")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert mock_instance.calculate_summary_statistics.call_args.args[1] == "example.com"
|
||||||
|
assert mock_instance.calculate_summary_statistics.call_args.kwargs["period_days"] == 14
|
||||||
|
|
||||||
def test_domain_stats_force_refresh(self, client: TestClient):
|
def test_domain_stats_force_refresh(self, client: TestClient):
|
||||||
response = client.get("/api/v1/stats/domain/example.com?force_refresh=true")
|
response = client.get("/api/v1/stats/domain/example.com?force_refresh=true")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
@@ -122,6 +123,83 @@ def _seed_mixed_source_records(db, domain_name="example.com"):
|
|||||||
return domain
|
return domain
|
||||||
|
|
||||||
|
|
||||||
|
def _timestamp_days_ago(days):
|
||||||
|
return int((datetime.now(timezone.utc) - timedelta(days=days)).timestamp())
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_recent_trend_records(db, domain_name="example.com"):
|
||||||
|
"""Insert recent reports across multiple days for trend calculations."""
|
||||||
|
domain = Domain(name=domain_name)
|
||||||
|
db.add(domain)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
report1 = DMARCReport(
|
||||||
|
domain_id=domain.id,
|
||||||
|
report_id=f"{domain_name}-recent-1",
|
||||||
|
org_name="google.com",
|
||||||
|
begin_date=_timestamp_days_ago(2),
|
||||||
|
end_date=_timestamp_days_ago(2) + 3600,
|
||||||
|
policy="none",
|
||||||
|
)
|
||||||
|
report2 = DMARCReport(
|
||||||
|
domain_id=domain.id,
|
||||||
|
report_id=f"{domain_name}-recent-2",
|
||||||
|
org_name="google.com",
|
||||||
|
begin_date=_timestamp_days_ago(0),
|
||||||
|
end_date=_timestamp_days_ago(0) + 3600,
|
||||||
|
policy="none",
|
||||||
|
)
|
||||||
|
report3 = DMARCReport(
|
||||||
|
domain_id=domain.id,
|
||||||
|
report_id=f"{domain_name}-old",
|
||||||
|
org_name="google.com",
|
||||||
|
begin_date=_timestamp_days_ago(20),
|
||||||
|
end_date=_timestamp_days_ago(20) + 3600,
|
||||||
|
policy="none",
|
||||||
|
)
|
||||||
|
db.add_all([report1, report2, report3])
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
ReportRecord(
|
||||||
|
report_id=report1.id,
|
||||||
|
source_ip="203.0.113.10",
|
||||||
|
count=6,
|
||||||
|
disposition="none",
|
||||||
|
dkim="pass",
|
||||||
|
spf="fail",
|
||||||
|
),
|
||||||
|
ReportRecord(
|
||||||
|
report_id=report1.id,
|
||||||
|
source_ip="203.0.113.11",
|
||||||
|
count=4,
|
||||||
|
disposition="reject",
|
||||||
|
dkim="fail",
|
||||||
|
spf="fail",
|
||||||
|
),
|
||||||
|
ReportRecord(
|
||||||
|
report_id=report2.id,
|
||||||
|
source_ip="203.0.113.12",
|
||||||
|
count=5,
|
||||||
|
disposition="none",
|
||||||
|
dkim="pass",
|
||||||
|
spf="pass",
|
||||||
|
),
|
||||||
|
ReportRecord(
|
||||||
|
report_id=report3.id,
|
||||||
|
source_ip="203.0.113.13",
|
||||||
|
count=99,
|
||||||
|
disposition="none",
|
||||||
|
dkim="pass",
|
||||||
|
spf="pass",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
return domain
|
||||||
|
|
||||||
|
|
||||||
def test_auth_status_from_counts_returns_none_without_results():
|
def test_auth_status_from_counts_returns_none_without_results():
|
||||||
assert _auth_status_from_counts(0, 0) == "none"
|
assert _auth_status_from_counts(0, 0) == "none"
|
||||||
|
|
||||||
@@ -187,6 +265,30 @@ class TestStatsSummarizerGlobal:
|
|||||||
assert stats["total_emails"] == 16 # 8 * 2
|
assert stats["total_emails"] == 16 # 8 * 2
|
||||||
assert stats["reports_processed"] == 2
|
assert stats["reports_processed"] == 2
|
||||||
|
|
||||||
|
def test_global_trend_includes_volume_and_failure_rate(self, db_session, summarizer):
|
||||||
|
_seed_recent_trend_records(db_session)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
stats = summarizer.calculate_summary_statistics(db_session, period_days=7)
|
||||||
|
assert len(stats["compliance_trend"]) == 2
|
||||||
|
|
||||||
|
first_day = stats["compliance_trend"][0]
|
||||||
|
assert first_day["total"] == 10
|
||||||
|
assert first_day["volume"] == 10
|
||||||
|
assert first_day["passed"] == 6
|
||||||
|
assert first_day["failed"] == 4
|
||||||
|
assert first_day["rate"] == 60.0
|
||||||
|
assert first_day["compliance_rate"] == 60.0
|
||||||
|
assert first_day["failure_rate"] == 40.0
|
||||||
|
|
||||||
|
def test_global_trend_respects_period_days(self, db_session, summarizer):
|
||||||
|
_seed_recent_trend_records(db_session)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
stats = summarizer.calculate_summary_statistics(db_session, period_days=1)
|
||||||
|
assert len(stats["compliance_trend"]) == 1
|
||||||
|
assert stats["compliance_trend"][0]["total"] == 5
|
||||||
|
|
||||||
|
|
||||||
class TestStatsSummarizerDomain:
|
class TestStatsSummarizerDomain:
|
||||||
"""Tests for domain-specific statistics."""
|
"""Tests for domain-specific statistics."""
|
||||||
@@ -245,6 +347,16 @@ class TestStatsSummarizerDomain:
|
|||||||
stats = summarizer.calculate_summary_statistics(db_session, domain_id="example.com")
|
stats = summarizer.calculate_summary_statistics(db_session, domain_id="example.com")
|
||||||
assert stats["total_emails"] == 8 # Only example.com's data
|
assert stats["total_emails"] == 8 # Only example.com's data
|
||||||
|
|
||||||
|
def test_domain_trend_isolation(self, db_session, summarizer):
|
||||||
|
_seed_recent_trend_records(db_session, "example.com")
|
||||||
|
_seed_recent_trend_records(db_session, "other.org")
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
stats = summarizer.calculate_summary_statistics(
|
||||||
|
db_session, domain_id="example.com", period_days=7
|
||||||
|
)
|
||||||
|
assert [point["total"] for point in stats["compliance_trend"]] == [10, 5]
|
||||||
|
|
||||||
|
|
||||||
class TestStatsSummarizerCaching:
|
class TestStatsSummarizerCaching:
|
||||||
"""Tests for the caching layer."""
|
"""Tests for the caching layer."""
|
||||||
@@ -266,3 +378,13 @@ class TestStatsSummarizerCaching:
|
|||||||
# Should recalculate after invalidation
|
# Should recalculate after invalidation
|
||||||
stats = summarizer.calculate_summary_statistics(db_session)
|
stats = summarizer.calculate_summary_statistics(db_session)
|
||||||
assert stats["total_domains"] == 1
|
assert stats["total_domains"] == 1
|
||||||
|
|
||||||
|
def test_period_days_uses_separate_cache_files(self, db_session, summarizer):
|
||||||
|
_seed_recent_trend_records(db_session)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
stats_7_days = summarizer.calculate_summary_statistics(db_session, period_days=7)
|
||||||
|
stats_1_day = summarizer.calculate_summary_statistics(db_session, period_days=1)
|
||||||
|
|
||||||
|
assert len(stats_7_days["compliance_trend"]) == 2
|
||||||
|
assert len(stats_1_day["compliance_trend"]) == 1
|
||||||
|
|||||||
@@ -52,7 +52,10 @@ class StatsSummarizer:
|
|||||||
os.makedirs(self.cache_dir, exist_ok=True)
|
os.makedirs(self.cache_dir, exist_ok=True)
|
||||||
|
|
||||||
def get_cached_summary(
|
def get_cached_summary(
|
||||||
self, domain_id: Optional[str] = None, max_age_minutes: int = 60
|
self,
|
||||||
|
domain_id: Optional[str] = None,
|
||||||
|
max_age_minutes: int = 60,
|
||||||
|
period_days: int = 30,
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get cached summary statistics if available and not too old
|
Get cached summary statistics if available and not too old
|
||||||
@@ -61,11 +64,12 @@ class StatsSummarizer:
|
|||||||
domain_id: Optional domain ID to get domain-specific stats
|
domain_id: Optional domain ID to get domain-specific stats
|
||||||
If None, gets global summary
|
If None, gets global summary
|
||||||
max_age_minutes: Maximum age of cache in minutes
|
max_age_minutes: Maximum age of cache in minutes
|
||||||
|
period_days: Number of days used for time-based trend data
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Cached statistics or None if not available or too old
|
Cached statistics or None if not available or too old
|
||||||
"""
|
"""
|
||||||
cache_file = self._get_cache_filename(domain_id)
|
cache_file = self._get_cache_filename(domain_id, period_days)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not os.path.exists(cache_file):
|
if not os.path.exists(cache_file):
|
||||||
@@ -86,18 +90,21 @@ class StatsSummarizer:
|
|||||||
logger.warning("Error reading cache file %s: %s", cache_file, str(e))
|
logger.warning("Error reading cache file %s: %s", cache_file, str(e))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def save_summary(self, stats: Dict[str, Any], domain_id: Optional[str] = None) -> bool:
|
def save_summary(
|
||||||
|
self, stats: Dict[str, Any], domain_id: Optional[str] = None, period_days: int = 30
|
||||||
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Save summary statistics to cache
|
Save summary statistics to cache
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
stats: Dictionary of statistics to cache
|
stats: Dictionary of statistics to cache
|
||||||
domain_id: Optional domain ID for domain-specific stats
|
domain_id: Optional domain ID for domain-specific stats
|
||||||
|
period_days: Number of days used for time-based trend data
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if save was successful, False otherwise
|
True if save was successful, False otherwise
|
||||||
"""
|
"""
|
||||||
cache_file = self._get_cache_filename(domain_id)
|
cache_file = self._get_cache_filename(domain_id, period_days)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Add timestamp
|
# Add timestamp
|
||||||
@@ -121,34 +128,39 @@ class StatsSummarizer:
|
|||||||
If None, invalidates global summary cache
|
If None, invalidates global summary cache
|
||||||
"""
|
"""
|
||||||
if domain_id is None:
|
if domain_id is None:
|
||||||
# Invalidate all caches
|
self._remove_cache_files("global_summary")
|
||||||
cache_file = self._get_cache_filename()
|
|
||||||
if os.path.exists(cache_file):
|
|
||||||
os.remove(cache_file)
|
|
||||||
else:
|
else:
|
||||||
# Invalidate specific domain cache
|
safe_domain = domain_id.replace(".", "_").replace("/", "_")
|
||||||
cache_file = self._get_cache_filename(domain_id)
|
self._remove_cache_files(f"domain_{safe_domain}")
|
||||||
if os.path.exists(cache_file):
|
|
||||||
os.remove(cache_file)
|
|
||||||
|
|
||||||
def _get_cache_filename(self, domain_id: Optional[str] = None) -> str:
|
def _remove_cache_files(self, prefix: str) -> None:
|
||||||
|
"""Remove cached summary files that begin with the provided prefix."""
|
||||||
|
for filename in os.listdir(self.cache_dir):
|
||||||
|
if filename.startswith(prefix) and filename.endswith(".json"):
|
||||||
|
os.remove(os.path.join(self.cache_dir, filename))
|
||||||
|
|
||||||
|
def _get_cache_filename(
|
||||||
|
self, domain_id: Optional[str] = None, period_days: int = 30
|
||||||
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Get the filename for a cache file
|
Get the filename for a cache file
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
domain_id: Optional domain ID for domain-specific cache
|
domain_id: Optional domain ID for domain-specific cache
|
||||||
|
period_days: Number of days used for time-based trend data
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Path to the cache file
|
Path to the cache file
|
||||||
"""
|
"""
|
||||||
|
period_days = max(1, int(period_days or 30))
|
||||||
if domain_id is None:
|
if domain_id is None:
|
||||||
return os.path.join(self.cache_dir, "global_summary.json")
|
return os.path.join(self.cache_dir, f"global_summary_{period_days}d.json")
|
||||||
# Sanitize domain_id to use as filename
|
# Sanitize domain_id to use as filename
|
||||||
safe_domain = domain_id.replace(".", "_").replace("/", "_")
|
safe_domain = domain_id.replace(".", "_").replace("/", "_")
|
||||||
return os.path.join(self.cache_dir, f"domain_{safe_domain}.json")
|
return os.path.join(self.cache_dir, f"domain_{safe_domain}_{period_days}d.json")
|
||||||
|
|
||||||
def calculate_summary_statistics(
|
def calculate_summary_statistics(
|
||||||
self, db: Session, domain_id: Optional[str] = None
|
self, db: Session, domain_id: Optional[str] = None, period_days: int = 30
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Calculate summary statistics from the database
|
Calculate summary statistics from the database
|
||||||
@@ -156,26 +168,29 @@ class StatsSummarizer:
|
|||||||
Args:
|
Args:
|
||||||
db: Database session
|
db: Database session
|
||||||
domain_id: Optional domain ID to calculate domain-specific stats
|
domain_id: Optional domain ID to calculate domain-specific stats
|
||||||
|
period_days: Number of days used for time-based trend data
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with summary statistics
|
Dictionary with summary statistics
|
||||||
"""
|
"""
|
||||||
|
period_days = max(1, int(period_days or 30))
|
||||||
|
|
||||||
# First check if we have cached stats
|
# First check if we have cached stats
|
||||||
cached_stats = self.get_cached_summary(domain_id)
|
cached_stats = self.get_cached_summary(domain_id, period_days=period_days)
|
||||||
if cached_stats:
|
if cached_stats:
|
||||||
return cached_stats
|
return cached_stats
|
||||||
|
|
||||||
if domain_id is None:
|
if domain_id is None:
|
||||||
stats = self._calculate_global_statistics(db)
|
stats = self._calculate_global_statistics(db, period_days)
|
||||||
else:
|
else:
|
||||||
stats = self._calculate_domain_statistics(db, domain_id)
|
stats = self._calculate_domain_statistics(db, domain_id, period_days)
|
||||||
|
|
||||||
# Cache the statistics
|
# Cache the statistics
|
||||||
self.save_summary(stats, domain_id)
|
self.save_summary(stats, domain_id, period_days)
|
||||||
|
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
def _calculate_global_statistics(self, db: Session) -> Dict[str, Any]:
|
def _calculate_global_statistics(self, db: Session, period_days: int = 30) -> Dict[str, Any]:
|
||||||
"""Calculate global statistics across all domains from the database."""
|
"""Calculate global statistics across all domains from the database."""
|
||||||
# Count total domains
|
# Count total domains
|
||||||
total_domains = db.query(func.count(Domain.id)).scalar() or 0
|
total_domains = db.query(func.count(Domain.id)).scalar() or 0
|
||||||
@@ -206,7 +221,7 @@ class StatsSummarizer:
|
|||||||
top_sources = self._get_top_sources(db)
|
top_sources = self._get_top_sources(db)
|
||||||
|
|
||||||
# Compliance trend over recent days
|
# Compliance trend over recent days
|
||||||
compliance_trend = self._get_compliance_trend(db)
|
compliance_trend = self._get_compliance_trend(db, days=period_days)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total_domains": total_domains,
|
"total_domains": total_domains,
|
||||||
@@ -218,7 +233,9 @@ class StatsSummarizer:
|
|||||||
"compliance_trend": compliance_trend,
|
"compliance_trend": compliance_trend,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _calculate_domain_statistics(self, db: Session, domain_id: str) -> Dict[str, Any]:
|
def _calculate_domain_statistics(
|
||||||
|
self, db: Session, domain_id: str, period_days: int = 30
|
||||||
|
) -> Dict[str, Any]:
|
||||||
"""Calculate statistics for a specific domain from the database."""
|
"""Calculate statistics for a specific domain from the database."""
|
||||||
# Look up the domain by name
|
# Look up the domain by name
|
||||||
domain = db.query(Domain).filter(Domain.name == domain_id).first()
|
domain = db.query(Domain).filter(Domain.name == domain_id).first()
|
||||||
@@ -266,7 +283,7 @@ class StatsSummarizer:
|
|||||||
sources = self._get_domain_sources(db, domain.id)
|
sources = self._get_domain_sources(db, domain.id)
|
||||||
|
|
||||||
# Compliance trend for this domain
|
# Compliance trend for this domain
|
||||||
compliance_trend = self._get_compliance_trend(db, domain.id)
|
compliance_trend = self._get_compliance_trend(db, domain.id, days=period_days)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"domain": domain_id,
|
"domain": domain_id,
|
||||||
@@ -445,7 +462,22 @@ class StatsSummarizer:
|
|||||||
trend = []
|
trend = []
|
||||||
for date_str in sorted(daily.keys()):
|
for date_str in sorted(daily.keys()):
|
||||||
data = daily[date_str]
|
data = daily[date_str]
|
||||||
rate = round((data["passed"] / data["total"]) * 100, 1) if data["total"] > 0 else 0.0
|
total = data["total"]
|
||||||
trend.append({"date": date_str, "rate": rate})
|
passed = data["passed"]
|
||||||
|
failed = max(0, total - passed)
|
||||||
|
compliance_rate = round((passed / total) * 100, 1) if total > 0 else 0.0
|
||||||
|
failure_rate = round((failed / total) * 100, 1) if total > 0 else 0.0
|
||||||
|
trend.append(
|
||||||
|
{
|
||||||
|
"date": date_str,
|
||||||
|
"total": total,
|
||||||
|
"volume": total,
|
||||||
|
"passed": passed,
|
||||||
|
"failed": failed,
|
||||||
|
"rate": compliance_rate,
|
||||||
|
"compliance_rate": compliance_rate,
|
||||||
|
"failure_rate": failure_rate,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return trend
|
return trend
|
||||||
|
|||||||
@@ -41,12 +41,14 @@ Implementation note:
|
|||||||
Objective: turn parsed DMARC data into administrator-friendly reports.
|
Objective: turn parsed DMARC data into administrator-friendly reports.
|
||||||
|
|
||||||
Priority tasks:
|
Priority tasks:
|
||||||
- Add time-series charts for volume and compliance.
|
|
||||||
- Add per-domain daily rollups.
|
- Add per-domain daily rollups.
|
||||||
- Add "what changed" summaries for newly observed senders and sudden compliance drops.
|
- Add "what changed" summaries for newly observed senders and sudden compliance drops.
|
||||||
- Add exportable reports for a domain and date range.
|
- Add exportable reports for a domain and date range.
|
||||||
- Add actionable recommendations for common SPF, DKIM, and DMARC failure patterns.
|
- Add actionable recommendations for common SPF, DKIM, and DMARC failure patterns.
|
||||||
|
|
||||||
|
Delivered:
|
||||||
|
- Dashboard time-series charts show daily mail volume, compliance rate, and failure rate.
|
||||||
|
|
||||||
Quality bar:
|
Quality bar:
|
||||||
- A domain owner can understand who sends mail as their domain, which sources fail, and what to fix next.
|
- A domain owner can understand who sends mail as their domain, which sources fail, and what to fix next.
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -77,8 +77,10 @@ Status: In progress
|
|||||||
|
|
||||||
Goal: convert raw DMARC data into useful operational reporting.
|
Goal: convert raw DMARC data into useful operational reporting.
|
||||||
|
|
||||||
Planned:
|
Delivered:
|
||||||
- Dashboard trend charts for volume, compliance rate, and failure rate.
|
- Dashboard trend charts for volume, compliance rate, and failure rate.
|
||||||
|
|
||||||
|
Planned:
|
||||||
- Top sender/source reports with pass/fail breakdowns.
|
- Top sender/source reports with pass/fail breakdowns.
|
||||||
- Per-domain report timeline and daily rollups.
|
- Per-domain report timeline and daily rollups.
|
||||||
- Exportable reports for a selected domain and date range.
|
- Exportable reports for a selected domain and date range.
|
||||||
|
|||||||
+4
-4
@@ -128,16 +128,16 @@ This file tracks the specific implementation tasks for each milestone of the DMA
|
|||||||
## Milestone 4: Dashboard Enhancements
|
## Milestone 4: Dashboard Enhancements
|
||||||
|
|
||||||
### Data Visualization
|
### Data Visualization
|
||||||
- [ ] Integrate Chart.js library
|
- [x] Integrate Chart.js library
|
||||||
- [ ] Create time-series charts for DMARC compliance
|
- [x] Create time-series charts for DMARC compliance
|
||||||
- [ ] Add volume charts for email traffic
|
- [x] Add volume charts for email traffic
|
||||||
- [ ] Implement sender breakdown visualizations
|
- [ ] Implement sender breakdown visualizations
|
||||||
- [ ] Create policy distribution charts
|
- [ ] Create policy distribution charts
|
||||||
|
|
||||||
### Dashboard Widgets
|
### Dashboard Widgets
|
||||||
- [ ] Create compliance rate summary widget
|
- [ ] Create compliance rate summary widget
|
||||||
- [ ] Add enforcement rate widget
|
- [ ] Add enforcement rate widget
|
||||||
- [ ] Implement email volume trends widget
|
- [x] Implement email volume trends widget
|
||||||
- [ ] Create top sender sources widget
|
- [ ] Create top sender sources widget
|
||||||
- [ ] Add alert status summary (for later integration)
|
- [ ] Add alert status summary (for later integration)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user